diff --git a/.vscode/launch.json b/.vscode/launch.json index 815c87652..e6f542a9b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,33 +5,47 @@ "version": "0.2.0", "configurations": [ { - "name": "Flutter Profile", + "name": "dev", "type": "dart", "request": "launch", "program": "lib/main.dart", - "flutterMode": "profile" + "args": [ + // TODO: remove this when intl_translation supports nnbd https://github.com/dart-lang/intl_translation/issues/134 + "--no-sound-null-safety" + ], + "codeLens": { + "for": [ + "run-file", + "debug-file", + "run-test", + "run-test-file", + "debug-test", + "debug-test-file", + ], + // TODO: remove this intl_translation supports nnbd + // See also https://github.com/Dart-Code/Dart-Code/issues/3356 + "title": "${debugType} (--no-sound-null-safety)" + } }, { - "name": "Flutter Release", + "name": "profile", "type": "dart", "request": "launch", "program": "lib/main.dart", - "flutterMode": "release" - }, - { - "name": "Flutter Dev", - "type": "dart", - "request": "launch", - "program": "lib/main.dart" + "flutterMode": "profile", + "args": [ + "--no-sound-null-safety" + ] }, { - "name": "Flutter Dev (fast-start)", + "name": "release", "type": "dart", "request": "launch", "program": "lib/main.dart", + "flutterMode": "release", "args": [ - "--fast-start" + "--no-sound-null-safety" ] - }, + } ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 766d3e778..87a533825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.0.4 + +- Added playlists and artists +- Added more selection actions +- In deletion prompts added a preview of the arts of the content that is being deleted +- Other fixes and cosmetic changes + ## 1.0.3 - Fix issue with current song not updated on search route @@ -13,7 +20,7 @@ was saved only for the current app session, when it was added, and was not restored. - Optimized scrollbar and listviews - Became media browser service, support for Android Auto -- Fixed that album arts were not recahced (below Android 11) +- Fixed that album arts were not recached (below Android 10) - Refactored a lot of code and fixed a lot of other bugs - Changed player backend (non-UX) diff --git a/analysis_options.yaml b/analysis_options.yaml index a075b40b9..6cf90d90b 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -51,7 +51,7 @@ linter: # - avoid_setters_without_getters # not yet tested - avoid_shadowing_type_parameters - avoid_single_cascade_in_expression_statements - # - avoid_slow_async_io # DISABLED_BY_ME + - avoid_slow_async_io - avoid_type_to_string - avoid_types_as_parameter_names # - avoid_types_on_closure_parameters # conflicts with always_specify_types @@ -187,7 +187,7 @@ linter: - use_is_even_rather_than_modulo - use_key_in_widget_constructors - use_late_for_private_fields_and_variables - # - use_named_constants # not yet tested + - use_named_constants - use_raw_strings - use_rethrow_when_possible # - use_setters_to_change_properties # not yet tested diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro deleted file mode 100644 index b1d8733d3..000000000 --- a/android/app/proguard-rules.pro +++ /dev/null @@ -1,4 +0,0 @@ --keepclassmembers class com.nt4f04und.sweyer.player.Album { *; } --keepclassmembers class com.nt4f04und.sweyer.player.Artist { *; } --keepclassmembers class com.nt4f04und.sweyer.player.Playlist { *; } --keepclassmembers class com.nt4f04und.sweyer.player.Song { *; } \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 236cf94b5..76f41ad19 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -69,14 +69,7 @@ - - - - + diff --git a/android/app/src/main/java/com/nt4f04und/sweyer/Constants.java b/android/app/src/main/java/com/nt4f04und/sweyer/Constants.java index 01184ad6d..94c41197e 100644 --- a/android/app/src/main/java/com/nt4f04und/sweyer/Constants.java +++ b/android/app/src/main/java/com/nt4f04und/sweyer/Constants.java @@ -8,7 +8,13 @@ public class Constants { public static final String LogTag = "com.nt4f04und.sweyer"; - public static final class intents { - public static final int PERMANENT_DELETION_REQUEST = 0; + public enum intents { + FAVORITE_REQUEST(0), + PERMANENT_DELETION_REQUEST(1); + + public final int value; + intents(int value) { + this.value = value; + } } } \ No newline at end of file diff --git a/android/app/src/main/java/com/nt4f04und/sweyer/MainActivity.java b/android/app/src/main/java/com/nt4f04und/sweyer/MainActivity.java index 4f2151a7c..48c452729 100644 --- a/android/app/src/main/java/com/nt4f04und/sweyer/MainActivity.java +++ b/android/app/src/main/java/com/nt4f04und/sweyer/MainActivity.java @@ -9,16 +9,15 @@ import android.content.Intent; import android.os.Bundle; -import com.nt4f04und.sweyer.channels.GeneralChannel; +import androidx.annotation.Nullable; + import com.nt4f04und.sweyer.channels.ContentChannel; import com.nt4f04und.sweyer.handlers.GeneralHandler; - -import androidx.annotation.Nullable; +import com.ryanheise.audioservice.AudioServicePlugin; import io.flutter.embedding.android.FlutterActivity; import io.flutter.embedding.engine.FlutterEngine; import io.flutter.plugin.common.BinaryMessenger; -import com.ryanheise.audioservice.AudioServicePlugin; public class MainActivity extends FlutterActivity { @@ -27,8 +26,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); GeneralHandler.init(getApplicationContext()); BinaryMessenger messenger = getBinaryMessenger(); - GeneralChannel.instance.init(messenger, this); - ContentChannel.instance.init(messenger); + ContentChannel.instance.init(messenger, this); } BinaryMessenger getBinaryMessenger() { @@ -43,15 +41,14 @@ public FlutterEngine provideFlutterEngine(Context context) { @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - if (requestCode == Constants.intents.PERMANENT_DELETION_REQUEST) { - // Report deletion intent result on android R - ContentChannel.instance.sendDeletionResult(resultCode == RESULT_OK); + if (requestCode == Constants.intents.PERMANENT_DELETION_REQUEST.value || + requestCode == Constants.intents.FAVORITE_REQUEST.value) { + ContentChannel.instance.sendResultFromIntent(resultCode == RESULT_OK); } } @Override protected void onDestroy() { - GeneralChannel.instance.destroy(); ContentChannel.instance.destroy(); super.onDestroy(); } diff --git a/android/app/src/main/java/com/nt4f04und/sweyer/channels/ContentChannel.java b/android/app/src/main/java/com/nt4f04und/sweyer/channels/ContentChannel.java index 71da75346..893b11125 100644 --- a/android/app/src/main/java/com/nt4f04und/sweyer/channels/ContentChannel.java +++ b/android/app/src/main/java/com/nt4f04und/sweyer/channels/ContentChannel.java @@ -5,8 +5,14 @@ package com.nt4f04und.sweyer.channels; +import android.app.Activity; +import android.app.PendingIntent; import android.content.ContentResolver; import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Intent; +import android.content.IntentSender; +import android.database.Cursor; import android.graphics.Bitmap; import android.net.Uri; import android.os.Build; @@ -14,26 +20,28 @@ import android.os.Handler; import android.os.Looper; import android.os.OperationCanceledException; +import android.provider.MediaStore; import android.util.Log; import android.util.Size; -import com.nt4f04und.sweyer.BuildConfig; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + import com.nt4f04und.sweyer.Constants; import com.nt4f04und.sweyer.handlers.FetchHandler; import com.nt4f04und.sweyer.handlers.GeneralHandler; +import com.nt4f04und.sweyer.services.DeletionService; import org.jetbrains.annotations.NotNull; -import androidx.annotation.Nullable; - import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import io.flutter.embedding.android.FlutterActivity; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -41,8 +49,9 @@ public enum ContentChannel { instance; - public void init(BinaryMessenger messenger) { + public void init(BinaryMessenger messenger, FlutterActivity activity) { if (channel == null) { + this.activity = activity; channel = new MethodChannel(messenger, "content_channel"); channel.setMethodCallHandler(this::onMethodCall); } @@ -50,26 +59,34 @@ public void init(BinaryMessenger messenger) { public void destroy() { channel = null; + activity = null; } @Nullable MethodChannel channel; @Nullable private MethodChannel.Result result; + @Nullable + public Activity activity; private final HashMap loadingSignals = new HashMap<>(); + private static final String UNEXPECTED_ERROR = "UNEXPECTED_ERROR"; + private static final String INTENT_SENDER_ERROR = "INTENT_SENDER_ERROR"; + private static final String IO_ERROR = "IO_ERROR"; + private static final String SDK_ERROR = "SDK_ERROR"; + private static final String PLAYLIST_NOT_EXISTS_ERROR = "PLAYLIST_NOT_EXISTS_ERROR"; + public void onMethodCall(MethodCall call, @NotNull MethodChannel.Result result) { try { switch (call.method) { case "loadAlbumArt": { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ExecutorService executor = Executors.newSingleThreadExecutor(); Handler handler = new Handler(Looper.getMainLooper()); - ContentResolver contentResolver = GeneralHandler.getAppContext().getContentResolver(); + ContentResolver contentResolver = getContentResolver(); CancellationSignal signal = new CancellationSignal(); String id = call.argument("id"); loadingSignals.put(id, signal); - executor.execute(() -> { + Executors.newSingleThreadExecutor().execute(() -> { byte[] bytes = null; try { Bitmap bitmap = contentResolver.loadThumbnail( @@ -83,9 +100,8 @@ public void onMethodCall(MethodCall call, @NotNull MethodChannel.Result result) } catch (OperationCanceledException ex) { // do nothing } catch (IOException e) { - Log.e(Constants.LogTag,"loadThumbnail failed"); - e.printStackTrace(); - } finally { + result.error(IO_ERROR, "loadThumbnail failed", Log.getStackTraceString(e)); + } finally { byte[] finalBytes = bytes; handler.post(() -> { loadingSignals.remove(id); @@ -94,7 +110,7 @@ public void onMethodCall(MethodCall call, @NotNull MethodChannel.Result result) } }); } else { - result.error("0", "This method requires Android Q and above", ""); + result.error(SDK_ERROR, "This method requires Android 29 and above", ""); } break; } @@ -108,63 +124,344 @@ public void onMethodCall(MethodCall call, @NotNull MethodChannel.Result result) break; } case "fixAlbumArt": { - ExecutorService executor = Executors.newSingleThreadExecutor(); Handler handler = new Handler(Looper.getMainLooper()); - executor.execute(() -> { - Object rawId = call.argument("id"); - Long id; - if (rawId instanceof Long) { - id = (Long) rawId; - } else if (rawId instanceof Integer) { - id = Long.valueOf((Integer) rawId); - } else { - throw new IllegalArgumentException(); - } - Uri songCover = Uri.parse("content://media/external/audio/albumart"); - Uri uriSongCover = ContentUris.withAppendedId(songCover, id); - ContentResolver res = GeneralChannel.instance.activity.getContentResolver(); + Executors.newSingleThreadExecutor().execute(() -> { try { - InputStream is = res.openInputStream(uriSongCover); - is.close(); - } catch (Exception ex) { - // do nothing - ex.printStackTrace(); + Long id = GeneralHandler.getLong(call.argument("id")); + Uri songCover = Uri.parse("content://media/external/audio/albumart"); + Uri uriSongCover = ContentUris.withAppendedId(songCover, id); + ContentResolver res = getContentResolver(); + try { + InputStream is = res.openInputStream(uriSongCover); + is.close(); + } catch (Exception ex) { + // do nothing + } + handler.post(() -> { + result.success(null); + }); + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); } - handler.post(() -> { - result.success(null); - }); }); break; } case "retrieveSongs": { - ExecutorService executor = Executors.newSingleThreadExecutor(); Handler handler = new Handler(Looper.getMainLooper()); - executor.execute(() -> { - ArrayList> res = FetchHandler.retrieveSongs(); - handler.post(() -> { - result.success(res); - }); + Executors.newSingleThreadExecutor().execute(() -> { + try { + ArrayList> res = FetchHandler.retrieveSongs(); + handler.post(() -> { + result.success(res); + }); + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + } }); break; } case "retrieveAlbums": { - ExecutorService executor = Executors.newSingleThreadExecutor(); Handler handler = new Handler(Looper.getMainLooper()); - executor.execute(() -> { - ArrayList> res = FetchHandler.retrieveAlbums(); - handler.post(() -> { - result.success(res); - }); + Executors.newSingleThreadExecutor().execute(() -> { + try { + ArrayList> res = FetchHandler.retrieveAlbums(); + handler.post(() -> { + result.success(res); + }); + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + } }); break; } - case "deleteSongs": { - FetchHandler.deleteSongs(call.argument("songs")); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Save the result to report to the flutter code later in `sendDeletionResult` + case "retrievePlaylists": { + Handler handler = new Handler(Looper.getMainLooper()); + Executors.newSingleThreadExecutor().execute(() -> { + try { + ArrayList> res = FetchHandler.retrievePlaylists(); + handler.post(() -> { + result.success(res); + }); + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "retrieveArtists": { + Handler handler = new Handler(Looper.getMainLooper()); + Executors.newSingleThreadExecutor().execute(() -> { + try { + ArrayList> res = FetchHandler.retrieveArtists(); + handler.post(() -> { + result.success(res); + }); + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "retrieveGenres": { + Handler handler = new Handler(Looper.getMainLooper()); + Executors.newSingleThreadExecutor().execute(() -> { + try { + ArrayList> res = FetchHandler.retrieveGenres(); + handler.post(() -> { + result.success(res); + }); + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "setSongsFavorite": { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { this.result = result; + Boolean value = call.argument("value"); + ArrayList> songs = call.argument("songs"); + ArrayList uris = new ArrayList<>(); + for (HashMap song : songs) { + Long id = GeneralHandler.getLong(song.get("id")); + uris.add(ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)); + } + PendingIntent pendingIntent = MediaStore.createFavoriteRequest( + GeneralHandler.getAppContext().getContentResolver(), + uris, + value + ); + startIntentSenderForResult(pendingIntent, Constants.intents.FAVORITE_REQUEST); + } else { + result.error(SDK_ERROR, "This method requires Android 30 and above", ""); + } + break; + } + case "deleteSongs": { + Intent serviceIntent = new Intent(GeneralHandler.getAppContext(), DeletionService.class); + serviceIntent.putExtra("songs", (ArrayList>) call.argument("songs")); + GeneralHandler.getAppContext().startService(serviceIntent); + // Save the result to report to the flutter code later in `sendResultFromIntent(` + this.result = result; + break; + } + case "createPlaylist": { + Handler handler = new Handler(Looper.getMainLooper()); + Executors.newSingleThreadExecutor().execute(() -> { + try { + String name = call.argument("name"); + ContentResolver resolver = getContentResolver(); + ContentValues values = new ContentValues(1); + values.put(MediaStore.Audio.Playlists.NAME, name); + + Uri uri = resolver.insert(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, values); + if (uri != null) { + resolver.notifyChange(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, null); + } + handler.post(() -> { + result.success(null); + }); + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + + } + }); + break; + } + case "renamePlaylist": { + Handler handler = new Handler(Looper.getMainLooper()); + Executors.newSingleThreadExecutor().execute(() -> { + try { + Long id = GeneralHandler.getLong(call.argument("id")); + ContentResolver resolver = getContentResolver(); + if (playlistExists(id)) { + String name = call.argument("name"); + ContentValues values = new ContentValues(1); + values.put(MediaStore.Audio.Playlists.NAME, name); + + int rows = resolver.update( + ContentUris.withAppendedId(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, id), + values, + null, + null + ); + if (rows > 0) { + resolver.notifyChange(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, null); + } + handler.post(() -> { + result.success(null); + }); + } else { + handler.post(() -> { + result.error(PLAYLIST_NOT_EXISTS_ERROR, "No playlists with such id", id); + }); + } + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "removePlaylists": { + Handler handler = new Handler(Looper.getMainLooper()); + Executors.newSingleThreadExecutor().execute(() -> { + try { + ArrayList songIds = call.argument("ids"); + ArrayList songIdStrings = new ArrayList<>(); + for (Object id : songIds) { + songIdStrings.add(id.toString()); + } + ContentResolver resolver = getContentResolver(); + resolver.delete( + MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, + FetchHandler.buildWhereForCount(MediaStore.Audio.Playlists._ID, songIdStrings.size()), + songIdStrings.toArray(new String[0]) + ); + resolver.notifyChange(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, null); + handler.post(() -> { + result.success(null); + }); + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "insertSongsInPlaylist": { + Handler handler = new Handler(Looper.getMainLooper()); + Executors.newSingleThreadExecutor().execute(() -> { + try { + Long id = GeneralHandler.getLong(call.argument("id")); + if (playlistExists(id)) { + Long index = GeneralHandler.getLong(call.argument("index")); + ArrayList songIds = call.argument("songIds"); + ContentResolver resolver = getContentResolver(); + Uri uri; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + uri = MediaStore.Audio.Playlists.Members.getContentUri(MediaStore.VOLUME_EXTERNAL, id); + } else { + uri = MediaStore.Audio.Playlists.Members.getContentUri("external", id); + } + ArrayList valuesList = new ArrayList<>(); + for (int i = 0; i < songIds.size(); i++) { + ContentValues values = new ContentValues(2); + values.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, GeneralHandler.getLong(songIds.get(i))); + values.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, i + index); + valuesList.add(values); + } + resolver.bulkInsert(uri, valuesList.toArray(new ContentValues[0])); + handler.post(() -> { + result.success(null); + }); + } else { + handler.post(() -> { + result.error(PLAYLIST_NOT_EXISTS_ERROR, "No playlists with such id", id); + }); + } + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "moveSongInPlaylist": { + Handler handler = new Handler(Looper.getMainLooper()); + Executors.newSingleThreadExecutor().execute(() -> { + try { + ContentResolver resolver = getContentResolver(); + boolean moved = MediaStore.Audio.Playlists.Members.moveItem( + resolver, + GeneralHandler.getLong(call.argument("id")), + call.argument("from"), + call.argument("to") + ); + if (moved) { + resolver.notifyChange(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, null); + } + handler.post(() -> { + result.success(moved); + }); + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "removeFromPlaylistAt": { + Handler handler = new Handler(Looper.getMainLooper()); + Executors.newSingleThreadExecutor().execute(() -> { + try { + Long id = GeneralHandler.getLong(call.argument("id")); + if (playlistExists(id)) { + ArrayList indexes = call.argument("indexes"); + ArrayList stringIndexes = new ArrayList<>(); + for (Object index : indexes) { + // Android seems to require indexes to be offsetted by 1. + // + // It might be because when songs are inserted into the playlist, + // the indexing is quite similar an there it makes sense, because we need + // to be able to insert to `playlistLength + 1` position. + stringIndexes.add(String.valueOf(GeneralHandler.getLong(index) + 1)); + } + ContentResolver resolver = getContentResolver(); + Uri uri; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + uri = MediaStore.Audio.Playlists.Members.getContentUri(MediaStore.VOLUME_EXTERNAL, id); + } else { + uri = MediaStore.Audio.Playlists.Members.getContentUri("external", id); + } + int deletedRows = resolver.delete( + uri, + FetchHandler.buildWhereForCount(MediaStore.Audio.Playlists.Members.PLAY_ORDER, indexes.size()), + stringIndexes.toArray(new String[0]) + ); + if (deletedRows > 0) { + resolver.notifyChange(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, null); + } + handler.post(() -> { + result.success(null); + }); + } else { + handler.post(() -> { + result.error(PLAYLIST_NOT_EXISTS_ERROR, "No playlists with such id", id); + }); + } + } catch (Exception e) { + handler.post(() -> { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + }); + } + }); + break; + } + case "isIntentActionView": { + if (activity != null) { + Intent intent = activity.getIntent(); + result.success(Intent.ACTION_VIEW.equals(intent.getAction())); } else { - result.success(true); + throw new IllegalStateException("activity is null"); } break; } @@ -172,13 +469,63 @@ public void onMethodCall(MethodCall call, @NotNull MethodChannel.Result result) result.notImplemented(); } } catch (Exception e) { - result.error("CONTENT_CHANNEL_ERROR", e.getMessage(), Log.getStackTraceString(e)); + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + } + } + + private boolean playlistExists(Long id) { + Cursor cursor = getContentResolver().query( + MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, + new String[]{MediaStore.Audio.Playlists._ID}, + MediaStore.Audio.Playlists._ID + "=?", + new String[]{id.toString()}, + null + ); + if (cursor == null) { + return false; + } + if (cursor.getCount() == 0) { + cursor.close(); + return false; + } + return true; + } + + private ContentResolver getContentResolver() { + return GeneralHandler.getAppContext().getContentResolver(); + } + + @UiThread + public void startIntentSenderForResult(PendingIntent pendingIntent, Constants.intents intent) { + try { + activity.startIntentSenderForResult( + pendingIntent.getIntentSender(), + intent.value, + null, + 0, + 0, + 0); + } catch (IntentSender.SendIntentException e) { + if (this.result != null) { + result.error(INTENT_SENDER_ERROR, e.getMessage(), Log.getStackTraceString(e)); + this.result = null; + } + } catch (Exception e) { + if (this.result != null) { + result.error(UNEXPECTED_ERROR, e.getMessage(), Log.getStackTraceString(e)); + this.result = null; + } } } - public void sendDeletionResult(boolean result) { + /** + * Sends a results after activity receives a result after calling + * {@link #startIntentSenderForResult} + */ + public void sendResultFromIntent(boolean result) { if (this.result != null) { this.result.success(result); + this.result = null; } } } diff --git a/android/app/src/main/java/com/nt4f04und/sweyer/channels/GeneralChannel.java b/android/app/src/main/java/com/nt4f04und/sweyer/channels/GeneralChannel.java deleted file mode 100644 index bd17e3f87..000000000 --- a/android/app/src/main/java/com/nt4f04und/sweyer/channels/GeneralChannel.java +++ /dev/null @@ -1,56 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) nt4f04und. All rights reserved. - * Licensed under the BSD-style license. See LICENSE in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package com.nt4f04und.sweyer.channels; - -import android.app.Activity; -import android.util.Log; - -import com.nt4f04und.sweyer.handlers.GeneralHandler; - -import org.jetbrains.annotations.NotNull; - -import androidx.annotation.Nullable; - -import io.flutter.embedding.android.FlutterActivity; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; - -public enum GeneralChannel { - instance; - public void init(BinaryMessenger messenger, FlutterActivity activity) { - if (channel == null) { - this.activity = activity; - channel = new MethodChannel(messenger, "general_channel"); - channel.setMethodCallHandler(this::onMethodCall); - } - } - - public void destroy() { - channel = null; - activity = null; - } - - @Nullable - public MethodChannel channel; - public Activity activity; - - public void onMethodCall(@NotNull MethodCall call, @NotNull MethodChannel.Result result) { - // NOTE: this method is invoked on the main thread. - try { - switch (call.method) { - case "isIntentActionView": - result.success(GeneralHandler.isIntentActionView(activity)); - break; - default: - result.notImplemented(); - } - } catch (Exception e) { - result.error("GENERAL_CHANNEL_ERROR", e.getMessage(), Log.getStackTraceString(e)); - } - } - -} \ No newline at end of file diff --git a/android/app/src/main/java/com/nt4f04und/sweyer/handlers/FetchHandler.java b/android/app/src/main/java/com/nt4f04und/sweyer/handlers/FetchHandler.java index 783f8c4c7..86370cfd6 100644 --- a/android/app/src/main/java/com/nt4f04und/sweyer/handlers/FetchHandler.java +++ b/android/app/src/main/java/com/nt4f04und/sweyer/handlers/FetchHandler.java @@ -6,84 +6,158 @@ package com.nt4f04und.sweyer.handlers; import android.content.ContentResolver; -import android.content.Intent; import android.database.Cursor; +import android.os.Build; import android.provider.MediaStore; -import com.nt4f04und.sweyer.services.DeletionService; - import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; public class FetchHandler { - /// Accepts json string of array of songs - public static void deleteSongs(ArrayList> songs) { - Intent serviceIntent = new Intent(GeneralHandler.getAppContext(), DeletionService.class); - serviceIntent.putExtra("songs", songs); - GeneralHandler.getAppContext().startService(serviceIntent); + // Some audio may be explicitly marked as not being music or be trashed (on Android R and above), + // I'm excluding such. + static String songsSelection = MediaStore.Audio.Media.IS_MUSIC + " != 0"; + static String AND = " AND "; + static { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + songsSelection += AND + + MediaStore.Audio.Media.IS_TRASHED + " == 0" + AND + + MediaStore.Audio.Media.IS_PENDING + " == 0"; + } + } + + + /** Produces the `where` parameter for selection multiple items from the `MediaStore` + * Creates a string like "_data IN (?, ?, ?, ...)" */ + public static String buildWhereForCount(String column, int count) { + StringBuilder builder = new StringBuilder(column); + builder.append(" IN ("); + for (int i = 0; i < count - 1; i++) { + builder.append("?, "); + } + builder.append("?)"); + return builder.toString(); } public static ArrayList> retrieveSongs() { ArrayList> maps = new ArrayList<>(); - // Some audio may be explicitly marked as not being music - String selection = MediaStore.Audio.Media.IS_MUSIC + " != 0"; - String sortOrder = MediaStore.Audio.Media.DATE_MODIFIED + " DESC"; + + ArrayList projection = new ArrayList<>(Arrays.asList( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ARTIST_ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.TRACK, // position in album + MediaStore.Audio.Media.YEAR, + MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.DATE_MODIFIED, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.SIZE, + MediaStore.Audio.Media.DATA + + // Found useless/redundant: + // + // * ALBUM_ARTIST - for this one I can simply check the song album + // + // * AUTHOR + // * COMPOSER + // * WRITER + // + // * BITRATE + // * CAPTURE_FRAMERATE + // * CD_TRACK_NUMBER + // * COMPILATION + // * DATE_EXPIRES + // * DATE_TAKEN + // * DISC_NUMBER + // * DISPLAY_NAME - this is same as TITLE, but with file extension at the end + // * DOCUMENT_ID + // * HEIGHT + // * WIDTH + // * INSTANCE_ID + // + // * IS_ALARM + // * IS_AUDIOBOOK + // * IS_MUSIC - we fetch only music, see `selection` above + // * IS_NOTIFICATION + // * IS_PODCAST + // * IS_RECORDING + // * IS_RINGTONE + // + // * IS_DOWNLOAD + // * IS_DRM + // + // * IS_TRASHED - trashed items are excluded, see `selection` above + // * IS_PENDING - pedning items are excluded, see `selection` above + // + // * MIME_TYPE + // * NUM_TRACKS - the number of songs in the origin this media comes from + // * ORIENTATION + // * ORIGINAL_DOCUMENT_ID + // * OWNER_PACKAGE_NAME + // * RELATIVE_PATH + // * RESOLUTION + // * VOLUME_NAME + // * XMP + // * TITLE_RESOURCE_URI + // + // * BOOKMARK - position within the audio item at which + // playback should be resumed. For me it's making no sense to remember position for each + // media item. + )); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + projection.add(MediaStore.Audio.Media.IS_FAVORITE); + projection.add(MediaStore.Audio.Media.GENERATION_ADDED); + projection.add(MediaStore.Audio.Media.GENERATION_MODIFIED); + projection.add(MediaStore.Audio.Media.GENRE); + projection.add(MediaStore.Audio.Media.GENRE_ID); + } ContentResolver resolver = GeneralHandler.getAppContext().getContentResolver(); Cursor cursor = resolver.query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, - new String[]{ - MediaStore.Audio.Media._ID, - MediaStore.Audio.Media.ALBUM, - MediaStore.Audio.Media.ALBUM_ID, - MediaStore.Audio.Media.ARTIST, - MediaStore.Audio.Media.ARTIST_ID, - // TODO: COMPOSER ????? - // TODO: BOOKMARK ????? - // TODO: GENRE ????? - // TODO: GENRE_ID ????? - MediaStore.Audio.Media.TITLE, - MediaStore.Audio.Media.TRACK, // position in album - // TODO: TITLE_RESOURCE_URI ????? - // TODO: YEAR ????? - MediaStore.Audio.Media.DATE_ADDED, - MediaStore.Audio.Media.DATE_MODIFIED, - MediaStore.Audio.Media.DURATION, - MediaStore.Audio.Media.SIZE, - MediaStore.Audio.Media.DATA, - }, - selection, + projection.toArray(new String[0]), + songsSelection, null, - sortOrder + null ); - if (cursor == null) { - return maps; - } - while (cursor.moveToNext()) { - HashMap map = new HashMap(); - map.put("id", cursor.getInt(0)); - map.put("album", cursor.getString(1)); - map.put("albumId", cursor.getInt(2)); - map.put("artist", cursor.getString(3)); - map.put("artistId", cursor.getInt(4)); - map.put("title", cursor.getString(5)); - map.put("track", cursor.getString(6)); - map.put("dateAdded", cursor.getInt(7)); - map.put("dateModified", cursor.getInt(8)); - map.put("duration", cursor.getInt(9)); - map.put("size", cursor.getInt(10)); - map.put("data", cursor.getString(11)); - maps.add(map); + if (cursor != null) { + while (cursor.moveToNext()) { + HashMap map = new HashMap<>(); + map.put("id", cursor.getInt(0)); + map.put("album", cursor.getString(1)); + map.put("albumId", cursor.getInt(2)); + map.put("artist", cursor.getString(3)); + map.put("artistId", cursor.getInt(4)); + map.put("title", cursor.getString(5)); + map.put("track", cursor.getString(6)); + map.put("year", cursor.getString(7)); + map.put("dateAdded", cursor.getInt(8)); + map.put("dateModified", cursor.getInt(9)); + map.put("duration", cursor.getInt(10)); + map.put("size", cursor.getInt(11)); + map.put("data", cursor.getString(12)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + map.put("isFavorite", cursor.getInt(13) == 1); + map.put("generationAdded", cursor.getInt(14)); + map.put("generationModified", cursor.getInt(15)); + map.put("genre", cursor.getString(16)); + map.put("genreId", cursor.getInt(17)); + } + maps.add(map); + } + cursor.close(); } - cursor.close(); return maps; } public static ArrayList> retrieveAlbums() { ArrayList> maps = new ArrayList<>(); - String selection = MediaStore.Audio.Albums.ALBUM + " IS NOT NULL"; - String sortOrder = MediaStore.Audio.Albums._ID + " ASC"; ContentResolver resolver = GeneralHandler.getAppContext().getContentResolver(); Cursor cursor = resolver.query( @@ -98,28 +172,155 @@ public static void deleteSongs(ArrayList> songs) { MediaStore.Audio.Albums.LAST_YEAR, MediaStore.Audio.Albums.NUMBER_OF_SONGS }, - selection, + MediaStore.Audio.Albums.ALBUM + " IS NOT NULL", + null, + null + ); + + if (cursor != null) { + while (cursor.moveToNext()) { + HashMap map = new HashMap<>(); + map.put("id", cursor.getInt(0)); + map.put("album", cursor.getString(1)); + map.put("albumArt", cursor.getString(2)); + map.put("artist", cursor.getString(3)); + map.put("artistId", cursor.getInt(4)); + map.put("firstYear", cursor.getInt(5)); + map.put("lastYear", cursor.getInt(6)); + map.put("numberOfSongs", cursor.getInt(7)); + maps.add(map); + } + cursor.close(); + } + return maps; + } + + public static ArrayList> retrievePlaylists() { + ArrayList> maps = new ArrayList<>(); + + ContentResolver resolver = GeneralHandler.getAppContext().getContentResolver(); + Cursor cursor = resolver.query( + MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, + new String[]{ + MediaStore.Audio.Playlists._ID, + MediaStore.Audio.Playlists.DATA, + MediaStore.Audio.Playlists.DATE_ADDED, + MediaStore.Audio.Playlists.DATE_MODIFIED, + MediaStore.Audio.Playlists.NAME, + }, + MediaStore.Audio.Playlists.NAME + " IS NOT NULL", null, - sortOrder + null ); + if (cursor != null) { + String[] memberProjection = new String[]{ + MediaStore.Audio.Playlists.Members.AUDIO_ID, + }; + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + Cursor membersCursor = resolver.query( + MediaStore.Audio.Playlists.Members.getContentUri("external", id), + memberProjection, + songsSelection, + null, + MediaStore.Audio.Playlists.Members.DEFAULT_SORT_ORDER + ); + if (membersCursor != null) { + ArrayList songIds = new ArrayList<>(); + while (membersCursor.moveToNext()) { + songIds.add(membersCursor.getInt(0)); + } + HashMap map = new HashMap<>(); + map.put("id", cursor.getInt(0)); + map.put("data", cursor.getString(1)); + map.put("dateAdded", cursor.getInt(2)); + map.put("dateModified", cursor.getInt(3)); + map.put("name", cursor.getString(4)); + map.put("songIds", songIds); + maps.add(map); + membersCursor.close(); + } + } + cursor.close(); + } + return maps; + } + + public static ArrayList> retrieveArtists() { + ArrayList> maps = new ArrayList<>(); + + ContentResolver resolver = GeneralHandler.getAppContext().getContentResolver(); + Cursor cursor = resolver.query( + MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, + new String[]{ + MediaStore.Audio.Artists._ID, + MediaStore.Audio.Artists.ARTIST, + MediaStore.Audio.Artists.NUMBER_OF_ALBUMS, + MediaStore.Audio.Artists.NUMBER_OF_TRACKS, + }, + MediaStore.Audio.Artists.ARTIST + " IS NOT NULL", + null, + null + ); - if (cursor == null) { - return maps; + if (cursor != null) { + while (cursor.moveToNext()) { + HashMap map = new HashMap<>(); + map.put("id", cursor.getInt(0)); + map.put("artist", cursor.getString(1)); + map.put("numberOfAlbums", cursor.getInt(2)); + map.put("numberOfTracks", cursor.getInt(3)); + maps.add(map); + } + cursor.close(); } - while (cursor.moveToNext()) { - HashMap map = new HashMap(); - map.put("id", cursor.getInt(0)); - map.put("album", cursor.getString(1)); - map.put("albumArt", cursor.getString(2)); - map.put("artist", cursor.getString(3)); - map.put("artistId", cursor.getInt(4)); - map.put("firstYear", cursor.getInt(5)); - map.put("lastYear", cursor.getInt(6)); - map.put("numberOfSongs", cursor.getInt(7)); - maps.add(map); + return maps; + } + + public static ArrayList> retrieveGenres() { + ArrayList> maps = new ArrayList<>(); + + ContentResolver resolver = GeneralHandler.getAppContext().getContentResolver(); + Cursor cursor = resolver.query( + MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, + new String[]{ + MediaStore.Audio.Genres._ID, + MediaStore.Audio.Genres.NAME, + }, + MediaStore.Audio.Genres.NAME + " IS NOT NULL", + null, + null + ); + + if (cursor != null) { + String[] memberProjection = new String[]{ + MediaStore.Audio.Genres.Members._ID, + }; + while (cursor.moveToNext()) { + int id = cursor.getInt(0); + Cursor membersCursor = resolver.query( + MediaStore.Audio.Genres.Members.getContentUri("external", id), + memberProjection, + null, + null, + null + ); + if (membersCursor != null) { + ArrayList songIds = new ArrayList<>(); + while (membersCursor.moveToNext()) { + songIds.add(membersCursor.getInt(0)); + } + HashMap map = new HashMap<>(); + map.put("id", cursor.getInt(0)); + map.put("name", cursor.getString(1)); + map.put("songIds", songIds); + maps.add(map); + membersCursor.close(); + } + } + cursor.close(); } - cursor.close(); return maps; } } diff --git a/android/app/src/main/java/com/nt4f04und/sweyer/handlers/GeneralHandler.java b/android/app/src/main/java/com/nt4f04und/sweyer/handlers/GeneralHandler.java index ede3b9369..a03d2d089 100644 --- a/android/app/src/main/java/com/nt4f04und/sweyer/handlers/GeneralHandler.java +++ b/android/app/src/main/java/com/nt4f04und/sweyer/handlers/GeneralHandler.java @@ -5,36 +5,29 @@ package com.nt4f04und.sweyer.handlers; -import android.app.Activity; import android.content.Context; -import android.content.Intent; +import android.util.Log; import com.nt4f04und.sweyer.Constants; -import android.util.Log; -/** - * Just a junk yard of various utils methods - * Also a container for an application context - */ public abstract class GeneralHandler { - public static void init(Context appContext) { GeneralHandler.appContext = appContext; } - private static Context appContext; - public static Context getAppContext() { - if (appContext == null) + if (appContext == null) { Log.e(Constants.LogTag, "GeneralHandler is not initialized! Can't get app context!"); + } return appContext; } - - /** - * Check for if Intent action is VIEW - */ - public static boolean isIntentActionView(Activity activity) { - Intent intent = activity.getIntent(); - return Intent.ACTION_VIEW.equals(intent.getAction()); + public static Long getLong(Object rawValue) { + if (rawValue instanceof Long) { + return (Long) rawValue; + } else if (rawValue instanceof Integer) { + return Long.valueOf((Integer) rawValue); + } else { + throw new IllegalArgumentException(); + } } } \ No newline at end of file diff --git a/android/app/src/main/java/com/nt4f04und/sweyer/services/DeletionService.java b/android/app/src/main/java/com/nt4f04und/sweyer/services/DeletionService.java index b406b1027..a399e82ec 100644 --- a/android/app/src/main/java/com/nt4f04und/sweyer/services/DeletionService.java +++ b/android/app/src/main/java/com/nt4f04und/sweyer/services/DeletionService.java @@ -10,41 +10,33 @@ import android.content.ContentResolver; import android.content.ContentUris; import android.content.Intent; -import android.content.IntentSender; import android.net.Uri; -import android.os.AsyncTask; import android.os.Build; +import android.os.Handler; import android.os.IBinder; +import android.os.Looper; import android.provider.MediaStore; import android.util.Log; +import androidx.annotation.Nullable; + import com.nt4f04und.sweyer.Constants; -import com.nt4f04und.sweyer.channels.GeneralChannel; +import com.nt4f04und.sweyer.channels.ContentChannel; +import com.nt4f04und.sweyer.handlers.FetchHandler; import com.nt4f04und.sweyer.handlers.GeneralHandler; import java.io.File; import java.util.ArrayList; import java.util.HashMap; - -import androidx.annotation.Nullable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class DeletionService extends Service { - - /// Produces the `where` parameter for deleting songs from the `MediaStore` - /// Creates the string like "_data IN (?, ?, ?, ...)" - private static String buildWhereClauseForDeletion(int count) { - StringBuilder builder = new StringBuilder(MediaStore.Audio.Media.DATA); - builder.append(" IN ("); - for (int i = 0; i < count - 1; i++) { - builder.append("?, "); - } - builder.append("?)"); - return builder.toString(); - } - @Override public int onStartCommand(Intent intent, int flags, int startId) { - AsyncTask.execute(() -> { + ExecutorService executor = Executors.newSingleThreadExecutor(); + Handler handler = new Handler(Looper.getMainLooper()); + executor.submit(() -> { ArrayList> songs = (ArrayList>) intent.getSerializableExtra("songs"); ContentResolver resolver = GeneralHandler.getAppContext().getContentResolver(); @@ -56,33 +48,17 @@ public int onStartCommand(Intent intent, int flags, int startId) { ArrayList uris = new ArrayList<>(); // Populate `songListSuccessful` with uris for the intent for (HashMap song : songs) { - Object rawId = song.get("id"); - Long id; - if (rawId instanceof Long) { - id = (Long) rawId; - } else if (rawId instanceof Integer) { - id = Long.valueOf((Integer) rawId); - } else { - throw new IllegalArgumentException(); - } + Long id = GeneralHandler.getLong(song.get("id")); uris.add(ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)); } PendingIntent pendingIntent = MediaStore.createDeleteRequest( - GeneralChannel.instance.activity.getContentResolver(), + GeneralHandler.getAppContext().getContentResolver(), uris ); - try { - // On R we are now to request an OS permission for file deletions - GeneralChannel.instance.activity.startIntentSenderForResult( - pendingIntent.getIntentSender(), - Constants.intents.PERMANENT_DELETION_REQUEST, - null, - 0, - 0, - 0); - } catch (IntentSender.SendIntentException e) { - Log.e(Constants.LogTag, "DELETION_INTENT_ERROR: " + e.getMessage()); - } + handler.post(() -> { + // On R it's required to request an OS permission for file deletions + ContentChannel.instance.startIntentSenderForResult(pendingIntent, Constants.intents.PERMANENT_DELETION_REQUEST); + }); } else { ArrayList songListSuccessful = new ArrayList<>(); // Delete files and populate `songListSuccessful` with successful uris @@ -101,11 +77,14 @@ public int onStartCommand(Intent intent, int flags, int startId) { } Uri uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; - String where = buildWhereClauseForDeletion(songs.size()); + String where = FetchHandler.buildWhereForCount(MediaStore.Audio.Media.DATA, songs.size()); String[] selectionArgs = songListSuccessful.toArray(new String[0]); // Delete file from `MediaStore` resolver.delete(uri, where, selectionArgs); + resolver.notifyChange(uri, null); + ContentChannel.instance.sendResultFromIntent(true); } + stopSelf(); }); return super.onStartCommand(intent, flags, startId); } diff --git a/assets/fonts/SweyerIcons/SweyerIcons.ttf b/assets/fonts/SweyerIcons/SweyerIcons.ttf new file mode 100644 index 000000000..c4f4460fe Binary files /dev/null and b/assets/fonts/SweyerIcons/SweyerIcons.ttf differ diff --git a/assets/fonts/SweyerIcons/config.json b/assets/fonts/SweyerIcons/config.json new file mode 100644 index 000000000..103d3aad8 --- /dev/null +++ b/assets/fonts/SweyerIcons/config.json @@ -0,0 +1,52 @@ +{ + "name": "SweyerIcons", + "css_prefix_text": "", + "css_use_suffix": false, + "hinting": true, + "units_per_em": 1000, + "ascent": 850, + "glyphs": [ + { + "uid": "e31cd30eb4af59145be7ce268f3de979", + "css": "play_next", + "code": 59392, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M366.6 443.4H783.2A41.7 41.7 0 0 1 783.2 526.7H366.6A41.7 41.7 0 1 1 366.6 443.4ZM366.6 614.2H783.3A41.7 41.7 0 0 1 783.3 697.6H366.6A41.7 41.7 0 1 1 366.6 614.2ZM512.4 360.1H783.2A41.7 41.7 0 0 0 783.2 276.8H512.4A39.4 39.4 0 0 0 470.7 318.5 41.8 41.8 0 0 0 512.4 360.1ZM240.9 337.8H121.6A34.1 34.1 0 0 1 87.6 303.8V303.8H87.6A34.1 34.1 0 0 1 121.6 269.7H240.9V218.6A17 17 0 0 1 267.8 204.7L387.2 289.9A17 17 0 0 1 387.2 317.6L267.8 402.8H267.8A17 17 0 0 1 240.9 388.9H240.9Z", + "width": 1000 + }, + "search": [ + "play_next" + ] + }, + { + "uid": "958eba510c6552e1120f87c934eb04df", + "css": "add_to_queue", + "code": 59393, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M366.6 444H783.2A41.7 41.7 0 0 1 783.2 527.3H366.6A41.7 41.7 0 1 1 366.6 444ZM512.4 614.8H783.2A41.7 41.7 0 0 1 783.2 698.2H512.4A39.4 39.4 0 0 1 470.7 656.5 41.8 41.8 0 0 1 512.4 614.8ZM366.6 277.3H783.2A41.7 41.7 0 0 1 783.2 360.7H366.6A39.4 39.4 0 0 1 324.9 319 39.4 39.4 0 0 1 366.6 277.3ZM240.9 637.1H121.6A34.1 34.1 0 0 0 87.6 671.2V671.2H87.6A34.1 34.1 0 0 0 121.6 705.3H240.9V756.4A17 17 0 0 0 267.8 770.2L387.2 685.1A17 17 0 0 0 387.2 657.3L267.8 572.2H267.8A17 17 0 0 0 240.9 586H240.9Z", + "width": 1000 + }, + "search": [ + "add_to_queue" + ] + }, + { + "uid": "7a98de30bb6246867650613dc1c231d7", + "css": "share", + "code": 59394, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M845.7 429.7C861.3 445.3 861.3 470.7 845.7 488.3L695.3 636.7C669.9 664.1 625 644.5 625 607.4V500H334C263.7 500 209 556.6 209 625V750C209 773.4 189.5 791 166 791 144.5 791 125 773.4 125 750V625C125 509.8 218.8 416 334 416H625V308.6C625 271.5 669.9 253.9 695.3 279.3Z", + "width": 1020 + }, + "search": [ + "share" + ] + } + ] +} \ No newline at end of file diff --git a/lib/constants/colors.dart b/lib/constants/colors.dart index b864aa8c1..01debc0d5 100644 --- a/lib/constants/colors.dart +++ b/lib/constants/colors.dart @@ -26,7 +26,4 @@ abstract class AppColors { /// used for marking some parts as headers from the rest of UI in light mode static const Color whiteDarkened = Color(0xfff1f2f4); static const Color eee = Color(0xffeeeeee); - - /// Color used for text in dark mode - static const Color almostWhite = Color(0xfffffffe); } diff --git a/lib/constants/constants.dart b/lib/constants/constants.dart index a15c038b5..385da029d 100644 --- a/lib/constants/constants.dart +++ b/lib/constants/constants.dart @@ -7,7 +7,3 @@ export 'assets.dart'; export 'colors.dart'; export 'config.dart'; export 'themes.dart'; - -const double iconSize = 25.0; -const double iconButtonSize = 36.0; - diff --git a/lib/constants/themes.dart b/lib/constants/themes.dart index 70945b6e8..fda6dbe80 100644 --- a/lib/constants/themes.dart +++ b/lib/constants/themes.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; import 'package:sweyer/sweyer.dart'; import 'colors.dart'; @@ -27,7 +26,7 @@ abstract class Theme { static const _ThemeContainer menuItemColor = _ThemeContainer( light: Color(0xff3d3e42), - dark: AppColors.almostWhite, + dark: Colors.white, ); /// Color that contrasts with the [ColorScheme.background]. @@ -54,10 +53,10 @@ abstract class Theme { dark: Colors.white.withOpacity(0.1), ); - /// A [glowSplashColor] to draw over [contrast]. + /// A [glowSplashColor] to draw over contrasting colors, like primary or [contrast]. static final _ThemeContainer glowSplashColorOnContrast = _ThemeContainer( light: Colors.white.withOpacity(0.13), - dark: const Color(0x80cccccc), + dark: Colors.black.withOpacity(0.13), ); static const Color _lightIconColor = Color(0xff616266); @@ -68,7 +67,6 @@ abstract class Theme { fontFamily: 'Manrope', brightness: Brightness.light, //****************** Colors ********************** - accentColor: Colors.white, backgroundColor: Colors.white, primaryColor: defaultPrimaryColor, disabledColor: Colors.grey.shade400, @@ -167,14 +165,16 @@ abstract class Theme { ), appBarTheme: const AppBarTheme( brightness: Brightness.light, - elevation: 0.0, + elevation: 2.0, + titleSpacing: 0.0, + toolbarHeight: NFConstants.toolbarHeight, color: AppColors.eee, - textTheme: TextTheme( - headline6: TextStyle( - color: AppColors.greyText, - fontWeight: FontWeight.w600, - fontSize: 20.0, - ), + backwardsCompatibility: false, + titleTextStyle: TextStyle( + color: AppColors.greyText, + fontWeight: FontWeight.w600, + fontSize: 21.0, + fontFamily: 'Roboto', ), ), bottomSheetTheme: const BottomSheetThemeData( @@ -192,8 +192,6 @@ abstract class Theme { fontFamily: 'Manrope', brightness: Brightness.dark, //****************** Colors ********************** - // accentColor: AppColors.grey, - accentColor: Colors.red, backgroundColor: Colors.black, primaryColor: defaultPrimaryColor, disabledColor: Colors.grey.shade800, @@ -203,17 +201,17 @@ abstract class Theme { colorScheme: const ColorScheme( brightness: Brightness.dark, background: Colors.black, - onBackground: AppColors.almostWhite, + onBackground: Colors.white, primary: defaultPrimaryColor, // This is not darker, though lighter version primaryVariant: Color(0xff936bff), - onPrimary: AppColors.almostWhite, + onPrimary: Colors.white, secondary: AppColors.grey, secondaryVariant: Colors.black, // todo: temporarily used for text in [NFButtons], remove when it's removed - onSecondary: AppColors.almostWhite, + onSecondary: Colors.white, error: Color(0xffed3b3b), - onError: AppColors.almostWhite, + onError: Colors.white, /// For window headers (e.g. alert dialogs) surface: AppColors.grey, /// For dimmed text (e.g. in appbar) @@ -247,20 +245,20 @@ abstract class Theme { textTheme: const TextTheme( /// See https://material.io/design/typography/the-type-system.html#type-scale button: TextStyle(fontWeight: FontWeight.w600), - headline1: TextStyle(fontWeight: FontWeight.w600, color: AppColors.almostWhite), - headline2: TextStyle(fontWeight: FontWeight.w600, color: AppColors.almostWhite), - headline3: TextStyle(fontWeight: FontWeight.w600, color: AppColors.almostWhite), - headline4: TextStyle(fontWeight: FontWeight.w600, color: AppColors.almostWhite), - headline5: TextStyle(fontWeight: FontWeight.w600, color: AppColors.almostWhite), + headline1: TextStyle(fontWeight: FontWeight.w600, color: Colors.white), + headline2: TextStyle(fontWeight: FontWeight.w600, color: Colors.white), + headline3: TextStyle(fontWeight: FontWeight.w600, color: Colors.white), + headline4: TextStyle(fontWeight: FontWeight.w600, color: Colors.white), + headline5: TextStyle(fontWeight: FontWeight.w600, color: Colors.white), headline6: TextStyle( fontWeight: FontWeight.w700, - color: AppColors.almostWhite, + color: Colors.white, fontSize: 15.0, ), // Title in song tiles subtitle1: TextStyle( fontWeight: FontWeight.w600, - color: AppColors.almostWhite, + color: Colors.white, ), // Artist widget subtitle2: TextStyle( @@ -278,12 +276,14 @@ abstract class Theme { brightness: Brightness.dark, color: AppColors.grey, elevation: 0.0, - textTheme: TextTheme( - headline6: TextStyle( - color: AppColors.almostWhite, - fontWeight: FontWeight.w600, - fontSize: 20.0, - ), + titleSpacing: 0.0, + toolbarHeight: NFConstants.toolbarHeight, + backwardsCompatibility: false, + titleTextStyle: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 21.0, + fontFamily: 'Roboto', ), ), bottomSheetTheme: const BottomSheetThemeData( @@ -349,8 +349,7 @@ abstract class UiTheme { ); /// Theme for the bottom sheet dialog. - static final _ThemeContainer bottomSheet = - _ThemeContainer( + static final _ThemeContainer bottomSheet = _ThemeContainer( light: black.light.copyWith( systemNavigationBarColor: Colors.white, statusBarBrightness: Brightness.light, @@ -369,14 +368,11 @@ abstract class UiTheme { statusBarBrightness: Brightness.light, statusBarIconBrightness: Brightness.light, ), - dark: black.dark.copyWith( - statusBarColor: Colors.black, - ), + dark: black.dark, ); /// Theme for the modal dialog that is displayed over [grey]. - static final _ThemeContainer modalOverGrey = - _ThemeContainer( + static final _ThemeContainer modalOverGrey = _ThemeContainer( light: modal.light.copyWith( systemNavigationBarColor: const Color(0xff6d6d6d), ), @@ -388,7 +384,7 @@ abstract class UiTheme { /// Class to wrap some values, so they will have [light] and [dark] variants. class _ThemeContainer { - const _ThemeContainer({@required this.light, @required this.dark}); + const _ThemeContainer({required this.light, required this.dark}); final T light; final T dark; @@ -398,7 +394,7 @@ class _ThemeContainer { /// Checks theme and automatically picks opposite value from the current brightness. T get autoReverse => ThemeControl.isDark ? light : dark; - _ThemeContainer copyWith({T light, T dark}) { + _ThemeContainer copyWith({T? light, T? dark}) { return _ThemeContainer( light: light ?? this.light, dark: dark ?? this.dark, diff --git a/lib/core/colors.dart b/lib/core/colors.dart deleted file mode 100644 index 13bf0a67d..000000000 --- a/lib/core/colors.dart +++ /dev/null @@ -1,18 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'dart:ui'; - -/// This is the color of the mask background (by RGBs, full color would be `0x1a1a1a`). -/// It's twice lighter than the shadow color on the mask, -/// which is `0x001d0d0d`. This is needed to blend the image color -/// onto mask properly by subtracting it from the desired image background color. -const int _mask = 0x1a; -Color getColorForBlend(Color color) { - final int r = (((color.value >> 16) & 0xff) - _mask).clamp(0, 0xff); - final int g = (((color.value >> 8) & 0xff) - _mask).clamp(0, 0xff); - final int b = ((color.value & 0xff) - _mask).clamp(0, 0xff); - return Color((0xff << 24) + (r << 16) + (g << 8) + b); -} diff --git a/lib/core/core.dart b/lib/core/core.dart deleted file mode 100644 index 56d3339c8..000000000 --- a/lib/core/core.dart +++ /dev/null @@ -1,7 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -export 'colors.dart'; -export 'show_functions.dart'; \ No newline at end of file diff --git a/lib/core/show_functions.dart b/lib/core/show_functions.dart deleted file mode 100644 index 811f15aa3..000000000 --- a/lib/core/show_functions.dart +++ /dev/null @@ -1,114 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; -import 'package:sweyer/sweyer.dart'; -import 'package:flutter/material.dart' - hide showBottomSheet, showGeneralDialog, showModalBottomSheet; -import 'package:fluttertoast/fluttertoast.dart'; - -/// Class that contains composed 'show' functions, like [showDialog] and others -class ShowFunctions extends NFShowFunctions { - /// Empty constructor will allow enheritance. - ShowFunctions(); - ShowFunctions._(); - static final instance = ShowFunctions._(); - - /// Shows toast from `fluttertoast` plugin. - Future showToast({ - @required String msg, - Toast toastLength, - double fontSize = 15.0, - ToastGravity gravity, - Color textColor, - Color backgroundColor, - }) async { - backgroundColor ??= ThemeControl.theme.colorScheme.primary; - - return Fluttertoast.showToast( - msg: msg, - toastLength: toastLength, - fontSize: fontSize, - gravity: gravity, - textColor: textColor, - backgroundColor: backgroundColor, - fontAsset: 'assets/fonts/Manrope/manrope-semibold.ttf', - timeInSecForIosWeb: 20000, - ); - } - - /// Opens songs search - void showSongsSearch({ - String query = '', - bool openKeyboard = true, - }) { - HomeRouter.instance.goto(HomeRoutes.factory.search(SearchArguments( - query: query, - openKeyboard: openKeyboard, - ))); - } - - /// Will show up a snack bar notification that something's went wrong - /// - /// From that snack bar will be possible to proceed to special alert to see the error details with the ability to copy them. - /// [errorDetails] string to show in the alert - void showError({ @required String errorDetails }) { - final context = AppRouter.instance.navigatorKey.currentContext; - assert(context != null); - final l10n = getl10n(context); - final theme = ThemeControl.theme; - final globalKey = GlobalKey(); - NFSnackbarController.showSnackbar( - NFSnackbarEntry( - globalKey: globalKey, - child: NFSnackbar( - title: Text( - '😮 ' + l10n.errorMessage, - style: TextStyle( - fontSize: 15.0, - color: theme.colorScheme.onError, - ), - ), - color: theme.colorScheme.error, - trailing: NFButton( - variant: NFButtonVariant.raised, - text: l10n.details, - color: Colors.white, - textStyle: const TextStyle(color: Colors.black), - onPressed: () { - globalKey.currentState.close(); - showAlert( - context, - title: Text( - l10n.errorDetails, - textAlign: TextAlign.center, - ), - titlePadding: defaultAlertTitlePadding.copyWith( - left: 12.0, - right: 12.0, - ), - contentPadding: const EdgeInsets.only( - top: 16.0, - left: 2.0, - right: 2.0, - bottom: 10.0, - ), - content: SelectableText( - errorDetails, - // FIXME: temporarily do not apply AlwaysScrollableScrollPhysics, because of this issue https://github.com/flutter/flutter/issues/71342 - // scrollPhysics: AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()), - style: const TextStyle(fontSize: 11.0), - ), - additionalActions: [ - NFCopyButton(text: errorDetails), - ], - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/localization/arb/intl_en_us.arb b/lib/localization/arb/intl_en_us.arb index 288fbab95..975bc833a 100644 --- a/lib/localization/arb/intl_en_us.arb +++ b/lib/localization/arb/intl_en_us.arb @@ -1,6 +1,6 @@ { "@@locale": "en_us", - "@@last_modified": "2021-04-14T21:30:55.342088", + "@@last_modified": "2021-05-26T20:48:04.796046", "playback": "Playback", "@playback": { "type": "text", @@ -76,6 +76,16 @@ "type": "text", "placeholders": {} }, + "playlists": "Playlists", + "@playlists": { + "type": "text", + "placeholders": {} + }, + "artists": "Artists", + "@artists": { + "type": "text", + "placeholders": {} + }, "tracksPlural": "{count,plural, =0{Tracks}=1{Track}=2{Tracks}few{Tracks}many{Tracks}other{Tracks}}", "@tracksPlural": { "type": "text", @@ -90,18 +100,32 @@ "count": {} } }, - "allTracks": "All tracks", - "@allTracks": { + "playlistsPlural": "{count,plural, =0{Playlists}=1{Playlist}=2{Playlists}few{Playlists}many{Playlists}other{Playlists}}", + "@playlistsPlural": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "artistsPlural": "{count,plural, =0{Artists}=1{Artist}=2{Artists}few{Artists}many{Artists}other{Artists}}", + "@artistsPlural": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "albumNotFound": "Album not found", + "@albumNotFound": { "type": "text", "placeholders": {} }, - "playlist": "Playlist", - "@playlist": { + "artistNotFound": "Artist not found", + "@artistNotFound": { "type": "text", "placeholders": {} }, - "arbitraryQueue": "Arbitrary queue", - "@arbitraryQueue": { + "allTracks": "All tracks", + "@allTracks": { "type": "text", "placeholders": {} }, @@ -110,6 +134,21 @@ "type": "text", "placeholders": {} }, + "allPlaylists": "All playlists", + "@allPlaylists": { + "type": "text", + "placeholders": {} + }, + "allArtists": "All artitst", + "@allArtists": { + "type": "text", + "placeholders": {} + }, + "arbitraryQueue": "Arbitrary queue", + "@arbitraryQueue": { + "type": "text", + "placeholders": {} + }, "shuffled": "Shuffled", "@shuffled": { "type": "text", @@ -125,6 +164,31 @@ "type": "text", "placeholders": {} }, + "selectedPlural": "Selected", + "@selectedPlural": { + "type": "text", + "placeholders": {} + }, + "newPlaylist": "New playlist", + "@newPlaylist": { + "type": "text", + "placeholders": {} + }, + "trackAfterWhichToInsert": "Track after which to insert", + "@trackAfterWhichToInsert": { + "type": "text", + "placeholders": {} + }, + "insertAtTheBeginning": "Insert at the beginning", + "@insertAtTheBeginning": { + "type": "text", + "placeholders": {} + }, + "saveQueueAsPlaylist": "Save queue as playlist", + "@saveQueueAsPlaylist": { + "type": "text", + "placeholders": {} + }, "playContentList": "Play", "@playContentList": { "type": "text", @@ -145,6 +209,11 @@ "type": "text", "placeholders": {} }, + "areYouSureYouWantTo": "Are you sure you want to", + "@areYouSureYouWantTo": { + "type": "text", + "placeholders": {} + }, "reset": "Reset", "@reset": { "type": "text", @@ -155,6 +224,16 @@ "type": "text", "placeholders": {} }, + "saved": "Saved", + "@saved": { + "type": "text", + "placeholders": {} + }, + "view": "View", + "@view": { + "type": "text", + "placeholders": {} + }, "secondsShorthand": "s", "@secondsShorthand": { "type": "text", @@ -165,6 +244,38 @@ "type": "text", "placeholders": {} }, + "and": "And", + "@and": { + "type": "text", + "placeholders": {} + }, + "more": "More", + "@more": { + "type": "text", + "placeholders": {} + }, + "andNMore": "And {count} more", + "@andNMore": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "done": "Done", + "@done": { + "type": "text", + "placeholders": {} + }, + "create": "Create", + "@create": { + "type": "text", + "placeholders": {} + }, + "add": "Add", + "@add": { + "type": "text", + "placeholders": {} + }, "remove": "Remove", "@remove": { "type": "text", @@ -180,11 +291,6 @@ "type": "text", "placeholders": {} }, - "deletionError": "Deletion error", - "@deletionError": { - "type": "text", - "placeholders": {} - }, "edit": "Edit", "@edit": { "type": "text", @@ -200,18 +306,8 @@ "type": "text", "placeholders": {} }, - "errorMessage": "Oops! An error occurred", - "@errorMessage": { - "type": "text", - "placeholders": {} - }, - "errorDetails": "Error details", - "@errorDetails": { - "type": "text", - "placeholders": {} - }, - "playbackErrorMessage": "An error occurred during the playback, removing the track", - "@playbackErrorMessage": { + "nothingHere": "There's nothing here", + "@nothingHere": { "type": "text", "placeholders": {} }, @@ -230,11 +326,6 @@ "type": "text", "placeholders": {} }, - "unknownRoute": "Unknown route!", - "@unknownRoute": { - "type": "text", - "placeholders": {} - }, "found": "Found", "@found": { "type": "text", @@ -275,8 +366,8 @@ "type": "text", "placeholders": {} }, - "openAppSettingsError": "Error opening the app settings", - "@openAppSettingsError": { + "selected": "Selected", + "@selected": { "type": "text", "placeholders": {} }, @@ -290,23 +381,48 @@ "type": "text", "placeholders": {} }, + "goToArtist": "Go to artist", + "@goToArtist": { + "type": "text", + "placeholders": {} + }, "playNext": "Play next", "@playNext": { "type": "text", "placeholders": {} }, + "addToPlaylist": "Add to playlist", + "@addToPlaylist": { + "type": "text", + "placeholders": {} + }, + "addToFavorites": "Add to favorites", + "@addToFavorites": { + "type": "text", + "placeholders": {} + }, "addToQueue": "Add to queue", "@addToQueue": { "type": "text", "placeholders": {} }, - "sort": "Sort", - "@sort": { + "removeFromQueue": "Remove from queue", + "@removeFromQueue": { + "type": "text", + "placeholders": {} + }, + "share": "Share", + "@share": { "type": "text", "placeholders": {} }, - "artist": "Artist", - "@artist": { + "selectAll": "Select all", + "@selectAll": { + "type": "text", + "placeholders": {} + }, + "sort": "Sort", + "@sort": { "type": "text", "placeholders": {} }, @@ -315,6 +431,11 @@ "type": "text", "placeholders": {} }, + "name": "Name", + "@name": { + "type": "text", + "placeholders": {} + }, "dateModified": "Date modified", "@dateModified": { "type": "text", @@ -335,13 +456,8 @@ "type": "text", "placeholders": {} }, - "deletionPromptDescriptionP1": "Are you sure you want to delete ", - "@deletionPromptDescriptionP1": { - "type": "text", - "placeholders": {} - }, - "deletionPromptDescriptionP2": " selected tracks?", - "@deletionPromptDescriptionP2": { + "numberOfAlbums": "Number of albums", + "@numberOfAlbums": { "type": "text", "placeholders": {} }, @@ -372,16 +488,6 @@ "type": "text", "placeholders": {} }, - "devErrorSnackbar": "Show error snackbar", - "@devErrorSnackbar": { - "type": "text", - "placeholders": {} - }, - "devImportantSnackbar": "Show important snackbar", - "@devImportantSnackbar": { - "type": "text", - "placeholders": {} - }, "devAnimationsSlowMo": "Slow down animations", "@devAnimationsSlowMo": { "type": "text", @@ -446,5 +552,35 @@ "@searchClearHistory": { "type": "text", "placeholders": {} + }, + "oopsErrorOccurred": "Oops! An error occurred", + "@oopsErrorOccurred": { + "type": "text", + "placeholders": {} + }, + "errorDetails": "Error details", + "@errorDetails": { + "type": "text", + "placeholders": {} + }, + "deletionError": "Deletion error", + "@deletionError": { + "type": "text", + "placeholders": {} + }, + "playlistDoesNotExistError": "Can't find the playlist, perhaps it was deleted", + "@playlistDoesNotExistError": { + "type": "text", + "placeholders": {} + }, + "playbackError": "An error occurred during the playback", + "@playbackError": { + "type": "text", + "placeholders": {} + }, + "openAppSettingsError": "Error opening the app settings", + "@openAppSettingsError": { + "type": "text", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/localization/arb/intl_messages.arb b/lib/localization/arb/intl_messages.arb index ae63695d8..942c3a974 100644 --- a/lib/localization/arb/intl_messages.arb +++ b/lib/localization/arb/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2021-04-19T17:48:08.138682", + "@@last_modified": "2021-07-14T21:24:45.452638", "playback": "Playback", "@playback": { "type": "text", @@ -75,6 +75,16 @@ "type": "text", "placeholders": {} }, + "playlists": "Playlists", + "@playlists": { + "type": "text", + "placeholders": {} + }, + "artists": "Artists", + "@artists": { + "type": "text", + "placeholders": {} + }, "tracksPlural": "{count,plural, =0{Tracks}=1{Track}=2{Tracks}few{Tracks}many{Tracks}other{Tracks}}", "@tracksPlural": { "type": "text", @@ -89,18 +99,32 @@ "count": {} } }, - "allTracks": "All tracks", - "@allTracks": { + "playlistsPlural": "{count,plural, =0{Playlists}=1{Playlist}=2{Playlists}few{Playlists}many{Playlists}other{Playlists}}", + "@playlistsPlural": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "artistsPlural": "{count,plural, =0{Artists}=1{Artist}=2{Artists}few{Artists}many{Artists}other{Artists}}", + "@artistsPlural": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "albumNotFound": "Album not found", + "@albumNotFound": { "type": "text", "placeholders": {} }, - "playlist": "Playlist", - "@playlist": { + "artistNotFound": "Artist not found", + "@artistNotFound": { "type": "text", "placeholders": {} }, - "arbitraryQueue": "Arbitrary queue", - "@arbitraryQueue": { + "allTracks": "All tracks", + "@allTracks": { "type": "text", "placeholders": {} }, @@ -109,6 +133,21 @@ "type": "text", "placeholders": {} }, + "allPlaylists": "All playlists", + "@allPlaylists": { + "type": "text", + "placeholders": {} + }, + "allArtists": "All artitst", + "@allArtists": { + "type": "text", + "placeholders": {} + }, + "arbitraryQueue": "Arbitrary queue", + "@arbitraryQueue": { + "type": "text", + "placeholders": {} + }, "shuffled": "Shuffled", "@shuffled": { "type": "text", @@ -124,6 +163,31 @@ "type": "text", "placeholders": {} }, + "selectedPlural": "Selected", + "@selectedPlural": { + "type": "text", + "placeholders": {} + }, + "newPlaylist": "New playlist", + "@newPlaylist": { + "type": "text", + "placeholders": {} + }, + "trackAfterWhichToInsert": "Track after which to insert", + "@trackAfterWhichToInsert": { + "type": "text", + "placeholders": {} + }, + "insertAtTheBeginning": "Insert at the beginning", + "@insertAtTheBeginning": { + "type": "text", + "placeholders": {} + }, + "saveQueueAsPlaylist": "Save queue as playlist", + "@saveQueueAsPlaylist": { + "type": "text", + "placeholders": {} + }, "playContentList": "Play", "@playContentList": { "type": "text", @@ -144,6 +208,11 @@ "type": "text", "placeholders": {} }, + "areYouSureYouWantTo": "Are you sure you want to", + "@areYouSureYouWantTo": { + "type": "text", + "placeholders": {} + }, "reset": "Reset", "@reset": { "type": "text", @@ -154,6 +223,16 @@ "type": "text", "placeholders": {} }, + "saved": "Saved", + "@saved": { + "type": "text", + "placeholders": {} + }, + "view": "View", + "@view": { + "type": "text", + "placeholders": {} + }, "secondsShorthand": "s", "@secondsShorthand": { "type": "text", @@ -164,6 +243,38 @@ "type": "text", "placeholders": {} }, + "and": "And", + "@and": { + "type": "text", + "placeholders": {} + }, + "more": "More", + "@more": { + "type": "text", + "placeholders": {} + }, + "andNMore": "And {count} more", + "@andNMore": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "done": "Done", + "@done": { + "type": "text", + "placeholders": {} + }, + "create": "Create", + "@create": { + "type": "text", + "placeholders": {} + }, + "add": "Add", + "@add": { + "type": "text", + "placeholders": {} + }, "remove": "Remove", "@remove": { "type": "text", @@ -179,11 +290,6 @@ "type": "text", "placeholders": {} }, - "deletionError": "Deletion error", - "@deletionError": { - "type": "text", - "placeholders": {} - }, "edit": "Edit", "@edit": { "type": "text", @@ -199,18 +305,8 @@ "type": "text", "placeholders": {} }, - "errorMessage": "Oops! An error occurred", - "@errorMessage": { - "type": "text", - "placeholders": {} - }, - "errorDetails": "Error details", - "@errorDetails": { - "type": "text", - "placeholders": {} - }, - "playbackErrorMessage": "An error occurred during the playback, removing the track", - "@playbackErrorMessage": { + "nothingHere": "There's nothing here", + "@nothingHere": { "type": "text", "placeholders": {} }, @@ -229,11 +325,6 @@ "type": "text", "placeholders": {} }, - "unknownRoute": "Unknown route!", - "@unknownRoute": { - "type": "text", - "placeholders": {} - }, "found": "Found", "@found": { "type": "text", @@ -274,8 +365,8 @@ "type": "text", "placeholders": {} }, - "openAppSettingsError": "Error opening the app settings", - "@openAppSettingsError": { + "selected": "Selected", + "@selected": { "type": "text", "placeholders": {} }, @@ -289,23 +380,53 @@ "type": "text", "placeholders": {} }, + "goToArtist": "Go to artist", + "@goToArtist": { + "type": "text", + "placeholders": {} + }, "playNext": "Play next", "@playNext": { "type": "text", "placeholders": {} }, + "addToPlaylist": "Add to playlist", + "@addToPlaylist": { + "type": "text", + "placeholders": {} + }, + "removeFromPlaylist": "Remove from playlist", + "@removeFromPlaylist": { + "type": "text", + "placeholders": {} + }, + "addToFavorites": "Add to favorites", + "@addToFavorites": { + "type": "text", + "placeholders": {} + }, "addToQueue": "Add to queue", "@addToQueue": { "type": "text", "placeholders": {} }, - "sort": "Sort", - "@sort": { + "removeFromQueue": "Remove from queue", + "@removeFromQueue": { + "type": "text", + "placeholders": {} + }, + "share": "Share", + "@share": { + "type": "text", + "placeholders": {} + }, + "selectAll": "Select all", + "@selectAll": { "type": "text", "placeholders": {} }, - "artist": "Artist", - "@artist": { + "sort": "Sort", + "@sort": { "type": "text", "placeholders": {} }, @@ -314,6 +435,11 @@ "type": "text", "placeholders": {} }, + "name": "Name", + "@name": { + "type": "text", + "placeholders": {} + }, "dateModified": "Date modified", "@dateModified": { "type": "text", @@ -334,13 +460,8 @@ "type": "text", "placeholders": {} }, - "deletionPromptDescriptionP1": "Are you sure you want to delete ", - "@deletionPromptDescriptionP1": { - "type": "text", - "placeholders": {} - }, - "deletionPromptDescriptionP2": " selected tracks?", - "@deletionPromptDescriptionP2": { + "numberOfAlbums": "Number of albums", + "@numberOfAlbums": { "type": "text", "placeholders": {} }, @@ -371,16 +492,6 @@ "type": "text", "placeholders": {} }, - "devErrorSnackbar": "Show error snackbar", - "@devErrorSnackbar": { - "type": "text", - "placeholders": {} - }, - "devImportantSnackbar": "Show important snackbar", - "@devImportantSnackbar": { - "type": "text", - "placeholders": {} - }, "devAnimationsSlowMo": "Slow down animations", "@devAnimationsSlowMo": { "type": "text", @@ -445,5 +556,35 @@ "@searchClearHistory": { "type": "text", "placeholders": {} + }, + "oopsErrorOccurred": "Oops! An error occurred", + "@oopsErrorOccurred": { + "type": "text", + "placeholders": {} + }, + "errorDetails": "Error details", + "@errorDetails": { + "type": "text", + "placeholders": {} + }, + "deletionError": "Deletion error", + "@deletionError": { + "type": "text", + "placeholders": {} + }, + "playlistDoesNotExistError": "Can't find the playlist, perhaps it was deleted", + "@playlistDoesNotExistError": { + "type": "text", + "placeholders": {} + }, + "playbackError": "An error occurred during the playback", + "@playbackError": { + "type": "text", + "placeholders": {} + }, + "openAppSettingsError": "Error opening the app settings", + "@openAppSettingsError": { + "type": "text", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/localization/arb/intl_ru_ru.arb b/lib/localization/arb/intl_ru_ru.arb index 6ca987de4..69ebddce5 100644 --- a/lib/localization/arb/intl_ru_ru.arb +++ b/lib/localization/arb/intl_ru_ru.arb @@ -76,6 +76,16 @@ "type": "text", "placeholders": {} }, + "playlists": "Плейлисты", + "@playlists": { + "type": "text", + "placeholders": {} + }, + "artists": "Исполнители", + "@artists": { + "type": "text", + "placeholders": {} + }, "tracksPlural": "{count,plural, =0{Треков}=1{Трек}=2{Трека}few{Трека}many{Треков}other{Трека}}", "@tracksPlural": { "type": "text", @@ -90,11 +100,50 @@ "count": {} } }, + "playlistsPlural": "{count,plural, =0{Плейлистов}=1{Плейлист}=2{Плейлиста}few{Плейлиста}many{Плейлистов}other{Плейлиста}}", + "@playlistsPlural": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "artistsPlural": "{count,plural, =0{Исполнителей}=1{Исполнитель}=2{Исполнителя}few{Исполнителя}many{Исполнителей}other{Исполнителя}}", + "@artistsPlural": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "albumNotFound": "Альбом не найден", + "@albumNotFound": { + "type": "text", + "placeholders": {} + }, + "artistNotFound": "Исполнитель не найден", + "@artistNotFound": { + "type": "text", + "placeholders": {} + }, "allTracks": "Все треки", "@allTracks": { "type": "text", "placeholders": {} }, + "allAlbums": "Все альбомы", + "@allAlbums": { + "type": "text", + "placeholders": {} + }, + "allPlaylists": "Все плейлисты", + "@allPlaylists": { + "type": "text", + "placeholders": {} + }, + "allArtists": "Все исполнители", + "@allArtists": { + "type": "text", + "placeholders": {} + }, "playlist": "Плейлист", "@playlist": { "type": "text", @@ -105,11 +154,6 @@ "type": "text", "placeholders": {} }, - "allAlbums": "Все альбомы", - "@allAlbums": { - "type": "text", - "placeholders": {} - }, "shuffled": "Перемешано", "@shuffled": { "type": "text", @@ -125,6 +169,31 @@ "type": "text", "placeholders": {} }, + "selectedPlural": "Выбранные", + "@selectedPlural": { + "type": "text", + "placeholders": {} + }, + "newPlaylist": "Новый плейлист", + "@newPlaylist": { + "type": "text", + "placeholders": {} + }, + "trackAfterWhichToInsert": "Трек, после которого вставить", + "@trackAfterWhichToInsert": { + "type": "text", + "placeholders": {} + }, + "insertAtTheBeginning": "Вставить в начало", + "@insertAtTheBeginning": { + "type": "text", + "placeholders": {} + }, + "saveQueueAsPlaylist": "Сохранить очередь как плейлист", + "@saveQueueAsPlaylist": { + "type": "text", + "placeholders": {} + }, "playContentList": "Включить", "@playContentList": { "type": "text", @@ -145,6 +214,11 @@ "type": "text", "placeholders": {} }, + "areYouSureYouWantTo": "Вы точно хотите", + "@areYouSureYouWantTo": { + "type": "text", + "placeholders": {} + }, "reset": "Сбросить", "@reset": { "type": "text", @@ -155,6 +229,16 @@ "type": "text", "placeholders": {} }, + "saved": "Сохранено", + "@saved": { + "type": "text", + "placeholders": {} + }, + "view": "View", + "@view": { + "type": "text", + "placeholders": {} + }, "secondsShorthand": "с", "@secondsShorthand": { "type": "text", @@ -165,6 +249,38 @@ "type": "text", "placeholders": {} }, + "and": "И", + "@and": { + "type": "text", + "placeholders": {} + }, + "more": "Еще", + "@more": { + "type": "text", + "placeholders": {} + }, + "andNMore": "И еще {count}", + "@andNMore": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "done": "Готово", + "@done": { + "type": "text", + "placeholders": {} + }, + "create": "Создать", + "@create": { + "type": "text", + "placeholders": {} + }, + "add": "Добавить", + "@add": { + "type": "text", + "placeholders": {} + }, "remove": "Убрать", "@remove": { "type": "text", @@ -180,12 +296,7 @@ "type": "text", "placeholders": {} }, - "deletionError": "Ошибка при удалении", - "@deletionError": { - "type": "text", - "placeholders": {} - }, - "edit": "Редактировать", + "edit": "Изменить", "@edit": { "type": "text", "placeholders": {} @@ -200,18 +311,8 @@ "type": "text", "placeholders": {} }, - "errorMessage": "Упс! Произошла ошибка", - "@errorMessage": { - "type": "text", - "placeholders": {} - }, - "errorDetails": "Информация об ошибке", - "@errorDetails": { - "type": "text", - "placeholders": {} - }, - "playbackErrorMessage": "Произошла ошибка при воспроизведении, удаление трека", - "@playbackErrorMessage": { + "nothingHere": "Здесь ничего нет", + "@nothingHere": { "type": "text", "placeholders": {} }, @@ -230,11 +331,6 @@ "type": "text", "placeholders": {} }, - "unknownRoute": "Неизвестная страница!", - "@unknownRoute": { - "type": "text", - "placeholders": {} - }, "found": "Найдено", "@found": { "type": "text", @@ -275,31 +371,61 @@ "type": "text", "placeholders": {} }, - "openAppSettingsError": "Произошла ошибка при открытии настроек приложения", - "@openAppSettingsError": { - "type": "text", - "placeholders": {} + "selected": "Выбрано", + "@selected": { + "type": "text", + "placeholders": {} }, "actions": "Действия", "@actions": { "type": "text", "placeholders": {} }, - "goToAlbum": "Открыть альбом", + "goToAlbum": "Перейти к альбому", "@goToAlbum": { "type": "text", "placeholders": {} }, + "goToArtist": "Перейти к исполнителю", + "@goToArtist": { + "type": "text", + "placeholders": {} + }, "playNext": "Включить следующим", "@playNext": { "type": "text", "placeholders": {} }, + "addToPlaylist": "Добавить в плейлист", + "@addToPlaylist": { + "type": "text", + "placeholders": {} + }, + "addToFavorites": "Добавить в избранное", + "@addToFavorites": { + "type": "text", + "placeholders": {} + }, "addToQueue": "Добавить в очередь", "@addToQueue": { "type": "text", "placeholders": {} }, + "removeFromQueue": "Убрать из очереди", + "@removeFromQueue": { + "type": "text", + "placeholders": {} + }, + "share": "Поделиться", + "@share": { + "type": "text", + "placeholders": {} + }, + "selectAll": "Выбрать все", + "@selectAll": { + "type": "text", + "placeholders": {} + }, "sort": "Сортировка", "@sort": { "type": "text", @@ -315,6 +441,11 @@ "type": "text", "placeholders": {} }, + "name": "Имя", + "@name": { + "type": "text", + "placeholders": {} + }, "dateModified": "Дата изменения", "@dateModified": { "type": "text", @@ -335,13 +466,8 @@ "type": "text", "placeholders": {} }, - "deletionPromptDescriptionP1": "Вы точно хотите удалить ", - "@deletionPromptDescriptionP1": { - "type": "text", - "placeholders": {} - }, - "deletionPromptDescriptionP2": " выбранные треки?", - "@deletionPromptDescriptionP2": { + "numberOfAlbums": "Количество альбомов", + "@numberOfAlbums": { "type": "text", "placeholders": {} }, @@ -372,16 +498,6 @@ "type": "text", "placeholders": {} }, - "devErrorSnackbar": "Показать снакбар с ошибкой", - "@devErrorSnackbar": { - "type": "text", - "placeholders": {} - }, - "devImportantSnackbar": "Показать важный снакбар", - "@devImportantSnackbar": { - "type": "text", - "placeholders": {} - }, "devAnimationsSlowMo": "Замедление анимаций", "@devAnimationsSlowMo": { "type": "text", @@ -446,5 +562,35 @@ "@searchClearHistory": { "type": "text", "placeholders": {} + }, + "oopsErrorOccurred": "Упс! Произошла ошибка", + "@oopsErrorOccurred": { + "type": "text", + "placeholders": {} + }, + "errorDetails": "Информация об ошибке", + "@errorDetails": { + "type": "text", + "placeholders": {} + }, + "deletionError": "Ошибка при удалении", + "@deletionError": { + "type": "text", + "placeholders": {} + }, + "playlistDoesNotExistError": "Плейлист не найден, возможно, он был удален", + "@playlistDoesNotExistError": { + "type": "text", + "placeholders": {} + }, + "playbackError": "Произошла ошибка при воспроизведении", + "@playbackError": { + "type": "text", + "placeholders": {} + }, + "openAppSettingsError": "Произошла ошибка при открытии настроек приложения", + "@openAppSettingsError": { + "type": "text", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/localization/gen/messages_all.dart b/lib/localization/gen/messages_all.dart index b14d9faf7..479230ab0 100644 --- a/lib/localization/gen/messages_all.dart +++ b/lib/localization/gen/messages_all.dart @@ -9,6 +9,9 @@ // ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases // ignore_for_file:comment_references +// @dart = 2.7 + + import 'dart:async'; import 'package:intl/intl.dart'; diff --git a/lib/localization/gen/messages_en_us.dart b/lib/localization/gen/messages_en_us.dart index f3cb46d30..142c61f46 100644 --- a/lib/localization/gen/messages_en_us.dart +++ b/lib/localization/gen/messages_en_us.dart @@ -9,6 +9,9 @@ // ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases // ignore_for_file:unused_import, file_names +// @dart = 2.7 + + import 'package:intl/intl.dart'; import 'package:intl/message_lookup_by_library.dart'; @@ -21,58 +24,80 @@ class MessageLookup extends MessageLookupByLibrary { static m0(count) => "${Intl.plural(count, zero: 'Albums', one: 'Album', two: 'Albums', few: 'Albums', many: 'Albums', other: 'Albums')}"; - static m1(remainingClicks) => "only ${remainingClicks} clicks remaining..."; + static m1(count) => "And ${count} more"; + + static m2(count) => "${Intl.plural(count, zero: 'Artists', one: 'Artist', two: 'Artists', few: 'Artists', many: 'Artists', other: 'Artists')}"; + + static m3(remainingClicks) => "only ${remainingClicks} clicks remaining..."; + + static m4(count) => "${Intl.plural(count, zero: 'Playlists', one: 'Playlist', two: 'Playlists', few: 'Playlists', many: 'Playlists', other: 'Playlists')}"; - static m2(count) => "${Intl.plural(count, zero: 'Tracks', one: 'Track', two: 'Tracks', few: 'Tracks', many: 'Tracks', other: 'Tracks')}"; + static m5(count) => "${Intl.plural(count, zero: 'Tracks', one: 'Track', two: 'Tracks', few: 'Tracks', many: 'Tracks', other: 'Tracks')}"; final messages = _notInlinedMessages(_notInlinedMessages); static _notInlinedMessages(_) => { "actions" : MessageLookupByLibrary.simpleMessage("Actions"), + "add" : MessageLookupByLibrary.simpleMessage("Add"), + "addToFavorites" : MessageLookupByLibrary.simpleMessage("Add to favorites"), + "addToPlaylist" : MessageLookupByLibrary.simpleMessage("Add to playlist"), "addToQueue" : MessageLookupByLibrary.simpleMessage("Add to queue"), + "albumNotFound" : MessageLookupByLibrary.simpleMessage("Album not found"), "albums" : MessageLookupByLibrary.simpleMessage("Albums"), "albumsPlural" : m0, "allAlbums" : MessageLookupByLibrary.simpleMessage("All albums"), + "allArtists" : MessageLookupByLibrary.simpleMessage("All artitst"), + "allPlaylists" : MessageLookupByLibrary.simpleMessage("All playlists"), "allTracks" : MessageLookupByLibrary.simpleMessage("All tracks"), "allowAccessToExternalStorage" : MessageLookupByLibrary.simpleMessage("Please, allow access to storage"), "allowAccessToExternalStorageManually" : MessageLookupByLibrary.simpleMessage("Allow access to storage manually"), "almostThere" : MessageLookupByLibrary.simpleMessage("You\'re almost there"), + "and" : MessageLookupByLibrary.simpleMessage("And"), + "andNMore" : m1, "arbitraryQueue" : MessageLookupByLibrary.simpleMessage("Arbitrary queue"), "areYouSure" : MessageLookupByLibrary.simpleMessage("Are you sure?"), - "artist" : MessageLookupByLibrary.simpleMessage("Artist"), + "areYouSureYouWantTo" : MessageLookupByLibrary.simpleMessage("Are you sure you want to"), + "artistNotFound" : MessageLookupByLibrary.simpleMessage("Artist not found"), "artistUnknown" : MessageLookupByLibrary.simpleMessage("Unknown artist"), + "artists" : MessageLookupByLibrary.simpleMessage("Artists"), + "artistsPlural" : m2, "byQuery" : MessageLookupByLibrary.simpleMessage("By query"), + "create" : MessageLookupByLibrary.simpleMessage("Create"), "dateAdded" : MessageLookupByLibrary.simpleMessage("Date added"), "dateModified" : MessageLookupByLibrary.simpleMessage("Date modified"), "debug" : MessageLookupByLibrary.simpleMessage("Debug"), "delete" : MessageLookupByLibrary.simpleMessage("Delete"), "deletion" : MessageLookupByLibrary.simpleMessage("Deletion"), "deletionError" : MessageLookupByLibrary.simpleMessage("Deletion error"), - "deletionPromptDescriptionP1" : MessageLookupByLibrary.simpleMessage("Are you sure you want to delete "), - "deletionPromptDescriptionP2" : MessageLookupByLibrary.simpleMessage(" selected tracks?"), "details" : MessageLookupByLibrary.simpleMessage("Details"), "devAnimationsSlowMo" : MessageLookupByLibrary.simpleMessage("Slow down animations"), - "devErrorSnackbar" : MessageLookupByLibrary.simpleMessage("Show error snackbar"), - "devImportantSnackbar" : MessageLookupByLibrary.simpleMessage("Show important snackbar"), "devModeGreet" : MessageLookupByLibrary.simpleMessage("Done! You are now a developer"), "devTestToast" : MessageLookupByLibrary.simpleMessage("Test toast"), + "done" : MessageLookupByLibrary.simpleMessage("Done"), "edit" : MessageLookupByLibrary.simpleMessage("Edit"), "editMetadata" : MessageLookupByLibrary.simpleMessage("Edit metadata"), "errorDetails" : MessageLookupByLibrary.simpleMessage("Error details"), - "errorMessage" : MessageLookupByLibrary.simpleMessage("Oops! An error occurred"), "found" : MessageLookupByLibrary.simpleMessage("Found"), "general" : MessageLookupByLibrary.simpleMessage("General"), "goToAlbum" : MessageLookupByLibrary.simpleMessage("Go to album"), + "goToArtist" : MessageLookupByLibrary.simpleMessage("Go to artist"), "grant" : MessageLookupByLibrary.simpleMessage("Grant"), + "insertAtTheBeginning" : MessageLookupByLibrary.simpleMessage("Insert at the beginning"), "loopOff" : MessageLookupByLibrary.simpleMessage("Loop off"), "loopOn" : MessageLookupByLibrary.simpleMessage("Loop on"), "minutesShorthand" : MessageLookupByLibrary.simpleMessage("min"), "modified" : MessageLookupByLibrary.simpleMessage("Modified"), + "more" : MessageLookupByLibrary.simpleMessage("More"), + "name" : MessageLookupByLibrary.simpleMessage("Name"), + "newPlaylist" : MessageLookupByLibrary.simpleMessage("New playlist"), "next" : MessageLookupByLibrary.simpleMessage("Next"), "noMusic" : MessageLookupByLibrary.simpleMessage("There\'s no music on your device"), + "nothingHere" : MessageLookupByLibrary.simpleMessage("There\'s nothing here"), + "numberOfAlbums" : MessageLookupByLibrary.simpleMessage("Number of albums"), "numberOfTracks" : MessageLookupByLibrary.simpleMessage("Number of tracks"), "onThePathToDevMode" : MessageLookupByLibrary.simpleMessage("Something should happen now..."), - "onThePathToDevModeClicksRemaining" : m1, + "onThePathToDevModeClicksRemaining" : m3, "onThePathToDevModeLastClick" : MessageLookupByLibrary.simpleMessage("only 1 click remaining..."), + "oopsErrorOccurred" : MessageLookupByLibrary.simpleMessage("Oops! An error occurred"), "openAppSettingsError" : MessageLookupByLibrary.simpleMessage("Error opening the app settings"), "pause" : MessageLookupByLibrary.simpleMessage("Pause"), "play" : MessageLookupByLibrary.simpleMessage("Play"), @@ -81,16 +106,21 @@ class MessageLookup extends MessageLookupByLibrary { "playRecent" : MessageLookupByLibrary.simpleMessage("Play recent"), "playback" : MessageLookupByLibrary.simpleMessage("Playback"), "playbackControls" : MessageLookupByLibrary.simpleMessage("Playback controls"), - "playbackErrorMessage" : MessageLookupByLibrary.simpleMessage("An error occurred during the playback, removing the track"), - "playlist" : MessageLookupByLibrary.simpleMessage("Playlist"), + "playbackError" : MessageLookupByLibrary.simpleMessage("An error occurred during the playback"), + "playlistDoesNotExistError" : MessageLookupByLibrary.simpleMessage("Can\'t find the playlist, perhaps it was deleted"), + "playlists" : MessageLookupByLibrary.simpleMessage("Playlists"), + "playlistsPlural" : m4, "pressOnceAgainToExit" : MessageLookupByLibrary.simpleMessage("Press once again to exit"), "previous" : MessageLookupByLibrary.simpleMessage("Previous"), "quitDevMode" : MessageLookupByLibrary.simpleMessage("Quit the developer mode"), "quitDevModeDescription" : MessageLookupByLibrary.simpleMessage("Stop being a developer?"), "refresh" : MessageLookupByLibrary.simpleMessage("Refresh"), "remove" : MessageLookupByLibrary.simpleMessage("Remove"), + "removeFromQueue" : MessageLookupByLibrary.simpleMessage("Remove from queue"), "reset" : MessageLookupByLibrary.simpleMessage("Reset"), "save" : MessageLookupByLibrary.simpleMessage("Save"), + "saveQueueAsPlaylist" : MessageLookupByLibrary.simpleMessage("Save queue as playlist"), + "saved" : MessageLookupByLibrary.simpleMessage("Saved"), "search" : MessageLookupByLibrary.simpleMessage("Search"), "searchClearHistory" : MessageLookupByLibrary.simpleMessage("Clear search history?"), "searchHistory" : MessageLookupByLibrary.simpleMessage("Search history"), @@ -100,8 +130,12 @@ class MessageLookup extends MessageLookupByLibrary { "searchNothingFound" : MessageLookupByLibrary.simpleMessage("Nothing found"), "searchingForTracks" : MessageLookupByLibrary.simpleMessage("Searching for tracks..."), "secondsShorthand" : MessageLookupByLibrary.simpleMessage("s"), + "selectAll" : MessageLookupByLibrary.simpleMessage("Select all"), + "selected" : MessageLookupByLibrary.simpleMessage("Selected"), + "selectedPlural" : MessageLookupByLibrary.simpleMessage("Selected"), "settingLightMode" : MessageLookupByLibrary.simpleMessage("Light mode"), "settings" : MessageLookupByLibrary.simpleMessage("Settings"), + "share" : MessageLookupByLibrary.simpleMessage("Share"), "shuffleAll" : MessageLookupByLibrary.simpleMessage("Shuffle all"), "shuffleContentList" : MessageLookupByLibrary.simpleMessage("Shuffle"), "shuffled" : MessageLookupByLibrary.simpleMessage("Shuffled"), @@ -110,10 +144,11 @@ class MessageLookup extends MessageLookupByLibrary { "stop" : MessageLookupByLibrary.simpleMessage("Stop"), "theme" : MessageLookupByLibrary.simpleMessage("Theme"), "title" : MessageLookupByLibrary.simpleMessage("Title"), + "trackAfterWhichToInsert" : MessageLookupByLibrary.simpleMessage("Track after which to insert"), "tracks" : MessageLookupByLibrary.simpleMessage("Tracks"), - "tracksPlural" : m2, - "unknownRoute" : MessageLookupByLibrary.simpleMessage("Unknown route!"), + "tracksPlural" : m5, "upNext" : MessageLookupByLibrary.simpleMessage("Up next"), + "view" : MessageLookupByLibrary.simpleMessage("View"), "year" : MessageLookupByLibrary.simpleMessage("Year") }; } diff --git a/lib/localization/gen/messages_ru_ru.dart b/lib/localization/gen/messages_ru_ru.dart index 1e11e43bc..9f35cede7 100644 --- a/lib/localization/gen/messages_ru_ru.dart +++ b/lib/localization/gen/messages_ru_ru.dart @@ -9,6 +9,8 @@ // ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases // ignore_for_file:unused_import, file_names +// @dart = 2.7 + import 'package:intl/intl.dart'; import 'package:intl/message_lookup_by_library.dart'; @@ -21,58 +23,80 @@ class MessageLookup extends MessageLookupByLibrary { static m0(count) => "${Intl.plural(count, zero: 'Альбомов', one: 'Альбом', two: 'Альбома', few: 'Альбома', many: 'Альбомов', other: 'Альбома')}"; - static m1(remainingClicks) => "осталось всего ${remainingClicks} клика..."; + static m1(count) => "И еще ${count}"; + + static m2(count) => "${Intl.plural(count, zero: 'Исполнителей', one: 'Исполнитель', two: 'Исполнителя', few: 'Исполнителя', many: 'Исполнителей', other: 'Исполнителя')}"; + + static m3(remainingClicks) => "осталось всего ${remainingClicks} клика..."; + + static m4(count) => "${Intl.plural(count, zero: 'Плейлистов', one: 'Плейлист', two: 'Плейлиста', few: 'Плейлиста', many: 'Плейлистов', other: 'Плейлиста')}"; - static m2(count) => "${Intl.plural(count, zero: 'Треков', one: 'Трек', two: 'Трека', few: 'Трека', many: 'Треков', other: 'Трека')}"; + static m5(count) => "${Intl.plural(count, zero: 'Треков', one: 'Трек', two: 'Трека', few: 'Трека', many: 'Треков', other: 'Трека')}"; final messages = _notInlinedMessages(_notInlinedMessages); static _notInlinedMessages(_) => { "actions" : MessageLookupByLibrary.simpleMessage("Действия"), + "add" : MessageLookupByLibrary.simpleMessage("Добавить"), + "addToFavorites" : MessageLookupByLibrary.simpleMessage("Добавить в избранное"), + "addToPlaylist" : MessageLookupByLibrary.simpleMessage("Добавить в плейлист"), "addToQueue" : MessageLookupByLibrary.simpleMessage("Добавить в очередь"), + "albumNotFound" : MessageLookupByLibrary.simpleMessage("Альбом не найден"), "albums" : MessageLookupByLibrary.simpleMessage("Альбомы"), "albumsPlural" : m0, "allAlbums" : MessageLookupByLibrary.simpleMessage("Все альбомы"), + "allArtists" : MessageLookupByLibrary.simpleMessage("Все исполнители"), + "allPlaylists" : MessageLookupByLibrary.simpleMessage("Все плейлисты"), "allTracks" : MessageLookupByLibrary.simpleMessage("Все треки"), "allowAccessToExternalStorage" : MessageLookupByLibrary.simpleMessage("Пожалуйста, предоставьте доступ к хранилищу"), "allowAccessToExternalStorageManually" : MessageLookupByLibrary.simpleMessage("Предоставьте доступ к хранилищу вручную"), "almostThere" : MessageLookupByLibrary.simpleMessage("Вы почти у цели"), + "and" : MessageLookupByLibrary.simpleMessage("И"), + "andNMore" : m1, "arbitraryQueue" : MessageLookupByLibrary.simpleMessage("Произвольная очередь"), "areYouSure" : MessageLookupByLibrary.simpleMessage("Вы уверены?"), - "artist" : MessageLookupByLibrary.simpleMessage("Исполнитель"), + "areYouSureYouWantTo" : MessageLookupByLibrary.simpleMessage("Вы точно хотите"), + "artistNotFound" : MessageLookupByLibrary.simpleMessage("Исполнитель не найден"), "artistUnknown" : MessageLookupByLibrary.simpleMessage("Неизвестный исполнитель"), + "artists" : MessageLookupByLibrary.simpleMessage("Исполнители"), + "artistsPlural" : m2, "byQuery" : MessageLookupByLibrary.simpleMessage("По запросу"), + "create" : MessageLookupByLibrary.simpleMessage("Создать"), "dateAdded" : MessageLookupByLibrary.simpleMessage("Дата добавления"), "dateModified" : MessageLookupByLibrary.simpleMessage("Дата изменения"), "debug" : MessageLookupByLibrary.simpleMessage("Дебаг"), "delete" : MessageLookupByLibrary.simpleMessage("Удалить"), "deletion" : MessageLookupByLibrary.simpleMessage("Удаление"), "deletionError" : MessageLookupByLibrary.simpleMessage("Ошибка при удалении"), - "deletionPromptDescriptionP1" : MessageLookupByLibrary.simpleMessage("Вы точно хотите удалить "), - "deletionPromptDescriptionP2" : MessageLookupByLibrary.simpleMessage(" выбранные треки?"), "details" : MessageLookupByLibrary.simpleMessage("Детали"), "devAnimationsSlowMo" : MessageLookupByLibrary.simpleMessage("Замедление анимаций"), - "devErrorSnackbar" : MessageLookupByLibrary.simpleMessage("Показать снакбар с ошибкой"), - "devImportantSnackbar" : MessageLookupByLibrary.simpleMessage("Показать важный снакбар"), "devModeGreet" : MessageLookupByLibrary.simpleMessage("Готово! Теперь вы разработчик"), "devTestToast" : MessageLookupByLibrary.simpleMessage("Тестовый тост"), - "edit" : MessageLookupByLibrary.simpleMessage("Редактировать"), + "done" : MessageLookupByLibrary.simpleMessage("Готово"), + "edit" : MessageLookupByLibrary.simpleMessage("Изменить"), "editMetadata" : MessageLookupByLibrary.simpleMessage("Изменить информацию"), "errorDetails" : MessageLookupByLibrary.simpleMessage("Информация об ошибке"), - "errorMessage" : MessageLookupByLibrary.simpleMessage("Упс! Произошла ошибка"), "found" : MessageLookupByLibrary.simpleMessage("Найдено"), "general" : MessageLookupByLibrary.simpleMessage("Общие"), - "goToAlbum" : MessageLookupByLibrary.simpleMessage("Открыть альбом"), + "goToAlbum" : MessageLookupByLibrary.simpleMessage("Перейти к альбому"), + "goToArtist" : MessageLookupByLibrary.simpleMessage("Перейти к исполнителю"), "grant" : MessageLookupByLibrary.simpleMessage("Предоставить"), + "insertAtTheBeginning" : MessageLookupByLibrary.simpleMessage("Вставить в начало"), "loopOff" : MessageLookupByLibrary.simpleMessage("Зацикливание отключено"), "loopOn" : MessageLookupByLibrary.simpleMessage("Повторять этот трек"), "minutesShorthand" : MessageLookupByLibrary.simpleMessage("мин"), "modified" : MessageLookupByLibrary.simpleMessage("Изменено"), + "more" : MessageLookupByLibrary.simpleMessage("Еще"), + "name" : MessageLookupByLibrary.simpleMessage("Имя"), + "newPlaylist" : MessageLookupByLibrary.simpleMessage("Новый плейлист"), "next" : MessageLookupByLibrary.simpleMessage("Далее"), "noMusic" : MessageLookupByLibrary.simpleMessage("На вашем устройстве нету музыки"), + "nothingHere" : MessageLookupByLibrary.simpleMessage("Здесь ничего нет"), + "numberOfAlbums" : MessageLookupByLibrary.simpleMessage("Количество альбомов"), "numberOfTracks" : MessageLookupByLibrary.simpleMessage("Количество треков"), "onThePathToDevMode" : MessageLookupByLibrary.simpleMessage("Сейчас должно что-то произойти..."), - "onThePathToDevModeClicksRemaining" : m1, + "onThePathToDevModeClicksRemaining" : m3, "onThePathToDevModeLastClick" : MessageLookupByLibrary.simpleMessage("остался всего 1 клик..."), + "oopsErrorOccurred" : MessageLookupByLibrary.simpleMessage("Упс! Произошла ошибка"), "openAppSettingsError" : MessageLookupByLibrary.simpleMessage("Произошла ошибка при открытии настроек приложения"), "pause" : MessageLookupByLibrary.simpleMessage("Пауза"), "play" : MessageLookupByLibrary.simpleMessage("Играть"), @@ -81,16 +105,21 @@ class MessageLookup extends MessageLookupByLibrary { "playRecent" : MessageLookupByLibrary.simpleMessage("Играть текущую очередь"), "playback" : MessageLookupByLibrary.simpleMessage("Воспроизведение"), "playbackControls" : MessageLookupByLibrary.simpleMessage("Управление воспроизвединем"), - "playbackErrorMessage" : MessageLookupByLibrary.simpleMessage("Произошла ошибка при воспроизведении, удаление трека"), - "playlist" : MessageLookupByLibrary.simpleMessage("Плейлист"), + "playbackError" : MessageLookupByLibrary.simpleMessage("Произошла ошибка при воспроизведении"), + "playlistDoesNotExistError" : MessageLookupByLibrary.simpleMessage("Плейлист не найден, возможно, он был удален"), + "playlists" : MessageLookupByLibrary.simpleMessage("Плейлисты"), + "playlistsPlural" : m4, "pressOnceAgainToExit" : MessageLookupByLibrary.simpleMessage("Нажмите еще раз для выхода"), "previous" : MessageLookupByLibrary.simpleMessage("Назад"), "quitDevMode" : MessageLookupByLibrary.simpleMessage("Выйти из режима разработчика"), "quitDevModeDescription" : MessageLookupByLibrary.simpleMessage("Перестать быть разработчиком?"), "refresh" : MessageLookupByLibrary.simpleMessage("Обновить"), "remove" : MessageLookupByLibrary.simpleMessage("Убрать"), + "removeFromQueue" : MessageLookupByLibrary.simpleMessage("Убрать из очереди"), "reset" : MessageLookupByLibrary.simpleMessage("Сбросить"), "save" : MessageLookupByLibrary.simpleMessage("Сохранить"), + "saveQueueAsPlaylist" : MessageLookupByLibrary.simpleMessage("Сохранить очередь как плейлист"), + "saved" : MessageLookupByLibrary.simpleMessage("Сохранено"), "search" : MessageLookupByLibrary.simpleMessage("Искать"), "searchClearHistory" : MessageLookupByLibrary.simpleMessage("Очистить историю поиска?"), "searchHistory" : MessageLookupByLibrary.simpleMessage("История поиска"), @@ -100,8 +129,12 @@ class MessageLookup extends MessageLookupByLibrary { "searchNothingFound" : MessageLookupByLibrary.simpleMessage("Ничего не найдено"), "searchingForTracks" : MessageLookupByLibrary.simpleMessage("Ищем треки..."), "secondsShorthand" : MessageLookupByLibrary.simpleMessage("с"), + "selectAll" : MessageLookupByLibrary.simpleMessage("Выбрать все"), + "selected" : MessageLookupByLibrary.simpleMessage("Выбрано"), + "selectedPlural" : MessageLookupByLibrary.simpleMessage("Выбранные"), "settingLightMode" : MessageLookupByLibrary.simpleMessage("Светлая тема"), "settings" : MessageLookupByLibrary.simpleMessage("Настройки"), + "share" : MessageLookupByLibrary.simpleMessage("Поделиться"), "shuffleAll" : MessageLookupByLibrary.simpleMessage("Перемешать все"), "shuffleContentList" : MessageLookupByLibrary.simpleMessage("Перемешать"), "shuffled" : MessageLookupByLibrary.simpleMessage("Перемешано"), @@ -110,10 +143,11 @@ class MessageLookup extends MessageLookupByLibrary { "stop" : MessageLookupByLibrary.simpleMessage("Оставновить"), "theme" : MessageLookupByLibrary.simpleMessage("Тема"), "title" : MessageLookupByLibrary.simpleMessage("Название"), + "trackAfterWhichToInsert" : MessageLookupByLibrary.simpleMessage("Трек, после которого вставить"), "tracks" : MessageLookupByLibrary.simpleMessage("Треки"), - "tracksPlural" : m2, - "unknownRoute" : MessageLookupByLibrary.simpleMessage("Неизвестная страница!"), + "tracksPlural" : m5, "upNext" : MessageLookupByLibrary.simpleMessage("Далее"), + "view" : MessageLookupByLibrary.simpleMessage("View"), "year" : MessageLookupByLibrary.simpleMessage("Год") }; } diff --git a/lib/localization/localization.dart b/lib/localization/localization.dart index b5338125c..6cce4d3af 100644 --- a/lib/localization/localization.dart +++ b/lib/localization/localization.dart @@ -3,6 +3,9 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ +// TODO: remove this (and other similar) when intl_translation supports nnbd https://github.com/dart-lang/intl_translation/issues/134 +// @dart = 2.7 + import 'dart:async'; import 'package:intl/intl.dart'; @@ -47,12 +50,12 @@ class AppLocalizations { return Localizations.of(context, AppLocalizations); } - //* Used in notification + //* Used in notification ****************** /// Used as notification channel name. String get playback { return Intl.message( - 'Playback', + "Playback", name: 'playback', ); } @@ -60,89 +63,89 @@ class AppLocalizations { /// Used as notification channel description. String get playbackControls { return Intl.message( - 'Playback controls', + "Playback controls", name: 'playbackControls', ); } String get play { return Intl.message( - 'Play', + "Play", name: 'play', ); } String get pause { return Intl.message( - 'Pause', + "Pause", name: 'pause', ); } String get stop { return Intl.message( - 'Stop', + "Stop", name: 'stop', ); } String get next { return Intl.message( - 'Next', + "Next", name: 'next', ); } String get previous { return Intl.message( - 'Previous', + "Previous", name: 'previous', ); } String get loopOff { return Intl.message( - 'Loop off', + "Loop off", name: 'loopOff', ); } String get loopOn { return Intl.message( - 'Loop on', + "Loop on", name: 'loopOn', ); } - //*------------------------------------ - //* Quick actions + //* Quick actions ****************** String get search { return Intl.message( - 'Search', + "Search", name: 'search', ); } String get shuffleAll { return Intl.message( - 'Shuffle all', + "Shuffle all", name: 'shuffleAll', ); } String get playRecent { return Intl.message( - 'Play recent', + "Play recent", name: 'playRecent', ); } - //*------------------------------------ + + //* Content ****************** /// Label for unknown artist. String get artistUnknown { return Intl.message( - 'Unknown artist', + "Unknown artist", name: 'artistUnknown', ); } @@ -155,29 +158,63 @@ class AppLocalizations { return albumsPlural(1); } + String get playlist { + return playlistsPlural(1); + } + + String get artist { + return artistsPlural(1); + } + String get tracks { return Intl.message( - 'Tracks', + "Tracks", name: 'tracks', ); } String get albums { return Intl.message( - 'Albums', + "Albums", name: 'albums', ); } + String get playlists { + return Intl.message( + "Playlists", + name: 'playlists', + ); + } + + String get artists { + return Intl.message( + "Artists", + name: 'artists', + ); + } + + /// Picks a string of a [Content] in plural form. + /// For example "tracks". + String contents([Type contentType]) { + return contentPick>( + contentType: contentType, + song: () => tracks, + album: () => albums, + playlist: () => playlists, + artist: () => artists, + )(); + } + String tracksPlural(int count) { return Intl.plural( count, - zero: 'Tracks', - one: 'Track', - two: 'Tracks', - few: 'Tracks', - many: 'Tracks', - other: 'Tracks', + zero: "Tracks", + one: "Track", + two: "Tracks", + few: "Tracks", + many: "Tracks", + other: "Tracks", args: [count], name: 'tracksPlural', ); @@ -186,17 +223,75 @@ class AppLocalizations { String albumsPlural(int count) { return Intl.plural( count, - zero: 'Albums', - one: 'Album', - two: 'Albums', - few: 'Albums', - many: 'Albums', - other: 'Albums', + zero: "Albums", + one: "Album", + two: "Albums", + few: "Albums", + many: "Albums", + other: "Albums", args: [count], name: 'albumsPlural', ); } + String playlistsPlural(int count) { + return Intl.plural( + count, + zero: "Playlists", + one: "Playlist", + two: "Playlists", + few: "Playlists", + many: "Playlists", + other: "Playlists", + args: [count], + name: 'playlistsPlural', + ); + } + + String artistsPlural(int count) { + return Intl.plural( + count, + zero: "Artists", + one: "Artist", + two: "Artists", + few: "Artists", + many: "Artists", + other: "Artists", + args: [count], + name: 'artistsPlural', + ); + } + + /// Returns string in form "5 songs". + String contentsPluralWithCount(int count, [Type contentType]) { + return '$count ${contentsPlural(count, contentType).toLowerCase()}'; + } + + /// Calls a `plural` getter from Intl for a [Content]. + String contentsPlural(int count, [Type contentType]) { + return contentPick>( + contentType: contentType, + song: () => tracksPlural(count), + album: () => albumsPlural(count), + playlist: () => playlistsPlural(count), + artist: () => artistsPlural(count), + )(); + } + + String get albumNotFound { + return Intl.message( + "Album not found", + name: 'albumNotFound', + ); + } + + String get artistNotFound { + return Intl.message( + "Artist not found", + name: 'artistNotFound', + ); + } + String get allTracks { return Intl.message( "All tracks", @@ -204,36 +299,32 @@ class AppLocalizations { ); } - String get playlist { + String get allAlbums { return Intl.message( - "Playlist", - name: 'playlist', + "All albums", + name: 'allAlbums', ); } - String get arbitraryQueue { + String get allPlaylists { return Intl.message( - 'Arbitrary queue', - name: 'arbitraryQueue', + "All playlists", + name: 'allPlaylists', ); } - String get allAlbums { + String get allArtists { return Intl.message( - 'All albums', - name: 'allAlbums', + "All artitst", + name: 'allArtists', ); } - /// Converts [ArbitraryQueueOrigin] to human readable text. - /// Returns `null` from `null` argument. - String arbitraryQueueOrigin(ArbitraryQueueOrigin origin) { - if (origin == null) - return null; - switch (origin) { - case ArbitraryQueueOrigin.allAlbums: return allAlbums; - default: throw UnimplementedError(); - } + String get arbitraryQueue { + return Intl.message( + "Arbitrary queue", + name: 'arbitraryQueue', + ); } // NOTE: currently unused @@ -259,6 +350,46 @@ class AppLocalizations { ); } + /// Should be in plural form + String get selectedPlural { + return Intl.message( + "Selected", + name: 'selectedPlural', + ); + } + + //* Playlists ********** + + String get newPlaylist { + return Intl.message( + "New playlist", + name: 'newPlaylist', + ); + } + + String get trackAfterWhichToInsert { + return Intl.message( + "Track after which to insert", + name: 'trackAfterWhichToInsert', + ); + } + + String get insertAtTheBeginning { + return Intl.message( + "Insert at the beginning", + name: 'insertAtTheBeginning', + ); + } + + String get saveQueueAsPlaylist { + return Intl.message( + "Save queue as playlist", + name: 'saveQueueAsPlaylist', + ); + } + + //* Generic ****************** + /// Displayed in list headers in button to play it. String get playContentList { return Intl.message( @@ -284,149 +415,189 @@ class AppLocalizations { String get areYouSure { return Intl.message( - 'Are you sure?', + "Are you sure?", name: 'areYouSure', ); } + String get areYouSureYouWantTo { + return Intl.message( + "Are you sure you want to", + name: 'areYouSureYouWantTo', + ); + } + String get reset { return Intl.message( - 'Reset', + "Reset", name: 'reset', ); } String get save { return Intl.message( - 'Save', + "Save", name: 'save', ); } - // todo: currently unused + String get saved { + return Intl.message( + "Saved", + name: 'saved', + ); + } + + String get view { + return Intl.message( + "View", + name: 'view', + ); + } + + // TODO: currently unused String get secondsShorthand { return Intl.message( - 's', + "s", name: 'secondsShorthand', ); } - // todo: currently unused + // TODO: currently unused String get minutesShorthand { return Intl.message( - 'min', + "min", name: 'minutesShorthand', ); } - String get remove { + // TODO: currently unused + String get and { return Intl.message( - 'Remove', - name: 'remove', + "And", + name: 'and', ); } - String get delete { + // TODO: currently unused + String get more { return Intl.message( - 'Delete', - name: 'delete', + "More", + name: 'more', ); } - String get deletion { + /// "And 3 more" + String andNMore(int count) { return Intl.message( - 'Deletion', - name: 'deletion', + "And $count more", + args: [count], + name: 'andNMore', ); } - String get deletionError { + String get done { return Intl.message( - 'Deletion error', - name: 'deletionError', + "Done", + name: 'done', ); } - String get edit { + String get create { return Intl.message( - 'Edit', - name: 'edit', + "Create", + name: 'create', ); } - String get refresh { + String get add { return Intl.message( - 'Refresh', - name: 'refresh', + "Add", + name: 'add', ); } - String get grant { + String get remove { return Intl.message( - 'Grant', - name: 'grant', + "Remove", + name: 'remove', ); } - String get errorMessage { + String get delete { return Intl.message( - 'Oops! An error occurred', - name: 'errorMessage', + "Delete", + name: 'delete', ); } - String get errorDetails { + String get deletion { return Intl.message( - 'Error details', - name: 'errorDetails', + "Deletion", + name: 'deletion', ); } - String get playbackErrorMessage { + String get edit { return Intl.message( - 'An error occurred during the playback, removing the track', - name: 'playbackErrorMessage', + "Edit", + name: 'edit', + ); + } + + String get refresh { + return Intl.message( + "Refresh", + name: 'refresh', + ); + } + + String get grant { + return Intl.message( + "Grant", + name: 'grant', + ); + } + + String get nothingHere { + return Intl.message( + "There's nothing here", + name: 'nothingHere', ); } String get details { return Intl.message( - 'Details', + "Details", name: 'details', ); } String get songInformation { return Intl.message( - 'Track information', + "Track information", name: 'songInformation', ); } + // TODO: currently unused String get editMetadata { return Intl.message( - 'Edit metadata', + "Edit metadata", name: 'editMetadata', ); } - String get unknownRoute { - return Intl.message( - 'Unknown route!', - name: 'unknownRoute', - ); - } - String get found { return Intl.message( - 'Found', + "Found", name: 'found', ); } String get upNext { return Intl.message( - 'Up next', + "Up next", name: 'upNext', ); } @@ -473,13 +644,14 @@ class AppLocalizations { ); } - String get openAppSettingsError { + String get selected { return Intl.message( - "Error opening the app settings", - name: 'openAppSettingsError', + "Selected", + name: 'selected', ); } + // TODO: currently unused String get actions { return Intl.message( "Actions", @@ -494,6 +666,13 @@ class AppLocalizations { ); } + String get goToArtist { + return Intl.message( + "Go to artist", + name: 'goToArtist', + ); + } + String get playNext { return Intl.message( "Play next", @@ -501,6 +680,28 @@ class AppLocalizations { ); } + String get addToPlaylist { + return Intl.message( + "Add to playlist", + name: 'addToPlaylist', + ); + } + + String get removeFromPlaylist { + return Intl.message( + "Remove from playlist", + name: 'removeFromPlaylist', + ); + } + + // TODO: currently unused + String get addToFavorites { + return Intl.message( + "Add to favorites", + name: 'addToFavorites', + ); + } + String get addToQueue { return Intl.message( "Add to queue", @@ -508,67 +709,89 @@ class AppLocalizations { ); } - String get sort { + String get removeFromQueue { return Intl.message( - 'Sort', - name: 'sort', + "Remove from queue", + name: 'removeFromQueue', ); } - String get artist { + // TODO: currently unused + String get share { return Intl.message( - 'Artist', - name: 'artist', + "Share", + name: 'share', ); } + String get selectAll { + return Intl.message( + "Select all", + name: 'selectAll', + ); + } + + //* Sort ***************** + + String get sort { + return Intl.message( + "Sort", + name: 'sort', + ); + } + String get title { return Intl.message( - 'Title', + "Title", name: 'title', ); } + String get name { + return Intl.message( + "Name", + name: 'name', + ); + } + String get dateModified { return Intl.message( - 'Date modified', + "Date modified", name: 'dateModified', ); } String get dateAdded { return Intl.message( - 'Date added', + "Date added", name: 'dateAdded', ); } String get year { return Intl.message( - 'Year', + "Year", name: 'year', ); } String get numberOfTracks { return Intl.message( - 'Number of tracks', + "Number of tracks", name: 'numberOfTracks', ); } - /// Picks a string of a [Content] in plural form. - /// For example "tracks". - String contents([Type contentType]) { - return contentPick( - contentType: contentType, - song: () => tracks, - album: () => albums, - )(); + String get numberOfAlbums { + return Intl.message( + "Number of albums", + name: 'numberOfAlbums', + ); } - String sortFeature(SortFeature feature) { - return contentPick( + String sortFeature(SortFeature feature, [Type contentType]) { + return contentPick>( + contentType: contentType, song: () { switch (feature as SongSortFeature) { case SongSortFeature.dateModified: @@ -599,30 +822,37 @@ class AppLocalizations { throw UnimplementedError(); } }, + playlist: () { + switch (feature as PlaylistSortFeature) { + case PlaylistSortFeature.dateModified: + return dateModified; + case PlaylistSortFeature.dateAdded: + return dateAdded; + case PlaylistSortFeature.name: + return title; + default: + throw UnimplementedError(); + } + }, + artist: () { + switch (feature as ArtistSortFeature) { + case ArtistSortFeature.name: + return name; + case ArtistSortFeature.numberOfAlbums: + return numberOfAlbums; + case ArtistSortFeature.numberOfTracks: + return numberOfTracks; + default: + throw UnimplementedError(); + } + }, )(); } - //****************** Prompts ****************** - // Specific section for prompts localizations that are not concretely tied with some route - /// The description is being splitted into rich text there. - String get deletionPromptDescriptionP1 { - return Intl.message( - 'Are you sure you want to delete ', - name: 'deletionPromptDescriptionP1', - ); - } - - String get deletionPromptDescriptionP2 { - return Intl.message( - ' selected tracks?', - name: 'deletionPromptDescriptionP2', - ); - } - - //****************** Dev route ****************** + //* Dev route ****************** String get devModeGreet { return Intl.message( - 'Done! You are now a developer', + "Done! You are now a developer", name: 'devModeGreet', ); } @@ -630,7 +860,7 @@ class AppLocalizations { /// Shown when user tapped 4 times on app logo String get onThePathToDevMode { return Intl.message( - 'Something should happen now...', + "Something should happen now...", name: 'onThePathToDevMode', ); } @@ -654,95 +884,81 @@ class AppLocalizations { String get devTestToast { return Intl.message( - 'Test toast', + "Test toast", name: 'devTestToast', ); } - String get devErrorSnackbar { - return Intl.message( - 'Show error snackbar', - name: 'devErrorSnackbar', - ); - } - - String get devImportantSnackbar { - return Intl.message( - 'Show important snackbar', - name: 'devImportantSnackbar', - ); - } - String get devAnimationsSlowMo { return Intl.message( - 'Slow down animations', + "Slow down animations", name: 'devAnimationsSlowMo', ); } String get quitDevMode { return Intl.message( - 'Quit the developer mode', + "Quit the developer mode", name: 'quitDevMode', ); } String get quitDevModeDescription { return Intl.message( - 'Stop being a developer?', + "Stop being a developer?", name: 'quitDevModeDescription', ); } - //****************** Settings routes (extended is also included) ****************** + //* Settings routes ****************** String get settings { return Intl.message( - 'Settings', + "Settings", name: 'settings', ); } String get general { return Intl.message( - 'General', + "General", name: 'general', ); } String get theme { return Intl.message( - 'Theme', + "Theme", name: 'theme', ); } String get settingLightMode { return Intl.message( - 'Light mode', + "Light mode", name: 'settingLightMode', ); } - //****************** Search route ****************** + //* Search route ****************** String get searchHistory { return Intl.message( - 'Search history', + "Search history", name: 'searchHistory', ); } String get searchHistoryPlaceholder { return Intl.message( - 'Your search history will be displayed here', + "Your search history will be displayed here", name: 'searchHistoryPlaceholder', ); } String get searchNothingFound { return Intl.message( - 'Nothing found', + "Nothing found", name: 'searchNothingFound', ); } @@ -751,24 +967,67 @@ class AppLocalizations { /// The description is being splitted into rich text there. String get searchHistoryRemoveEntryDescriptionP1 { return Intl.message( - 'Are you sure you want to remove ', + "Are you sure you want to remove ", name: 'searchHistoryRemoveEntryDescriptionP1', ); } String get searchHistoryRemoveEntryDescriptionP2 { return Intl.message( - ' from your search history?', + " from your search history?", name: 'searchHistoryRemoveEntryDescriptionP2', ); } String get searchClearHistory { return Intl.message( - 'Clear search history?', + "Clear search history?", name: 'searchClearHistory', ); } + + //* Errors ****************** + String get oopsErrorOccurred { + return Intl.message( + "Oops! An error occurred", + name: 'oopsErrorOccurred', + ); + } + + String get errorDetails { + return Intl.message( + "Error details", + name: 'errorDetails', + ); + } + + String get deletionError { + return Intl.message( + "Deletion error", + name: 'deletionError', + ); + } + + String get playlistDoesNotExistError { + return Intl.message( + "Can't find the playlist, perhaps it was deleted", + name: 'playlistDoesNotExistError', + ); + } + + String get playbackError { + return Intl.message( + "An error occurred during the playback", + name: 'playbackError', + ); + } + + String get openAppSettingsError { + return Intl.message( + "Error opening the app settings", + name: 'openAppSettingsError', + ); + } } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/logic/channels/channels.dart b/lib/logic/channels/channels.dart deleted file mode 100644 index 8a70e90cb..000000000 --- a/lib/logic/channels/channels.dart +++ /dev/null @@ -1,7 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -export 'content.dart'; -export 'general.dart'; diff --git a/lib/logic/channels/content.dart b/lib/logic/channels/content.dart deleted file mode 100644 index 4959a6469..000000000 --- a/lib/logic/channels/content.dart +++ /dev/null @@ -1,97 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:sweyer/sweyer.dart'; -import 'package:uuid/uuid.dart'; - -const _uuid = Uuid(); - -/// Class to cancel the [ContentChannel.loadAlbumArt]. -class CancellationSignal { - CancellationSignal() : _id = _uuid.v4(); - final String _id; - - /// Cancel loading of an album art. - Future cancel() { - return ContentChannel._channel.invokeMethod( - 'cancelAlbumArtLoading', - {'id': _id} - ); - } -} - -abstract class ContentChannel { - static const MethodChannel _channel = MethodChannel('content_channel'); - - /// Loads album art on Android Q (SDK 29) and above. - /// Calling this on versions below with throw. - static Future loadAlbumArt({ @required String uri, @required Size size, @required CancellationSignal signal }) async { - return _channel.invokeMethod( - 'loadAlbumArt', - { - 'id': signal._id, - 'uri': uri, - 'width': size.width.toInt(), - 'height': size.height.toInt(), - }, - ); - } - - /// Tries to tell system to recreate album art by [albumId]. - /// - /// Sometimes `MediaStore` tells that there's an albumart for some song, but the actual file - /// by some path doesn't exist. Supposedly, what happens is that Android detects reads on this - /// entry from something like `InputStream` in Java and regenerates albumthumbs if they do not exist - /// (because this is just a caches that system tends to clear out if it thinks it should), - /// but (yet again, supposedly) this is not the case when Flutter accesses this file. System cannot - /// detect it and does not recreate the thumb, so we do this instead. - /// - /// See https://stackoverflow.com/questions/18606007/android-image-files-deleted-from-com-android-providers-media-albumthumbs-on-rebo - static Future fixAlbumArt(int albumId) async { - return _channel.invokeMethod( - 'fixAlbumArt', - {'id': albumId}, - ); - } - - static Future> retrieveSongs() async { - final maps = await _channel.invokeListMethod('retrieveSongs'); - final List songs = []; - for (Map map in maps) { - map = map.cast(); - songs.add(Song.fromMap(map)); - } - return songs; - } - - static Future> retrieveAlbums() async { - final maps = await _channel.invokeListMethod('retrieveAlbums'); - final Map albums = {}; - for (Map map in maps) { - map = map.cast(); - albums[map['id'] as int] = Album.fromJson(map); - } - return albums; - } - - // static Future>> retrievePlaylists() async { - // return _channel.invokeListMethod>('retrievePlaylists'); - // } - - // static Future>> retrieveArtists() async { - // return _channel.invokeListMethod>('retrieveArtists'); - // } - - static Future deleteSongs(Set songSet) async { - return _channel.invokeMethod( - 'deleteSongs', - {'songs': songSet.map((song) => song.toMap()).toList()}, - ); - } -} diff --git a/lib/logic/channels/general.dart b/lib/logic/channels/general.dart deleted file mode 100644 index 682c249ca..000000000 --- a/lib/logic/channels/general.dart +++ /dev/null @@ -1,15 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'package:flutter/services.dart'; - -abstract class GeneralChannel { - static const MethodChannel _channel = MethodChannel('general_channel'); - - /// Checks if open intent is view (user tried to open file with app) - static Future isIntentActionView() async { - return _channel.invokeMethod('isIntentActionView'); - } -} diff --git a/lib/logic/exceptions.dart b/lib/logic/exceptions.dart deleted file mode 100644 index 2bf77a8bc..000000000 --- a/lib/logic/exceptions.dart +++ /dev/null @@ -1,10 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -/// Indicates that reached code path that is invalid. -/// -/// In difference with [UnimplementedError], this exception used to mark that -/// given code path should never be reached. -class InvalidCodePathError extends Error {} \ No newline at end of file diff --git a/lib/logic/logic.dart b/lib/logic/logic.dart index d1c04cdc0..2cc74b04e 100644 --- a/lib/logic/logic.dart +++ b/lib/logic/logic.dart @@ -3,15 +3,12 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/// This module contains all pure app logic classes -/// -/// API is not exported as you should use `as API` +/// This module contains all app logic classes. -export 'channels/channels.dart'; export 'models/models.dart'; export 'player/music_player.dart'; -export 'exceptions.dart'; +export 'palette.dart'; export 'permissions.dart'; export 'prefs.dart'; export 'theme.dart'; \ No newline at end of file diff --git a/lib/logic/models/album.dart b/lib/logic/models/album.dart index 0a87612bd..806246f52 100644 --- a/lib/logic/models/album.dart +++ b/lib/logic/models/album.dart @@ -8,80 +8,115 @@ import 'package:flutter/material.dart'; import 'package:sweyer/sweyer.dart'; class Album extends PersistentQueue { - /// Album name. final String album; final String albumArt; final String artist; final int artistId; final int firstYear; - final int lastYear; + final int? lastYear; final int numberOfSongs; - /// Gets album normalized year. - int get year { - return lastYear == null || lastYear < 1000 - ? DateTime.now().year - : lastYear; - } + /// An icon for this content type. + static const icon = Icons.album_rounded; + + @override + String get title => album; /// Returns songs that belong to this album. @override - List get songs => - ContentControl.state.allSongs.songs.fold>([], (prev, el) { + List get songs { + return ContentControl.state.allSongs.songs + .fold>([], (prev, el) { if (el.albumId == id) { - prev.add(el.copyWith()); + prev.add(el.copyWith(origin: this)); } return prev; - }).toList(); + }) + .toList(); + } @override int get length => numberOfSongs; - /// Returns content URI of the first item in the album. - String get contentUri { - final song = ContentControl.state.allSongs.songs.firstWhere((el) => el.albumId == id); - return song.contentUri; + @override + bool get playable => true; + + /// Gets album normalized year. + int get year { + return lastYear == null || lastYear! < 1000 + ? DateTime.now().year + : lastYear!; + } + + Song get firstSong { + return ContentControl.state.allSongs.songs.firstWhere((el) => el.albumId == id); + } + + /// Returns string in format `album name • year`. + String get nameDotYear { + return ContentUtils.appendYearWithDot(album, year); + } + + /// Returns string in format `Album • year`. + String albumDotName(AppLocalizations l10n) { + return ContentUtils.appendYearWithDot(l10n.album, year); } - Album({ - @required int id, - @required this.album, - @required this.albumArt, - @required this.artist, - @required this.artistId, - @required this.firstYear, - @required this.lastYear, - @required this.numberOfSongs, + /// Returns the album artist. + Artist getArtist() => ContentControl.state.artists.firstWhere((el) => el.id == artistId); + + const Album({ + required int id, + required this.album, + required this.albumArt, + required this.artist, + required this.artistId, + required this.firstYear, + required this.lastYear, + required this.numberOfSongs, }) : super(id: id); + @override + AlbumCopyWith get copyWith => _AlbumCopyWith(this); + + @override MediaItem toMediaItem() { return MediaItem( id: id.toString(), album: null, defaultArtBlendColor: ThemeControl.colorForBlend.value, - // artUri: albumArt == null ? null : Uri(scheme:'', path: albumArt), artUri: null, title: album, - artist: formatArtist(artist, staticl10n), - genre: null, // TODO: GENRE + artist: ContentUtils.localizedArtist(artist, staticl10n), + genre: null, rating: null, extras: null, playable: false, ); } - factory Album.fromJson(Map json) { + @override + SongOriginEntry toSongOriginEntry() { + return SongOriginEntry( + type: SongOriginType.album, + id: id, + ); + } + + factory Album.fromMap(Map map) { return Album( - id: json['id'] as int, - album: json['album'] as String, - albumArt: json['albumArt'] as String, - artist: json['artist'] as String, - artistId: json['artistId'] as int, - firstYear: json['firstYear'] as int, - lastYear: json['lastYear'] as int, - numberOfSongs: json['numberOfSongs'] as int, + id: map['id'] as int, + album: map['album'] as String, + albumArt: map['albumArt'] as String, + artist: map['artist'] as String, + artistId: map['artistId'] as int, + firstYear: map['firstYear'] as int, + lastYear: map['lastYear'] as int, + numberOfSongs: map['numberOfSongs'] as int, ); } + + @override Map toMap() => { 'id': id, 'album': album, @@ -93,3 +128,51 @@ class Album extends PersistentQueue { 'numberOfSongs': numberOfSongs, }; } + +/// The `copyWith` function type for [Album]. +abstract class AlbumCopyWith { + Album call({ + int id, + String album, + String albumArt, + String artist, + int artistId, + int firstYear, + int? lastYear, + int numberOfSongs, + }); +} + +/// The implementation of [Album]'s `copyWith` function allowing +/// parameters to be explicitly set to null. +class _AlbumCopyWith extends AlbumCopyWith { + static const _undefined = Object(); + + /// The object this function applies to. + final Album value; + + _AlbumCopyWith(this.value); + + @override + Album call({ + Object id = _undefined, + Object album = _undefined, + Object albumArt = _undefined, + Object artist = _undefined, + Object artistId = _undefined, + Object firstYear = _undefined, + Object? lastYear = _undefined, + Object numberOfSongs = _undefined, + }) { + return Album( + id: id == _undefined ? value.id : id as int, + album: album == _undefined ? value.album : album as String, + albumArt: albumArt == _undefined ? value.albumArt : albumArt as String, + artist: artist == _undefined ? value.artist : artist as String, + artistId: artistId == _undefined ? value.artistId : artistId as int, + firstYear: firstYear == _undefined ? value.firstYear : firstYear as int, + lastYear: lastYear == _undefined ? value.lastYear : lastYear as int?, + numberOfSongs: numberOfSongs == _undefined ? value.numberOfSongs : numberOfSongs as int, + ); + } +} diff --git a/lib/logic/models/artist.dart b/lib/logic/models/artist.dart index f4dfbdcb5..ceb0a51e2 100644 --- a/lib/logic/models/artist.dart +++ b/lib/logic/models/artist.dart @@ -3,9 +3,136 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// import 'package:flutter/material.dart'; -// import 'package:sweyer/sweyer.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:sweyer/sweyer.dart'; -class Artist { // todo: implements Content - Artist(); +class Artist extends SongOrigin { + @override + final int id; + final String artist; + final int numberOfAlbums; + final int numberOfTracks; + + /// An icon for this content type. + static const icon = Icons.person_rounded; + + @override + List get props => [id]; + + @override + String get title => artist; + + /// Returns songs for this artst. + @override + List get songs { + return ContentControl.state.allSongs.songs.fold>([], (prev, el) { + if (el.artistId == id) { + prev.add(el.copyWith(origin: this)); + } + return prev; + }).toList(); + } + + @override + int get length => numberOfTracks; + + /// Returns albums for this artst. + List get albums { + return ContentControl.state.albums.values + .where((el) => el.artistId == id) + .toList(); + } + + /// Whether this artist represents an unknown artist. + bool get isUnknown => artist == ContentUtils.unknownArtist; + + Future fetchInfo() => Backend.getArtistInfo(artist); + + const Artist({ + required this.id, + required this.artist, + required this.numberOfAlbums, + required this.numberOfTracks, + }); + + @override + ArtistCopyWith get copyWith => _ArtistCopyWith(this); + + @override + MediaItem toMediaItem() { + return MediaItem( + id: id.toString(), + album: null, + defaultArtBlendColor: ThemeControl.colorForBlend.value, + artUri: null, + title: title, + artist: null, + genre: null, + rating: null, + extras: null, + playable: false, + ); + } + + @override + SongOriginEntry toSongOriginEntry() { + return SongOriginEntry( + type: SongOriginType.artist, + id: id, + ); + } + + factory Artist.fromMap(Map map) { + return Artist( + id: map['id'] as int, + artist: map['artist'] as String, + numberOfAlbums: map['numberOfAlbums'] as int, + numberOfTracks: map['numberOfTracks'] as int, + ); + } + + @override + Map toMap() => { + 'id': id, + 'artist': artist, + 'numberOfAlbums': numberOfAlbums, + 'numberOfTracks': numberOfTracks, + }; +} + +/// The `copyWith` function type for [Artist]. +abstract class ArtistCopyWith { + Artist call({ + int id, + String artist, + int numberOfAlbums, + int numberOfTracks, + }); +} + +/// The implementation of [Artist]'s `copyWith` function allowing +/// parameters to be explicitly set to null. +class _ArtistCopyWith extends ArtistCopyWith { + static const _undefined = Object(); + + /// The object this function applies to. + final Artist value; + + _ArtistCopyWith(this.value); + + @override + Artist call({ + Object id = _undefined, + Object artist = _undefined, + Object numberOfAlbums = _undefined, + Object numberOfTracks = _undefined, + }) { + return Artist( + id: id == _undefined ? value.id : id as int, + artist: artist == _undefined ? value.artist : artist as String, + numberOfAlbums: numberOfAlbums == _undefined ? value.numberOfAlbums : numberOfAlbums as int, + numberOfTracks: numberOfTracks == _undefined ? value.numberOfTracks : numberOfTracks as int, + ); + } } diff --git a/lib/logic/models/content.dart b/lib/logic/models/content.dart index de8142b17..8bb07a5c0 100644 --- a/lib/logic/models/content.dart +++ b/lib/logic/models/content.dart @@ -3,7 +3,11 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/cupertino.dart'; import 'package:sweyer/sweyer.dart'; +import 'package:equatable/equatable.dart'; +import 'package:collection/collection.dart'; /// Represents some content in the app (songs, album, etc). /// @@ -11,10 +15,206 @@ import 'package:sweyer/sweyer.dart'; /// /// See also: /// * [ContentType], a list of all content types. -abstract class Content { +abstract class Content with EquatableMixin { + const Content(); + /// A unique ID of the content. int get id; + /// Title the content. + String get title; + + /// Creates a copy of this content with the given fields replaced with new values. + dynamic get copyWith; + + /// Converts the content to media item. + MediaItem toMediaItem(); + + /// Converts the content to map. + Map toMap(); + /// Enumerates all the types of content (derived from this class). - static List enumerate() => [Song, Album]; + static List enumerate() => [Song, Album, Playlist, Artist]; +} + +/// Content that can contain other songs inside it. +/// +/// This class represents not duplicating, a.k.a. `true source` song origins. +/// For origins to allow duplication, see a protocol in [DuplicatingSongOriginMixin]. +/// +/// The [songs] getter must set the [Song.origin]s. +/// +/// Examples: +/// * [Album] +/// * [Artist] +abstract class SongOrigin extends Content { + const SongOrigin(); + + /// List of songs. + List get songs; + + /// Length of the queue. + int get length; + + /// Used to serialize the origin. + SongOriginEntry toSongOriginEntry(); + + /// Creates origin from map. + static SongOrigin? originFromEntry(SongOriginEntry? entry) { + if (entry == null) + return null; + switch (entry.type) { + case SongOriginType.album: + return ContentControl.getContentById(entry.id); + case SongOriginType.playlist: + return ContentControl.getContentById(entry.id); + case SongOriginType.artist: + return ContentControl.getContentById(entry.id); + default: + throw UnimplementedError(); + } + } +} + +/// Song origin that allows duplication within the [songs]. +/// +/// Classes that are mixed in with this should in [songs] getter: +/// * set the [Song.origin] +/// * set a [Song.duplicationIndex] +/// * create, fill and set a [Song.idMap] +/// * call [debugAssertSongsAreValid] at the ennd of the getter, to check +/// that everything is set correctly. +/// +/// Examples: +/// * [Playlist] +mixin DuplicatingSongOriginMixin on SongOrigin { + /// Must be created and filled automatically each time the [songs] is called. + IdMap? get idMap; + + /// Ensures that [idMap] is initialized and receieved [Song.idMap] + /// and the [Song.origin]. + bool debugAssertSongsAreValid(List songs) { + // Check that new valid idMap is created. + // ignore: unused_local_variable + final value = idMap; + + for (final song in songs) { + if (song.origin != this || song.idMap != idMap) + return false; + } + return true; + } +} + +/// Model used to serialize song origin type. +class SongOriginType { + const SongOriginType._(this._value); + final String _value; + + static const album = SongOriginType._('album'); + static const playlist = SongOriginType._('playlist'); + static const artist = SongOriginType._('artist'); + + static List get values => const [ + album, playlist, artist, + ]; +} + +/// Model used to serialize song origin. +@immutable +class SongOriginEntry { + const SongOriginEntry({ + required this.type, + required this.id, + }); + + final SongOriginType type; + final int id; + + /// Will return null if map is not valid. + static SongOriginEntry? fromMap(Map map) { + final rawType = map['origin_type']; + if (rawType == null) + return null; + final id = map['origin_id']; + assert(id != null); + return SongOriginEntry( + type: SongOriginType.values.firstWhere((el) => el._value == rawType), + id: id, + ); + } + + Map toMap() => { + 'origin_type': type._value, + 'origin_id': id, + }; + + @override + int get hashCode => hashValues(type, id); + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is SongOriginEntry && + other.type == type && + other.id == id; + } +} + +/// See [ContentUtils.deduplicateSong]. +typedef IdMap = Map; + +/// The key used in [IdMap]s. +@immutable +class IdMapKey { + const IdMapKey({ + required this.id, + required this.originEntry, + }) : assert(id < 0); + + /// The id of song, this map key is associated with. + /// Must be negative per the ID map rules. + final int id; + + /// The origin entry of song, this map key is associated with. + final SongOriginEntry? originEntry; + + /// Will return null if map is not valid. + static IdMapKey? fromMap(Map map) { + final id = map['id']; + if (id == null) + return null; + SongOriginEntry? originEntry; + if (map.length > 1) { + final rawOriginEntry = map['origin']; + if (rawOriginEntry != null) { + originEntry = SongOriginEntry.fromMap(rawOriginEntry); + } + } + return IdMapKey( + id: id, + originEntry: originEntry, + ); + } + + Map toMap() => { + 'id': id, + if (originEntry != null) + 'origin': originEntry!.toMap(), + }; + + @override + int get hashCode => hashValues(id, originEntry); + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is IdMapKey && + other.id == id && + other.originEntry == originEntry; + } } diff --git a/lib/logic/models/genre.dart b/lib/logic/models/genre.dart new file mode 100644 index 000000000..71e985e64 --- /dev/null +++ b/lib/logic/models/genre.dart @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:audio_service/audio_service.dart'; +import 'package:sweyer/sweyer.dart'; +class Genre extends Content { + @override + final int id; + final String name; + final List songIds; + + @override + String get title => name; + + @override + List get props => [id]; + + const Genre({ + required this.id, + required this.name, + required this.songIds, + }); + + @override + GenreCopyWith get copyWith => _GenreCopyWith(this); + + factory Genre.fromMap(Map map) { + return Genre( + id: map['id'] as int, + name: map['name'] as String, + songIds: map['songIds'].cast(), + ); + } + + @override + Map toMap() => { + 'id': id, + 'name': name, + 'songIds': songIds, + }; + + @override + MediaItem toMediaItem() { + throw UnimplementedError(); + } +} + +/// The `copyWith` function type for [Genre]. +abstract class GenreCopyWith { + Genre call({ + int id, + String name, + List songIds, + }); +} + +/// The implementation of [Genre]'s `copyWith` function allowing +/// parameters to be explicitly set to null. +class _GenreCopyWith extends GenreCopyWith { + static const _undefined = Object(); + + /// The object this function applies to. + final Genre value; + + _GenreCopyWith(this.value); + + @override + Genre call({ + Object id = _undefined, + Object name = _undefined, + Object songIds = _undefined, + }) { + return Genre( + id: id == _undefined ? value.id : id as int, + name: name == _undefined ? value.name : name as String, + songIds: songIds == _undefined ? value.songIds : songIds as List, + ); + } +} diff --git a/lib/logic/models/models.dart b/lib/logic/models/models.dart index 0a2e2f76d..129dc9fee 100644 --- a/lib/logic/models/models.dart +++ b/lib/logic/models/models.dart @@ -3,12 +3,12 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export 'sql/sql.dart'; export 'album.dart'; -// export 'artist.dart'; +export 'artist.dart'; export 'content.dart'; -// export 'playlist.dart'; +export 'genre.dart'; export 'persistent_queue.dart'; +export 'playlist.dart'; export 'selection_entry.dart'; export 'song.dart'; export 'sort.dart'; diff --git a/lib/logic/models/persistent_queue.dart b/lib/logic/models/persistent_queue.dart index 03c2ba9e9..069aa4280 100644 --- a/lib/logic/models/persistent_queue.dart +++ b/lib/logic/models/persistent_queue.dart @@ -3,9 +3,6 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @dart = 2.12 - -import 'package:equatable/equatable.dart'; import 'package:sweyer/sweyer.dart'; /// Represents some persistent queue on user device that has @@ -19,18 +16,15 @@ import 'package:sweyer/sweyer.dart'; /// /// See also: /// * [QueueType] which is a type of currently playing queue. -abstract class PersistentQueue extends Content with EquatableMixin { - PersistentQueue({ required this.id }); +abstract class PersistentQueue extends SongOrigin { + const PersistentQueue({ required this.id }); /// A unique ID of this queue. @override final int id; - /// List of songs. - List get songs; - - /// Length of the queue. - int get length; + /// Whether the queue can be played. + bool get playable; @override List get props => [id]; diff --git a/lib/logic/models/playlist.dart b/lib/logic/models/playlist.dart index 2a5da4259..1438bc760 100644 --- a/lib/logic/models/playlist.dart +++ b/lib/logic/models/playlist.dart @@ -3,9 +3,176 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// import 'package:flutter/material.dart';\ -// import 'package:sweyer/sweyer.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:sweyer/sweyer.dart'; -class Playlist { // todo: implements Content - Playlist(); +class Playlist extends PersistentQueue with DuplicatingSongOriginMixin { + final String data; + final int dateAdded; + final int dateModified; + final String name; + final List songIds; + + /// An icon for this content type. + static const icon = Icons.queue_music_rounded; + + @override + String get title => name; + + @override + int get length => songIds.length; + + @override + bool get playable => songIds.isNotEmpty; + + @override + IdMap get idMap => _idMap; + late IdMap _idMap; + + /// For each array of songs a new instance of [idMap] will be created + /// and assonged to each [Song.idMap]. + /// + /// The origin will also be set for interaction with queue instertions + /// at functions like [ContentControl.playNext]. + @override + List get songs { + _idMap = {}; + // Key - song id + // Value - duplication index + final _duplicationIndexMap = {}; + final List found = []; + final List notFoundIndices = []; + for (int i = 0; i < songIds.length; i++) { + final song = ContentControl.state.allSongs.byId.get(songIds[i]); + if (song != null) { + final copiedSong = song.copyWith(); + copiedSong.origin = this; + copiedSong.idMap = idMap; + final id = copiedSong.id; + final duplicationIndex = _duplicationIndexMap[id] ??= 0; + copiedSong.duplicationIndex = duplicationIndex; + ContentUtils.deduplicateSong( + song: copiedSong, + index: duplicationIndex, + list: found, + idMap: idMap, + ); + _duplicationIndexMap[id] = _duplicationIndexMap[id]! + 1; + found.add(copiedSong); + } else { + notFoundIndices.add(songIds[i]); + } + } + for (int i = notFoundIndices.length - 1; i >= 0; i--) { + songIds.remove(i); + } + assert(debugAssertSongsAreValid(found)); + return found; + } + + /// Returns content URI of the first item in the album. + String get firstSong { + final song = ContentControl.state.allSongs.songs.firstWhere((el) => el.albumId == id); + return song.contentUri; + } + + Playlist({ + required int id, + required this.data, + required this.dateAdded, + required this.dateModified, + required this.name, + required this.songIds, + }) : super(id: id); + + @override + PlaylistCopyWith get copyWith => _PlaylistCopyWith(this); + + @override + MediaItem toMediaItem() { + return MediaItem( + id: id.toString(), + album: null, + defaultArtBlendColor: ThemeControl.colorForBlend.value, + artUri: null, + title: title, + artist: null, + genre: null, + rating: null, + extras: null, + playable: false, + ); + } + + @override + SongOriginEntry toSongOriginEntry() { + return SongOriginEntry( + type: SongOriginType.playlist, + id: id, + ); + } + + factory Playlist.fromMap(Map map) { + return Playlist( + id: map['id'] as int, + data: map['data'] as String, + dateAdded: map['dateAdded'] as int, + dateModified: map['dateModified'] as int, + name: map['name'] as String, + songIds: (map['songIds'] as List).cast().toList(), + ); + } + + @override + Map toMap() => { + 'id': id, + 'data': data, + 'dateAdded': dateAdded, + 'dateModified': dateModified, + 'name': name, + 'songIds': songIds, + }; +} + +/// The `copyWith` function type for [Playlist]. +abstract class PlaylistCopyWith { + Playlist call({ + int id, + String data, + int dateAdded, + int dateModified, + String name, + List songIds, + }); +} + +/// The implementation of [Playlist]'s `copyWith` function allowing +/// parameters to be explicitly set to null. +class _PlaylistCopyWith extends PlaylistCopyWith { + static const _undefined = Object(); + + /// The object this function applies to. + final Playlist value; + + _PlaylistCopyWith(this.value); + + @override + Playlist call({ + Object id = _undefined, + Object data = _undefined, + Object dateAdded = _undefined, + Object dateModified = _undefined, + Object name = _undefined, + Object songIds = _undefined, + }) { + return Playlist( + id: id == _undefined ? value.id : id as int, + data: data == _undefined ? value.data : data as String, + dateAdded: dateAdded == _undefined ? value.dateAdded : dateAdded as int, + dateModified: dateModified == _undefined ? value.dateModified : dateModified as int, + name: name == _undefined ? value.name : name as String, + songIds: songIds == _undefined ? value.songIds : songIds as List, + ); + } } diff --git a/lib/logic/models/selection_entry.dart b/lib/logic/models/selection_entry.dart index 03c62fe47..43959bd5c 100644 --- a/lib/logic/models/selection_entry.dart +++ b/lib/logic/models/selection_entry.dart @@ -3,24 +3,99 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import 'package:equatable/equatable.dart'; -import 'package:meta/meta.dart'; +import 'package:flutter/cupertino.dart'; import 'package:sweyer/sweyer.dart'; /// Used for selection of [Content]. -class SelectionEntry extends Equatable { +@immutable +class SelectionEntry { const SelectionEntry({ - @required this.index, - @required this.data, + required this.data, + required this.index, + required this.origin, }); - /// Used for comparison and for sorting when content is being - /// inserted into queue. - final int index; + /// Creates a selection entry from a content. + /// + /// Default selection entry factory used throughout the app. + factory SelectionEntry.fromContent({ + required T content, + required int index, + required BuildContext context, + }) { + return contentPick>>( + contentType: content.runtimeType, + song: () { + final song = content as Song; + return SelectionEntry( + data: content, + index: selectionRouteOf(context) + ? ContentControl.state.allSongs.getIndex(song) + : index, + origin: selectionRouteOf(context) && song.origin is DuplicatingSongOriginMixin + ? song.origin + : null, + ) as SelectionEntry; + }, + album: () => SelectionEntry( + index: index, + data: content as Album, + origin: null, + ) as SelectionEntry, + playlist: () => SelectionEntry( + index: index, + data: content as Playlist, + origin: null, + ) as SelectionEntry, + artist: () => SelectionEntry( + index: index, + data: content as Artist, + origin: null, + ) as SelectionEntry, + )(); + } /// The content data. final T data; + /// Used for comparison and for sorting. When content is being + /// inserted into queue, it must be sorted by this prior. + /// + /// Usually the selection controller is created per one screen with one list. + /// In this case the index can be taken just as index of item in the list. + /// Aside from that, for this case when the controller is in selection any + /// sorting or reordering operatins must not happen, otherwise might mixup and + /// the correct order might be lost. + /// + /// But in some cases it's not enough to imply index from the list and we + /// need to have some source of truth of that index. There might be other + /// approaches to solve this, but this is the easiest one. + /// + /// See also: + /// * [SelectableState.selectionRoute] and [SongTile] state for example of custom indexing + /// * [ContentUtils.selectionSortAndPack], which is the default way of sorting + /// the content for further usage + final int index; + + /// Might be used to scope the selection to origins. + /// + /// See discussion in [SelectableState.selectionRoute] for example. + final SongOrigin? origin; + @override - List get props => [data, index]; + int get hashCode => hashValues(data, index, origin); + + @override + bool operator ==(Object other) { + // Skip the runtimeType comparison, since I that that entries only compared by + // actual values, because sometimes generics are missing + // + // if (other.runtimeType != runtimeType) + // return false; + + return other is SelectionEntry + && other.data == data + && other.index == index + && other.origin == origin; + } } diff --git a/lib/logic/models/song.dart b/lib/logic/models/song.dart index dafc6ba3b..58975c0c9 100644 --- a/lib/logic/models/song.dart +++ b/lib/logic/models/song.dart @@ -4,111 +4,139 @@ *--------------------------------------------------------------------------------------------*/ import 'package:audio_service/audio_service.dart'; -import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:sweyer/sweyer.dart'; -// ignore: must_be_immutable -class Song extends Content with EquatableMixin { +/// Represends a song. +/// +/// Songs are always playable, trashed or pending songs on Android Q are excluded. +class Song extends Content { + /// This is the main song ID used for comparisons. + /// + /// Initially, this is equal to the source song [sourceId], but if song is + /// found to be duplicated within some queue and this queue is currently + /// being processed in some way (for example, played), it might be altered + /// with a negative value. @override int id; - /// Album name. - final String album; - final int albumId; + final String? album; + final int? albumId; final String artist; final int artistId; + // TODO: vodoo shenanigans on android versions with this (and other places where i can) + final String? genre; + final int? genreId; + @override final String title; - /// The track number of this song on the album, if any. - final String track; + final String? track; final int dateAdded; final int dateModified; - /// Duration in milliseconds final int duration; final int size; - final String data; + final String? data; + + /// Indicates that user marked this song as favorite. + /// + /// In native only available starting from Android R, below this favorite logic + /// is implemented in app itself. + final bool? isFavorite; + + /// Generation number at which metadata for this media item was first inserted. + /// + /// Available starting from Android R, in lower is `null`. + final int? generationAdded; + + /// Generation number at which metadata for this media item was last changed. + /// + /// Available starting from Android R, in lower is `null`. + final int? generationModified; - /// The [PersistentQueue] this song comes from. - /// This will help determining where the song comes from to show [CurrentIndicator]s. - PersistentQueue origin; + /// The origin this song comes from. + /// + /// Particularly, this will help determining where the song comes from to show [CurrentIndicator]s. + /// + /// Set by [SongOrigin.songs] getters. + SongOrigin? origin; + + /// Index of a duplicate song within its duplicates in its queue. + /// + /// For example if there are 4 duplicates a song in the queue, + /// and the song is inserted to the end, its duplication index will be + /// last `index + 1`, i.e `3 + 1 = 4`. + /// + /// Set by [DuplicatingSongOriginMixin]s. + int? duplicationIndex; + + /// A supplementary ID map, inserted by origins that allow duplication, + /// like [Playlist]. + /// + /// Not copied with [copyWith]. + IdMap? idMap; + + /// An icon for this content type. + static const icon = Icons.music_note_rounded; @override - List get props => [id]; + List get props => [id]; - int get sourceId => getSourceId(id); - static int getSourceId(int id) { - return id < 0 ? ContentControl.state.idMap[id.toString()] : id; - } + /// Returns source song ID. + int get sourceId => ContentUtils.getSourceId( + id, + origin: origin, + idMap: idMap, + ); + + /// Returns the song artist. + Artist getArtist() => ContentControl.state.artists.firstWhere((el) => el.id == artistId); /// Returns the album this song belongs to (if any). - Album getAlbum() => albumId == null ? null : ContentControl.state.albums[albumId]; + Album? getAlbum() => albumId == null ? null : ContentControl.state.albums[albumId!]; /// Returns the album art for this (if any). - String get albumArt => getAlbum()?.albumArt; + String? get albumArt => getAlbum()?.albumArt; + /// Needed to display [ContentArtSource] and passed to [ContentArtSource]. String get contentUri => 'content://media/external/audio/media/$sourceId'; Song({ - @required this.id, - @required this.album, - @required this.albumId, - @required this.artist, - @required this.artistId, - @required this.title, - @required this.track, - @required this.dateAdded, - @required this.dateModified, - @required this.duration, - @required this.size, - @required this.data, + required this.id, + required this.album, + required this.albumId, + required this.artist, + required this.artistId, + required this.genre, + required this.genreId, + required this.title, + required this.track, + required this.dateAdded, + required this.dateModified, + required this.duration, + required this.size, + required this.data, + required this.isFavorite, + required this.generationAdded, + required this.generationModified, + this.duplicationIndex, this.origin, }); - Song copyWith({ - int id, - String album, - int albumId, - String artist, - int artistId, - String title, - String track, - int dateAdded, - int dateModified, - int duration, - int size, - String data, - PersistentQueue origin, - }) { - return Song( - id: id ?? this.id, - album: album ?? this.album, - albumId: albumId ?? this.albumId, - artist: artist ?? this.artist, - artistId: artistId ?? this.artistId, - title: title ?? this.title, - track: track ?? this.track, - dateAdded: dateAdded ?? this.dateAdded, - dateModified: dateModified ?? this.dateModified, - duration: duration ?? this.duration, - size: size ?? this.size, - data: data ?? this.data, - origin: origin ?? this.origin, - ); - } + @override + SongCopyWith get copyWith => _SongCopyWith(this); + @override MediaItem toMediaItem() { return MediaItem( id: sourceId.toString(), uri: contentUri, defaultArtBlendColor: ThemeControl.colorForBlend.value, - // artUri: albumArt == null ? null : Uri(scheme: '', path: albumArt), artUri: null, - album: getAlbum().album, + album: getAlbum()?.album, title: title, - artist: formatArtist(artist, staticl10n), - genre: null, // TODO: GENRE + artist: ContentUtils.localizedArtist(artist, staticl10n), + genre: genre, duration: Duration(milliseconds: duration), playable: true, rating: null, @@ -116,28 +144,37 @@ class Song extends Content with EquatableMixin { ); } - factory Song.fromMap(Map json) { + factory Song.fromMap(Map map) { return Song( - id: json['id'] as int, - album: json['album'] as String, - albumId: json['albumId'] as int, - artist: json['artist'] as String, - artistId: json['artistId'] as int, - title: json['title'] as String, - track: json['track'] as String, - dateAdded: json['dateAdded'] as int, - dateModified: json['dateModified'] as int, - duration: json['duration'] as int, - size: json['size'] as int, - data: json['data'] as String, + id: map['id'] as int, + album: map['album'] as String?, + albumId: map['albumId'] as int?, + artist: map['artist'] as String, + artistId: map['artistId'] as int, + genre: map['genre'] as String?, + genreId: map['genreId'] as int?, + title: map['title'] as String, + track: map['track'] as String?, + dateAdded: map['dateAdded'] as int, + dateModified: map['dateModified'] as int, + duration: map['duration'] as int, + size: map['size'] as int, + data: map['data'] as String?, + isFavorite: map['isFavorite'] as bool?, + generationAdded: map['generationAdded'] as int?, + generationModified: map['generationModified'] as int?, ); } + + @override Map toMap() => { 'id': id, 'album': album, 'albumId': albumId, 'artist': artist, 'artistId': artistId, + 'genre': genre, + 'genreId': genreId, 'title': title, 'track': track, 'dateAdded': dateAdded, @@ -145,5 +182,89 @@ class Song extends Content with EquatableMixin { 'duration': duration, 'size': size, 'data': data, + 'isFavorite': isFavorite, + 'generationAdded': generationAdded, + 'generationModified': generationModified, }; } + +/// The `copyWith` function type for [Song]. +abstract class SongCopyWith { + Song call({ + int id, + String? album, + int? albumId, + String artist, + int artistId, + String? genre, + int? genreId, + String title, + String? track, + int dateAdded, + int dateModified, + int duration, + int size, + String? data, + bool? isFavorite, + int? generationAdded, + int? generationModified, + int? duplicationIndex, + SongOrigin? origin, + }); +} + +/// The implementation of [Song]'s `copyWith` function allowing +/// parameters to be explicitly set to null. +class _SongCopyWith extends SongCopyWith { + static const _undefined = Object(); + + /// The object this function applies to. + final Song value; + + _SongCopyWith(this.value); + + @override + Song call({ + Object id = _undefined, + Object? album = _undefined, + Object? albumId = _undefined, + Object artist = _undefined, + Object artistId = _undefined, + Object? genre = _undefined, + Object? genreId = _undefined, + Object title = _undefined, + Object? track = _undefined, + Object dateAdded = _undefined, + Object dateModified = _undefined, + Object duration = _undefined, + Object size = _undefined, + Object? data = _undefined, + Object? isFavorite = _undefined, + Object? generationAdded = _undefined, + Object? generationModified = _undefined, + Object? duplicationIndex = _undefined, + Object? origin = _undefined, + }) { + return Song( + id: id == _undefined ? value.id : id as int, + album: album == _undefined ? value.album : album as String?, + albumId: albumId == _undefined ? value.albumId : albumId as int?, + artist: artist == _undefined ? value.artist : artist as String, + artistId: artistId == _undefined ? value.artistId : artistId as int, + genre: genre == _undefined ? value.genre : genre as String?, + genreId: genreId == _undefined ? value.genreId : genreId as int?, + title: title == _undefined ? value.title : title as String, + track: track == _undefined ? value.track : track as String?, + dateAdded: dateAdded == _undefined ? value.dateAdded : dateAdded as int, + dateModified: dateModified == _undefined ? value.dateModified : dateModified as int, + duration: duration == _undefined ? value.duration : duration as int, + size: size == _undefined ? value.size : size as int, + data: data == _undefined ? value.data : data as String?, + isFavorite: isFavorite == _undefined ? value.isFavorite : isFavorite as bool?, + generationAdded: generationAdded == _undefined ? value.generationAdded : generationAdded as int?, + generationModified: generationModified == _undefined ? value.generationModified : generationModified as int?, + duplicationIndex: duplicationIndex == _undefined ? value.duplicationIndex : duplicationIndex as int?, + origin: origin == _undefined ? value.origin : origin as SongOrigin?, + ); + } +} diff --git a/lib/logic/models/sort.dart b/lib/logic/models/sort.dart index 53ca5cde9..7697cc029 100644 --- a/lib/logic/models/sort.dart +++ b/lib/logic/models/sort.dart @@ -6,12 +6,24 @@ import 'package:enum_to_string/enum_to_string.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; -import 'package:sweyer/logic/logic.dart'; +import 'package:sweyer/sweyer.dart'; /// Inteface for other sort feature enums. abstract class SortFeature extends Enum { const SortFeature._(String value) : super(value); + + /// Returns sort feature values for a given content. + static List getValuesForContent([Type? contentType]) { + return contentPick>>( + contentType: contentType, + song: () => SongSortFeature.values, + album: () => AlbumSortFeature.values, + playlist: () => PlaylistSortFeature.values, + artist: () => ArtistSortFeature.values, + )(); + } + + /// Whether the default order is ASC. bool get defaultOrderAscending; } @@ -25,9 +37,9 @@ class SongSortFeature extends SortFeature { @override bool get defaultOrderAscending => this != dateModified && this != dateAdded; - static List get values { - return [dateModified, dateAdded, title, artist, album]; - } + static List get values => const [ + dateModified, dateAdded, title, artist, album + ]; /// Sort by the [Song.dateModified]. /// Default sort order is DESC. @@ -60,9 +72,9 @@ class AlbumSortFeature extends SortFeature { @override bool get defaultOrderAscending => this != year; - static List get values { - return [title, artist, year, numberOfSongs]; - } + static List get values => const [ + title, artist, year, numberOfSongs + ]; /// Sort by the [Album.album]. /// Default sort order is ASC. @@ -81,12 +93,65 @@ class AlbumSortFeature extends SortFeature { static const numberOfSongs = AlbumSortFeature._('numberOfSongs'); } +/// Features to sort by a [Playlist] list. +/// +/// Each feature also has the default sort order - ASC or DESC. +/// When user changes the [PlaylistSortFeature] the new default sort order is applied. +class PlaylistSortFeature extends SortFeature { + const PlaylistSortFeature._(String value) : super._(value); + + @override + bool get defaultOrderAscending => this != dateModified && this != dateAdded; + + static List get values => const [ + dateAdded, dateModified, name + ]; + + /// Sort by the [Playlist.dateModified]. + /// Default sort order is DESC. + static const dateModified = PlaylistSortFeature._('dateModified'); + + /// Sort by the [Playlist.dateAdded]. + /// Default sort order is DESC. + static const dateAdded = PlaylistSortFeature._('dateAdded'); + + /// Sort by the [Playlist.name]. + /// Default sort order is ASC. + static const name = PlaylistSortFeature._('name'); +} + +/// Features to sort by a [Artist] list. +/// +/// Each feature also has the default sort order - ASC or DESC. +/// When user changes the [ArtistSortFeature] the new default sort order is applied. +class ArtistSortFeature extends SortFeature { + const ArtistSortFeature._(String value) : super._(value); + + @override + bool get defaultOrderAscending => true; + + static List get values => const [ + name, numberOfAlbums, numberOfTracks + ]; + + /// Sort by the [Artist.artist]. + /// Default sort order is ASC. + static const name = ArtistSortFeature._('name'); + + /// Sort by the [Artist.numberOfAlbums]. + /// Default sort order is ASC. + static const numberOfAlbums = ArtistSortFeature._('numberOfAlbums'); + + /// Sort by the [Artist.numberOfTracks]. + /// Default sort order is ASC. + static const numberOfTracks = ArtistSortFeature._('numberOfTracks'); +} + abstract class Sort extends Equatable { const Sort({ - @required this.feature, - @required this.orderAscending, - }) : assert(feature != null), - assert(orderAscending != null); + required this.feature, + required this.orderAscending, + }); Sort.defaultOrder(this.feature) : orderAscending = feature.defaultOrderAscending; @@ -96,12 +161,12 @@ abstract class Sort extends Equatable { @override List get props => [feature, orderAscending]; - Sort copyWith({SortFeature feature, bool orderAscending}); + Sort copyWith({SortFeature? feature, bool? orderAscending}); Sort get withDefaultOrder; Comparator get comparator; - Map toJson() => { + Map toMap() => { 'feature': feature.value, 'orderAscending': orderAscending, }; @@ -109,26 +174,26 @@ abstract class Sort extends Equatable { class SongSort extends Sort { const SongSort({ - SongSortFeature feature, + required SongSortFeature feature, bool orderAscending = true, }) : super(feature: feature, orderAscending: orderAscending); SongSort.defaultOrder(feature) : super.defaultOrder(feature); - factory SongSort.fromJson(Map json) => SongSort( + factory SongSort.fromMap(Map map) => SongSort( feature: EnumToString.fromString( SongSortFeature.values, - json['feature'], - ), - orderAscending: json['orderAscending'], + map['feature'], + )!, + orderAscending: map['orderAscending'], ); @override SongSort copyWith({ - covariant SongSortFeature feature, - bool orderAscending, + covariant SongSortFeature? feature, + bool? orderAscending, }) { return SongSort( - feature: feature ?? this.feature, + feature: feature ?? this.feature as SongSortFeature, orderAscending: orderAscending ?? this.orderAscending, ); } @@ -138,14 +203,14 @@ class SongSort extends Sort { return copyWith(orderAscending: feature.defaultOrderAscending); } - int _fallbackDateModified(Song a, Song b) { - return a.dateModified.compareTo(b.dateModified); - } - int _fallbackTitle(Song a, Song b) { return a.title.toLowerCase().compareTo(b.title.toLowerCase()); } + int _fallbackDateModified(Song a, Song b) { + return a.dateModified.compareTo(b.dateModified); + } + @override Comparator get comparator { Comparator c; @@ -184,7 +249,7 @@ class SongSort extends Sort { break; case SongSortFeature.album: c = (a, b) { - final compare = a.album.toLowerCase().compareTo(b.album.toLowerCase()); + final compare = a.album!.toLowerCase().compareTo(b.album!.toLowerCase()); if (compare == 0) return _fallbackTitle(a, b); return compare; @@ -202,32 +267,32 @@ class SongSort extends Sort { class AlbumSort extends Sort { const AlbumSort({ - AlbumSortFeature feature, + required AlbumSortFeature feature, bool orderAscending = true, }) : super(feature: feature, orderAscending: orderAscending); AlbumSort.defaultOrder(feature) : super.defaultOrder(feature); - factory AlbumSort.fromJson(Map json) => AlbumSort( + factory AlbumSort.fromMap(Map map) => AlbumSort( feature: EnumToString.fromString( AlbumSortFeature.values, - json['feature'], - ), - orderAscending: json['orderAscending'], + map['feature'], + )!, + orderAscending: map['orderAscending'], ); @override - Map toJson() => { + Map toMap() => { 'feature': feature.value, 'orderAscending': orderAscending, }; @override AlbumSort copyWith({ - covariant AlbumSortFeature feature, - bool orderAscending, + covariant AlbumSortFeature? feature, + bool? orderAscending, }) { return AlbumSort( - feature: feature ?? this.feature, + feature: feature ?? this.feature as AlbumSortFeature, orderAscending: orderAscending ?? this.orderAscending, ); } @@ -290,3 +355,170 @@ class AlbumSort extends Sort { return c; } } + + +class PlaylistSort extends Sort { + const PlaylistSort({ + required PlaylistSortFeature feature, + bool orderAscending = true, + }) : super(feature: feature, orderAscending: orderAscending); + PlaylistSort.defaultOrder(feature) : super.defaultOrder(feature); + + factory PlaylistSort.fromMap(Map map) => PlaylistSort( + feature: EnumToString.fromString( + PlaylistSortFeature.values, + map['feature'], + )!, + orderAscending: map['orderAscending'], + ); + + @override + Map toMap() => { + 'feature': feature.value, + 'orderAscending': orderAscending, + }; + + @override + PlaylistSort copyWith({ + covariant PlaylistSortFeature? feature, + bool? orderAscending, + }) { + return PlaylistSort( + feature: feature ?? this.feature as PlaylistSortFeature, + orderAscending: orderAscending ?? this.orderAscending, + ); + } + + @override + PlaylistSort get withDefaultOrder { + return copyWith(orderAscending: feature.defaultOrderAscending); + } + + int _fallbackName(Playlist a, Playlist b) { + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + } + + int _fallbackDateModified(Playlist a, Playlist b) { + return a.dateModified.compareTo(b.dateModified); + } + + @override + Comparator get comparator { + Comparator c; + switch (feature) { + case PlaylistSortFeature.dateModified: + c = (a, b) { + final compare = a.dateModified.compareTo(b.dateModified); + if (compare == 0) + return _fallbackName(a, b); + return compare; + }; + break; + case PlaylistSortFeature.dateAdded: + c = (a, b) { + final compare = a.dateAdded.compareTo(b.dateAdded); + if (compare == 0) + return _fallbackName(a, b); + return compare; + }; + break; + case PlaylistSortFeature.name: + c = (a, b) { + final compare = a.name.toLowerCase().compareTo(b.name.toLowerCase()); + if (compare == 0) + return _fallbackDateModified(a, b); + return compare; + }; + break; + default: + throw UnimplementedError(); + } + if (!orderAscending) { + return (a, b) => c(b, a); + } + return c; + } +} + +class ArtistSort extends Sort { + const ArtistSort({ + required ArtistSortFeature feature, + bool orderAscending = true, + }) : super(feature: feature, orderAscending: orderAscending); + ArtistSort.defaultOrder(feature) : super.defaultOrder(feature); + + factory ArtistSort.fromMap(Map map) => ArtistSort( + feature: EnumToString.fromString( + ArtistSortFeature.values, + map['feature'], + )!, + orderAscending: map['orderAscending'], + ); + + @override + Map toMap() => { + 'feature': feature.value, + 'orderAscending': orderAscending, + }; + + @override + ArtistSort copyWith({ + covariant ArtistSortFeature? feature, + bool? orderAscending, + }) { + return ArtistSort( + feature: feature ?? this.feature as ArtistSortFeature, + orderAscending: orderAscending ?? this.orderAscending, + ); + } + + @override + ArtistSort get withDefaultOrder { + return copyWith(orderAscending: feature.defaultOrderAscending); + } + + int _fallbackName(Artist a, Artist b) { + return a.artist.toLowerCase().compareTo(b.artist.toLowerCase()); + } + + int _fallbackNumberOfTracks(Artist a, Artist b) { + return a.numberOfTracks.compareTo(b.numberOfTracks); + } + + @override + Comparator get comparator { + Comparator c; + switch (feature) { + case ArtistSortFeature.name: + c = (a, b) { + final compare = a.artist.toLowerCase().compareTo(b.artist.toLowerCase()); + if (compare == 0) + return _fallbackNumberOfTracks(a, b); + return compare; + }; + break; + case ArtistSortFeature.numberOfAlbums: + c = (a, b) { + final compare = a.numberOfAlbums.compareTo(b.numberOfAlbums); + if (compare == 0) + return _fallbackName(a, b); + return compare; + }; + break; + case ArtistSortFeature.numberOfTracks: + c = (a, b) { + final compare = a.numberOfTracks.compareTo(b.numberOfTracks); + if (compare == 0) + return _fallbackName(a, b); + return compare; + }; + break; + default: + throw UnimplementedError(); + } + if (!orderAscending) { + return (a, b) => c(b, a); + } + return c; + } +} diff --git a/lib/logic/models/sql/album.dart b/lib/logic/models/sql/album.dart deleted file mode 100644 index ad85052e5..000000000 --- a/lib/logic/models/sql/album.dart +++ /dev/null @@ -1,22 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'package:flutter/foundation.dart'; -import 'package:sweyer/sweyer.dart'; - -/// A model for the `albums` table. -/// This table is currently only used for storing the [SongsSort] for songs inside the album. -/// -/// Inside the album, we can't have [SongsSortFeature.album]. -class SqlAlbum { - SqlAlbum({ - @required this.id, - @required this.sort, - }) : assert(id != null), - assert(sort.feature != SongSortFeature.album); - - final int id; - final SongSort sort; -} diff --git a/lib/logic/models/sql/sql.dart b/lib/logic/models/sql/sql.dart deleted file mode 100644 index 4147d607a..000000000 --- a/lib/logic/models/sql/sql.dart +++ /dev/null @@ -1,6 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -export 'album.dart'; diff --git a/lib/logic/palette.dart b/lib/logic/palette.dart new file mode 100644 index 000000000..796af7588 --- /dev/null +++ b/lib/logic/palette.dart @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'dart:ui' as ui; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:palette_generator/palette_generator.dart'; + +Future createPalette(ui.Image image) => PaletteGenerator.fromImage( + image, + maximumColorCount: 50, +); + +/// A widget that draws the swatches for the [PaletteGenerator] it is given, +/// and shows the selected target colors. +class PaletteSwatches extends StatelessWidget { + /// Create a Palette swatch. + /// + /// The [generator] is optional. If it is null, then the display will + /// just be an empty container. + const PaletteSwatches({Key? key, this.generator}) : super(key: key); + + /// The [PaletteGenerator] that contains all of the swatches that we're going + /// to display. + final PaletteGenerator? generator; + + @override + Widget build(BuildContext context) { + final List swatches = []; + final PaletteGenerator? paletteGen = generator; + if (paletteGen == null || paletteGen.colors.isEmpty) { + return Container(); + } + for (final Color color in paletteGen.colors) { + swatches.add(PaletteSwatch(color: color)); + } + return Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Wrap( + children: swatches, + ), + const SizedBox(height: 30.0), + PaletteSwatch.forPalette(label: 'Dominant', color: paletteGen.dominantColor), + PaletteSwatch.forPalette(label: 'Light Vibrant', color: paletteGen.lightVibrantColor), + PaletteSwatch.forPalette(label: 'Vibrant', color: paletteGen.vibrantColor), + PaletteSwatch.forPalette(label: 'Dark Vibrant', color: paletteGen.darkVibrantColor), + PaletteSwatch.forPalette(label: 'Light Muted', color: paletteGen.lightMutedColor), + PaletteSwatch.forPalette(label: 'Muted', color: paletteGen.mutedColor), + PaletteSwatch.forPalette(label: 'Dark Muted', color: paletteGen.darkMutedColor), + ], + ); + } +} + + +const Color _kBackgroundColor = Color(0xffa0a0a0); +const Color _kSelectionRectangleBackground = Color(0x15000000); +const Color _kSelectionRectangleBorder = Color(0x80000000); +const Color _kPlaceholderColor = Color(0x80404040); + +/// A small square of color with an optional label. +@immutable +class PaletteSwatch extends StatelessWidget { + /// Creates a PaletteSwatch. + /// + /// If the [paletteColor] has property `isTargetColorFound` as `false`, + /// then the swatch will show a placeholder instead, to indicate + /// that there is no color. + const PaletteSwatch({ + Key? key, + this.color, + this.label, + }) : paletteColor = null, + super(key: key); + + const PaletteSwatch.forPalette({ + Key? key, + PaletteColor? color, + this.label, + }) : paletteColor = color, + color = null, + super(key: key); + + /// The color of the swatch. + final Color? color; + + /// The palette color of the swatch. + final PaletteColor? paletteColor; + + /// The optional label to display next to the swatch. + final String? label; + + Widget _buildSwatch(Color? color) { + // Compute the "distance" of the color swatch and the background color + // so that we can put a border around those color swatches that are too + // close to the background's saturation and lightness. We ignore hue for + // the comparison. + final HSLColor hslColor = HSLColor.fromColor(color ?? Colors.transparent); + final HSLColor backgroundAsHsl = HSLColor.fromColor(_kBackgroundColor); + final double colorDistance = math.sqrt( + math.pow(hslColor.saturation - backgroundAsHsl.saturation, 2.0) + + math.pow(hslColor.lightness - backgroundAsHsl.lightness, 2.0) + ); + + return Padding( + padding: const EdgeInsets.all(2.0), + child: color == null + ? const Placeholder( + fallbackWidth: 34.0, + fallbackHeight: 20.0, + color: Color(0xff404040), + strokeWidth: 2.0, + ) + : Container( + decoration: BoxDecoration( + color: color, + border: Border.all( + width: 1.0, + color: _kPlaceholderColor, + style: colorDistance < 0.2 + ? BorderStyle.solid + : BorderStyle.none, + ), + ), + width: 34.0, + height: 20.0, + ), + ); + } + + @override + Widget build(BuildContext context) { + final color = _buildSwatch(this.color ?? paletteColor?.color); + if (label == null) + return color; + final titleText = _buildSwatch(paletteColor?.titleTextColor); + final bodyText = _buildSwatch(paletteColor?.bodyTextColor); + return Container( + color: Colors.white, + constraints: const BoxConstraints(maxWidth: 130.0, minWidth: 130.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + titleText, + const SizedBox(width: 5.0), + bodyText, + const SizedBox(width: 5.0), + color, + const SizedBox(width: 5.0), + Text(label!), + ], + ), + ); + } +} diff --git a/lib/logic/permissions.dart b/lib/logic/permissions.dart index 78aa626be..8ff3ffa09 100644 --- a/lib/logic/permissions.dart +++ b/lib/logic/permissions.dart @@ -9,7 +9,7 @@ import 'package:permission_handler/permission_handler.dart'; abstract class Permissions { /// Whether storage permission is granted - static PermissionStatus _permissionStorageStatus; + static late PermissionStatus _permissionStorageStatus; /// Returns true if permissions were granted static bool get granted => _permissionStorageStatus == PermissionStatus.granted; diff --git a/lib/logic/player/backend.dart b/lib/logic/player/backend.dart new file mode 100644 index 000000000..e5b6ce562 --- /dev/null +++ b/lib/logic/player/backend.dart @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'dart:convert'; + +import 'package:clock/clock.dart'; +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +/// [Backend.getArtistInfo] response. +class GetArtistInfoResponse { + /// Artist image url. + final String? imageUrl; + + GetArtistInfoResponse({required this.imageUrl}); + + factory GetArtistInfoResponse.fromMap(Map map) { + return GetArtistInfoResponse(imageUrl: map['imageUrl']); + } +} + +/// A namespace for connection methods with the backend. +/// +/// Backend source code is avilable here https://github.com/nt4f04uNd/sweyer-backend. +abstract class Backend { + static const _version = 1; + static const _cacheKey = 'backend'; + + static final _cacheManager = CacheManager( + Config( + _cacheKey, + maxNrOfCacheObjects: 500, + fileService: _ArtistInfoFileService(version: _version), + ), + ); + + /// Calls to the backend to find the info about artist. + static Future getArtistInfo(String name) async { + final file = await _cacheManager.getSingleFile(name); + if (!file.existsSync()) + return GetArtistInfoResponse(imageUrl: null); + return GetArtistInfoResponse.fromMap(jsonDecode(await file.readAsString())); + } +} + +class _ArtistInfoFileService extends FileService { + _ArtistInfoFileService({required this.version}); + final int version; + + @override + Future get(String url, {Map? headers}) async { + final function = FirebaseFunctions.instance.httpsCallable('getArtistInfo'); + try { + final result = await function.call({ + 'version': version, + 'name': url, + }); + return _ArtistInfoServiceResponse(result.data); + } on FirebaseFunctionsException catch (ex) { + if (ex.code == 'unknown') { + return _ArtistInfoServiceResponse(null); + } else { + rethrow; + } + } + } +} + +class _ArtistInfoServiceResponse extends FileServiceResponse { + _ArtistInfoServiceResponse(this.data); + final Map? data; + + final DateTime _receivedTime = clock.now(); + + @override + Stream> get content => Stream.value(utf8.encode(jsonEncode(data))); + + @override + int? get contentLength => null; + + @override + String? get eTag => null; + + @override + String get fileExtension => ''; + + @override + int get statusCode => data == null ? 404 : 200; + + @override + DateTime get validTill => _receivedTime.add(const Duration(days: 3)); + +} diff --git a/lib/logic/player/content.dart b/lib/logic/player/content.dart index 66161a5e7..73dc1702c 100644 --- a/lib/logic/player/content.dart +++ b/lib/logic/player/content.dart @@ -3,22 +3,24 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @dart = 2.12 - import 'dart:async'; -import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:device_info/device_info.dart'; import 'package:enum_to_string/enum_to_string.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; // import 'package:quick_actions/quick_actions.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:sweyer/logic/logic.dart'; import 'package:sweyer/sweyer.dart'; +// See content logic overview here +// https://docs.google.com/document/d/1QtF9koBcWuRE1lIYJ45cRMogAprb7xD83ImmI0cn3lQ/edit +// TODO: update it + enum QuickAction { search, shuffleAll, @@ -27,40 +29,57 @@ enum QuickAction { extension QuickActionSerialization on QuickAction { String get value => EnumToString.convertToString(this); -} - -/// The description where the [QueueType.arbitrary] originates from. -/// -/// Can be Cconverted to human readable text with [AppLocalizations.arbitraryQueueOrigin]. -enum ArbitraryQueueOrigin { - /// Correspnods - allAlbums, -} - -extension ArbitraryQueueOriginSerialization on ArbitraryQueueOrigin { - String get value => EnumToString.convertToString(this); } /// Picks some value based on the provided `T` type of [Content]. -/// +/// /// Instead of `T`, you can explicitly specify [contentType]. -/// +/// /// The [fallback] can be specified in cases when the type is [Content]. /// Generally, it's better never use it, but in some cases, like selection actions, /// that can react to [ContentSelectionController]s of mixed types, it is relevant to use it. +/// +/// The point of this function is to structurize and generalize the places where multiple contents +/// an be used. It also allows to ensure that every existing content in the app is supported in all +/// places it should be supported, this is extra useful when new content type is added. V contentPick({ Type? contentType, required V song, required V album, + required V playlist, + required V artist, V? fallback, }) { - // TODO: when i fully migrate to safety, remove this assert and allow passing nulls here - assert(song != null && album != null); switch (contentType ?? T) { case Song: return song; case Album: return album; + case Playlist: + return playlist; + case Artist: + return artist; + case Content: + if (fallback != null) + return fallback; + throw UnimplementedError(); + default: + throw UnimplementedError(); + } +} + +/// Analogue of [contentPick] for [PersistentQueue]s. +V persistentQueuePick({ + Type? contentType, + required V album, + required V playlist, + V? fallback, +}) { + switch (contentType ?? T) { + case Album: + return album; + case Playlist: + return playlist; case Content: if (fallback != null) return fallback; @@ -75,23 +94,22 @@ class ContentMap { /// Creates a content map from initial value [map]. /// /// If none specified, will initialize the map with `null`s. - ContentMap([Map? map]) : - _map = map ?? { - Song: null, - Album: null, - }; + ContentMap([Map? map]) : _map = map ?? {}; - Map _map; + final Map _map; /// Map values. - Iterable get values => _map.values; + Iterable get values => _map.values; + + /// Map entries. + Iterable> get entries => _map.entries; /// Returs a [Sort] per `T` [Content] from the map. /// /// If [key] was explicitly provided, will use it instead. V getValue([Type? key]) { assert( - Content.enumerate().contains(typeOf()), + Content.enumerate().contains(key ?? T), "Specified type must be a subtype of Content", ); return _map[key ?? T]!; @@ -102,7 +120,7 @@ class ContentMap { /// If [key] was explicitly provided, will use it instead. void setValue(V value, {Type? key}) { assert( - Content.enumerate().contains(typeOf()), + Content.enumerate().contains(key ?? T), "Specified type must be a subtype of Content", ); _map[key ?? T] = value; @@ -149,7 +167,7 @@ class _QueuePool { /// Actual type of the queue, that can be displayed to the user. QueueType get type => _type; - QueueType _type = QueueType.all; + QueueType _type = QueueType.allSongs; _PoolQueueType get _internalType { if (shuffled) { @@ -158,29 +176,27 @@ class _QueuePool { return _PoolQueueType.queue; } - Queue get current => _map[_internalType]!; + Queue get current { + final value = _map[_internalType]!; + assert(value.isNotEmpty, "Current queue must not be empty"); + if (value.isEmpty) { + ContentControl.resetQueue(); + } + return _map[_internalType]!; + } Queue get _queue => _map[_PoolQueueType.queue]!; Queue get _shuffledQueue => _map[_PoolQueueType.shuffled]!; - /// Current queue for [QueueType.persistent]. - /// If [type] is not [QueueType.persistent], will return `null`. - PersistentQueue? get persistent => _persistent; - PersistentQueue? _persistent; + /// Current queue for [QueueType.origin]. + /// If [type] is not [QueueType.origin], will return `null`. + SongOrigin? get origin => _origin; + SongOrigin? _origin; /// A search query for [QueueType.searched]. /// If [type] is not [QueueType.searched], will return `null`. String? get searchQuery => _searchQuery; String? _searchQuery; - /// A description where the [QueueType.arbitrary] originates from. - /// - /// May be `null`, then by default instead of description, in the interface queue should be just - /// marked as [AppLocalizations.arbitraryQueue]. - /// - /// If [type] is not [QueueType.arbitrary], will return `null`. - ArbitraryQueueOrigin? get arbitraryQueueOrigin => _arbitraryQueueOrigin; - ArbitraryQueueOrigin? _arbitraryQueueOrigin; - /// Whether the current queue is modified. /// /// Applied in certain conditions when user adds, removes @@ -194,31 +210,40 @@ class _QueuePool { bool _shuffled = false; } -class _ContentState { +class ContentState { final _QueuePool queues = _QueuePool({ _PoolQueueType.queue: Queue([]), _PoolQueueType.shuffled: Queue([]), }); - /// The path to default album art to show it in notification. - late String defaultAlbumArtPath; - /// All songs in the application. /// This list should be modified in any way, except for sorting. Queue allSongs = Queue([]); Map albums = {}; + List playlists = []; + + List artists = []; + /// This is a map to store ids of duplicated songs in queue. - /// Its key is always negative, so when a song has negative id, you must - /// look up for the mapping of its actual id in here. - Map idMap = {}; + /// + /// The key is string, because [jsonEncode] and [jsonDecode] can only + /// work with `Map`. Convertion to int doesn't seem to be a + /// benefit, so keeping this as string. + /// + /// See [ContentUtils.deduplicateSong] for discussion about the + /// logic behind this. + IdMap idMap = {}; + + /// When true, [idMap] will be saved in the next [setQueue] call. + bool idMapDirty = false; /// Contains various [Sort]s of the application. /// Sorts of specific [Queues] like [Album]s are stored separately. // TODO: this is currently not implemented - remove this todo when it will be /// - /// Values are restored in [_restoreSorts]. - final ContentMap sorts = ContentMap(); + /// Restored in [ContentContol._restoreSorts]. + late final ContentMap sorts; /// Get current playing song. Song get currentSong { @@ -244,22 +269,23 @@ class _ContentState { return index; } - /// Currently playing peristent queue when song is added via [playQueueNext] - /// or [addQueueToQueue]. + /// Currently playing peristent queue when song is added via [ContentControl.playOriginNext] + /// or [ContentControl.addOriginToQueue]. /// - /// Used for showing [CurrentIndicator] for [PersistenQueue]s. + /// Used for showing [CurrentIndicator] for [SongOrigin]s. /// - /// See [Song.origin] for more info. - PersistentQueue? get currentSongOrigin => currentSong.origin; + /// See also [Song.origin]. + SongOrigin? get currentSongOrigin => currentSong.origin; /// Changes current song id and emits change event. /// This allows to change the current id visually, separately from the player. /// /// Also, uses [Song.origin] to set [currentSongOrigin]. void changeSong(Song song) { - Prefs.songIdInt.set(song.id); - // Song id saved to prefs in the native play method. - emitSongChange(song); + if (song.id != currentSongNullable?.id) + Prefs.songId.set(song.id); + if (!identical(song, currentSongNullable)) + emitSongChange(song); } /// A stream of changes over content. @@ -321,13 +347,13 @@ abstract class ContentControl { /// /// This getter only can be called when it's known for sure /// that this will be not `null`, otherwise it will throw. - static _ContentState get state => _stateSubject.value!; + static ContentState get state => _stateSubject.value!; /// Same as [state], but can be `null`, which means that the state was disposed. - static _ContentState? get stateNullable => _stateSubject.value; + static ContentState? get stateNullable => _stateSubject.value; /// Notifies when [state] is changed created or disposed. - static Stream<_ContentState?> get onStateCreateRemove => _stateSubject.stream; - static final BehaviorSubject<_ContentState?> _stateSubject = BehaviorSubject(); + static Stream get onStateCreateRemove => _stateSubject.stream; + static final BehaviorSubject _stateSubject = BehaviorSubject(); // /// Recently pressed quick action. // static final quickAction = BehaviorSubject(); @@ -347,26 +373,18 @@ abstract class ContentControl { static late int _sdkInt; static int get sdkInt => _sdkInt; - static ValueNotifier get devMode => _devMode; - static late ValueNotifier _devMode; - /// Sets dev mode. - static void setDevMode(bool value) { - devMode.value = value; - Prefs.devModeBool.set(value); - } - /// The main data app initialization function, inits all queues. /// Also handles no-permissions situations. static Future init() async { if (stateNullable == null) { - _stateSubject.add(_ContentState()); + _stateSubject.add(ContentState()); } final androidInfo = await DeviceInfoPlugin().androidInfo; _sdkInt = androidInfo.version.sdkInt; - _devMode = ValueNotifier(await Prefs.devModeBool.get()); if (Permissions.granted) { + // TODO: prevent initalizing if already initizlied _initializeCompleter = Completer(); - state.emitContentChange(); // update ui to show "Searching songs screen" + state.emitContentChange(); // update ui to show "Searching songs" screen await Future.wait([ state.queues.init(), _idMapSerializer.init(), @@ -431,25 +449,22 @@ abstract class ContentControl { // ]); // } - /// Should be called if played song is duplicated in the current queue. - static void handleDuplicate(Song song) { - final originalSong = state.allSongs.byId.get(song.sourceId); - if (originalSong == null) { - removeFromQueue(song); - return; + //****************** Queue manipulation methods ***************************************************** + + /// Must be called before the song is instreted to the current queue, + /// calls [ContentUtils.deduplicateSong]. + static void _deduplicateSong(Song song) { + final result = ContentUtils.deduplicateSong( + song: song, + index: null, + list: state.queues.current.songs, + idMap: state.idMap, + ); + if (result) { + state.idMapDirty = true; } - if (identical(originalSong, song)) - return; - final map = state.idMap; - final newId = -(map.length + 1); - map[newId.toString()] = originalSong.id; - song.id = newId; - state.queues._saveCurrentQueue(); - _idMapSerializer.save(state.idMap); } - //****************** Queue manipulation methods ***************************************************** - /// Marks queues modified and traverses it to be unshuffled, preseving the shuffled /// queue contents. static void _unshuffle() { @@ -463,24 +478,44 @@ abstract class ContentControl { ); } - /// Cheks if current queue is persistent, if yes, adds this queue as origin - /// to all its songs. This is a required actions for each addition to the queue. + /// Checks if current queue is [QueueType.origin], if yes, adds this queue as origin + /// to all its songs. This is a required action for each addition to the queue. static void _setOrigins() { - // Adding origin to the songs in the current persistent playlist. - if (state.queues.type == QueueType.persistent) { + if (state.queues.type == QueueType.origin) { final songs = state.queues.current.songs; - final persistentQueue = state.queues.persistent!; + final songOrigin = state.queues.origin!; for (final song in songs) { - song.origin = persistentQueue; + song.origin = songOrigin; + } + } + } + + /// Checks whether the current origin contains a song. + /// If current queue is not origin, will always return `true`. + /// Intended to be used in queue insertion operations, see [playNext] for example. + static bool _doesOriginContain(Song song) { + final queues = state.queues; + if (queues._type == QueueType.origin) { + final currentOrigin = queues.origin!; + final originSongs = currentOrigin.songs; + final int index; + if (currentOrigin is DuplicatingSongOriginMixin) { + // Duplicating song origins should be a unique container, so that songs that are outside them + // are considered to be not contained in them, even if they have the same source IDs. + index = originSongs.indexWhere((el) => el.sourceId == song.sourceId && currentOrigin == song.origin); + } else { + index = originSongs.indexWhere((el) => el.sourceId == song.sourceId); } + return index >= 0; } + return true; } /// If the [song] is next (or currently playing), will duplicate it and queue it to be played next, /// else will move it to be next. After that it can be duplicated to be played more. /// /// Same as for [addToQueue]: - /// * if current queue is [QueueType.persistent] and the added [song] is present in it, will mark the queue as modified, + /// * if current queue is [QueueType.origin] and the added [song] is present in it, will mark the queue as modified, /// else will traverse it into [QueueType.arbitrary]. All the other queues will be just marked as modified. /// * if current queue is shuffled, it will copy all songs (thus saving the order of shuffled songs), go back to be unshuffled, /// and add the [songs] there. @@ -493,19 +528,19 @@ abstract class ContentControl { final currentQueue = queues.current; if (songs.length == 1) { final song = songs[0]; - if (song != state.currentSong && - song != currentQueue.getNext(state.currentSong) && + if (song.sourceId != state.currentSong.sourceId && + song.sourceId != currentQueue.getNext(state.currentSong)?.sourceId && state.currentSongIndex != currentQueue.length - 1) { currentQueue.remove(song); } } bool contains = true; for (int i = 0; i < songs.length; i++) { - currentQueue.insert(state.currentSongIndex + i + 1, songs[i]); - if (queues._type == QueueType.persistent && contains) { - final persistentSongs = queues.persistent!.songs; - final index = persistentSongs.indexWhere((el) => el.sourceId == songs[i].sourceId); - contains = index >= 0; + final song = songs[i].copyWith(); + _deduplicateSong(song); + currentQueue.insert(state.currentSongIndex + i + 1, song); + if (contains) { + contains = _doesOriginContain(song); } } setQueue(type: contains ? null : QueueType.arbitrary); @@ -514,39 +549,38 @@ abstract class ContentControl { /// Queues the [song] to the last position in queue. /// /// Same as for [playNext]: - /// * if current queue is [QueueType.persistent] and the added [song] is present in it, will mark the queue as modified, + /// * if current queue is [QueueType.origin] and the added [song] is present in it, will mark the queue as modified, /// else will traverse it into [QueueType.arbitrary]. All the other queues will be just marked as modified. /// * if current queue is shuffled, it will copy all songs (thus saving the order of shuffled songs), go back to be unshuffled, /// and add the [songs] there. static void addToQueue(List songs) { assert(songs.isNotEmpty); - final queues = state.queues; // Save queue order _unshuffle(); _setOrigins(); bool contains = true; - for (final song in songs) { + for (var song in songs) { + song = song.copyWith(); + _deduplicateSong(song); state.queues.current.add(song); - if (queues._type == QueueType.persistent && contains) { - final persistentSongs = queues.persistent!.songs; - final index = persistentSongs.indexWhere((el) => el.sourceId == song.sourceId); - contains = index >= 0; + if (contains) { + contains = _doesOriginContain(song); } } setQueue(type: contains ? null : QueueType.arbitrary); } - /// Queues the persistent [queue] to be played next. + /// Queues the song origin to be played next. /// /// Saves it to [Song.origin] in its items, and so when the item is played, - /// this peristent queue will be also shown as playing. + /// this song origin will be also shown as playing. /// - /// If currently some persistent queue is already playing, will first save the current queue to + /// If currently some song origin is already playing, will first save the current queue to /// [Song.origin] in its items. /// /// In difference with [playNext], always traverses the playlist into [QueueType.arbitrary]. - static void playQueueNext(PersistentQueue queue) { - final songs = queue.songs; + static void playOriginNext(SongOrigin origin) { + final songs = origin.songs; assert(songs.isNotEmpty); // Save queue order _unshuffle(); @@ -554,46 +588,53 @@ abstract class ContentControl { final currentQueue = state.queues.current; final currentIndex = state.currentSongIndex; int i = 0; - for (final song in songs) { - song.origin = queue; + for (var song in songs) { + song = song.copyWith(); + song.origin = origin; + _deduplicateSong(song); currentQueue.insert(currentIndex + i + 1, song); i++; } setQueue(type: QueueType.arbitrary); } - /// Queues the persistent [queue] to the last position in queue. + /// Queues the song origin to the last position in queue. /// /// Saves it to [Song.origin] in its items, and so when the item is played, - /// this peristent queue will be also shown as playing. + /// this song origin will be also shown as playing. /// - /// If currently some persistent queue is already playing, will first save the current queue to + /// If currently some song origin is already playing, will first save the current queue to /// [Song.origin] in its items. /// /// In difference with [addToQueue], always traverses the playlist into [QueueType.arbitrary]. - static void addQueueToQueue(PersistentQueue queue) { - final songs = queue.songs; + static void addOriginToQueue(SongOrigin origin) { + final songs = origin.songs; assert(songs.isNotEmpty); // Save queue order _unshuffle(); _setOrigins(); - for (final song in songs) { - song.origin = queue; + for (var song in songs) { + song = song.copyWith(); + song.origin = origin; + _deduplicateSong(song); state.queues.current.add(song); } setQueue(type: QueueType.arbitrary); } - /// Inserts [song] at [index] in the queue. - static void insertToQueue(int index, Song song) { + /// Inserts [songs] at [index] in the queue. + static void insertToQueue(int index, List songs) { // Save queue order _unshuffle(); - final queues = state.queues; + _setOrigins(); bool contains = true; - if (queues._type == QueueType.persistent) { - final persistentSongs = queues.persistent!.songs; - final index = persistentSongs.indexWhere((el) => el.sourceId == song.sourceId); - contains = index >= 0; + for (var song in songs) { + song = song.copyWith(); + _deduplicateSong(song); + state.queues.current.insert(index, song); + if (contains) { + contains = _doesOriginContain(song); + } } setQueue(type: contains ? null : QueueType.arbitrary); } @@ -604,20 +645,28 @@ abstract class ContentControl { /// * fall back to the first song in [QueueType.all] /// * fall back to [QueueType.all] /// * stop the playback - static void removeFromQueue(Song song) { + static bool removeFromQueue(Song song) { final queues = state.queues; + final bool removed; if (queues.current.length == 1) { - resetQueue(); - MusicPlayer.instance.setSong(state.queues.current.songs[0]); - MusicPlayer.instance.pause(); + removed = queues.current.remove(song); + if (removed) { + resetQueueAsFallback(); + } } else { - if (song == state.currentSong) { - MusicPlayer.instance.setSong(state.queues.current.getPrev(song)); + final current = song == state.currentSong; + Song? nextSong; + if (current) { + nextSong = state.queues.current.getNext(song); + } + removed = queues.current.remove(song); + if (removed && current) { MusicPlayer.instance.pause(); + MusicPlayer.instance.setSong(nextSong); } - queues.current.remove(song); setQueue(modified: true); } + return removed; } /// Removes a song at given [index] from the queue. @@ -626,20 +675,21 @@ abstract class ContentControl { /// * fall back to the first song in [QueueType.all] /// * fall back to [QueueType.all] /// * stop the playback - static void removeFromQueueAt(int index) { + static Song? removeFromQueueAt(int index) { final queues = state.queues; + final Song? song; if (queues.current.length == 1) { - resetQueue(); - MusicPlayer.instance.setSong(state.queues.current.songs[0]); - MusicPlayer.instance.pause(); + song = queues.current.removeAt(0); + resetQueueAsFallback(); } else { if (index == state.currentSongIndex) { - MusicPlayer.instance.setSong(state.queues.current.getNextAt(index)); MusicPlayer.instance.pause(); + MusicPlayer.instance.setSong(state.queues.current.getNextAt(index)); } - queues.current.removeAt(index); + song = queues.current.removeAt(index); setQueue(modified: true); } + return song; } /// Removes all items at given [indexes] from the queue. @@ -649,12 +699,12 @@ abstract class ContentControl { /// * fall back to the first song in [QueueType.all] /// * fall back to [QueueType.all] /// * stop the playback + /// + /// TODO: add return value ? static void removeAllFromQueueAt(List indexes) { final queues = state.queues; if (indexes.length >= queues.current.length) { - resetQueue(); - MusicPlayer.instance.setSong(state.queues.current.songs[0]); - MusicPlayer.instance.pause(); + resetQueueAsFallback(); } else { final containsCurrent = indexes.contains(state.currentSongIndex); if (containsCurrent) { @@ -665,7 +715,7 @@ abstract class ContentControl { } if (containsCurrent) { /// TODO: add to [Queue] something like relative indexing, that allows negative indexes - /// and imporvie this + /// and imporove this MusicPlayer.instance.setSong(state.queues.current.songs[0]); } setQueue(modified: true); @@ -683,21 +733,19 @@ abstract class ContentControl { ); } - /// A shorthand for setting [QueueType.persistent]. - /// - /// By default sets [shuffled] queue. - static void setPersistentQueue({ - required PersistentQueue queue, + /// A shorthand for setting [QueueType.origin]. + static void setOriginQueue({ + required SongOrigin origin, required List songs, bool shuffled = false, + List? shuffledSongs, }) { - List? shuffledSongs; if (shuffled) { - shuffledSongs = Queue.shuffleSongs(songs); + shuffledSongs ??= Queue.shuffleSongs(songs); } setQueue( - type: QueueType.persistent, - persistentQueue: queue, + type: QueueType.origin, + origin: origin, modified: false, shuffled: shuffled, songs: shuffledSongs ?? songs, @@ -708,15 +756,30 @@ abstract class ContentControl { /// Resets queue to all songs. static void resetQueue() { setQueue( - type: QueueType.all, + type: QueueType.allSongs, + modified: false, + shuffled: false, + ); + } + + /// Resets queue to all songs, pauses the player and sets the first song as + /// current. + /// + /// This fucntion should be called if queue is found to be broken + /// and there's no straight way to figure out where to fallback. + static void resetQueueAsFallback() { + setQueue( + type: QueueType.allSongs, modified: false, shuffled: false, ); + MusicPlayer.instance.pause(); + MusicPlayer.instance.setSong(state.queues.current.songs[0]); } /// Sets the queue with specified [type] and other parameters. - /// Most of the parameters are updated separately and almost can be omitted, - /// unless differently specified: + /// Most of the parameters are updated separately and can be omitted, unless + /// differently specified: /// /// * [shuffled] can be used to shuffle / unshuffle the queue /// * [modified] can be used to mark current queue as modified @@ -733,22 +796,19 @@ abstract class ContentControl { /// /// If both [songs] and [shuffleFrom] is not specified, will shuffle /// from current queue. - /// * [persistentQueue] is the persistent queue being set, - /// only applied when [type] is [QueueType.persistent]. - /// When [QueueType.persistent] is set and currently it's not persistent, this parameter is required. + /// * [origin] is the song origin being set, only applied when [type] is [QueueType.origin]. + /// When [QueueType.origin] is set and currently it's not origin, this parameter is required. /// Otherwise it can be omitted and for updating other paramters only. + /// + /// With playlist origin the [Playlist.idMap] will be used to update the + /// [ContentState.idMap]. /// * [searchQuery] is the search query the playlist was searched by, /// only applied when [type] is [QueueType.searched]. - /// Similarly as for [persistentQueue], when [QueueType.searched] is set and currently it's not searched, + /// Similarly as for [origin], when [QueueType.searched] is set and currently it's not searched, /// this parameter is required. Otherwise it can be omitted for updating other paramters only. - /// * [arbitraryQueueOrigin] is the description where the [QueueType.arbitrary] originates from, - /// ignored with other types of queues. If none specified, by default instead of description, - /// queue is just marked as [AppLocalizations.arbitraryQueue]. - /// It always must be localized, so [AppLocalizations] getter must be returned from this function. - /// - /// Because this parameter can be null with [QueueType.arbitrary], to reset to back to `null` - /// after it's set, you need to pass [type] explicitly. /// * [emitChangeEvent] is whether to emit a song list change event + /// * [setIdMapFromPlaylist] allows to configure whether to set the [Playlist.idMap] + /// when set [origin] is playlist. Needed to not override the map at the app start. /// * [save] parameter can be used to disable redundant writing to JSONs when, /// for example, when we restore the queue from this exact json. /// * [copied] indicates that [songs] was already copied, @@ -759,10 +819,10 @@ abstract class ContentControl { bool? modified, List? songs, List? shuffleFrom, - PersistentQueue? persistentQueue, + SongOrigin? origin, String? searchQuery, - ArbitraryQueueOrigin? arbitraryQueueOrigin, bool save = true, + bool setIdMapFromPlaylist = true, bool copied = false, bool emitChangeEvent = true, }) { @@ -778,10 +838,10 @@ abstract class ContentControl { "It's invalid to set empty songs queue", ); assert( - type != QueueType.persistent || - queues._persistent != null || - persistentQueue != null, - "When you set `persistent` queue and currently none set, you must provide the `persistentQueue` paramenter", + type != QueueType.origin || + queues._origin != null || + origin != null, + "When you set `origin` queue and currently none set, you must provide the `origin` paramenter", ); assert( type != QueueType.searched || @@ -790,51 +850,45 @@ abstract class ContentControl { "When you set `searched` queue and currently none set, you must provide the `searchQuery` paramenter", ); - final typeArg = type; type ??= queues._type; if (type == QueueType.arbitrary) { modified = false; - if (arbitraryQueueOrigin != null) { - // Set once and don't change thereafter until type is passed explicitly. - state.queues._arbitraryQueueOrigin = arbitraryQueueOrigin; - Prefs.arbitraryQueueOrigin.set(arbitraryQueueOrigin.value); - } - } - if (type != QueueType.arbitrary || - // Reset when queue type is passed explicitly. - typeArg == QueueType.arbitrary && arbitraryQueueOrigin == null) { - state.queues._arbitraryQueueOrigin = null; - Prefs.arbitraryQueueOrigin.delete(); } - if (type == QueueType.persistent) { - if (persistentQueue != null) { - queues._persistent = persistentQueue; - Prefs.persistentQueueId.set(persistentQueue.id); + if (type == QueueType.origin) { + if (origin != null) { + queues._origin = origin; + Prefs.songOrigin.set(origin); + if (setIdMapFromPlaylist && origin is Playlist) { + state.idMap.clear(); + state.idMap.addAll(origin.idMap); + state.idMapDirty = false; + _idMapSerializer.save(state.idMap); + } } } else { - queues._persistent = null; - Prefs.persistentQueueId.delete(); + queues._origin = null; + Prefs.songOrigin.delete(); } if (type == QueueType.searched) { if (searchQuery != null) { queues._searchQuery = searchQuery; - Prefs.searchQueryString.set(searchQuery); + Prefs.searchQuery.set(searchQuery); } } else { queues._searchQuery = null; - Prefs.searchQueryString.delete(); + Prefs.searchQuery.delete(); } modified ??= queues._modified; shuffled ??= queues._shuffled; queues._type = type; - Prefs.queueTypeString.set(type.value); + Prefs.queueType.set(type); queues._modified = modified; - Prefs.queueModifiedBool.set(modified); + Prefs.queueModified.set(modified); if (shuffled) { queues._shuffledQueue.setSongs( @@ -849,13 +903,13 @@ abstract class ContentControl { queues._shuffledQueue.clear(); if (songs != null) { queues._queue.setSongs(copySongs(songs)); - } else if (type == QueueType.all && !modified) { + } else if (type == QueueType.allSongs && !modified) { queues._queue.setSongs(List.from(state.allSongs.songs)); } } queues._shuffled = shuffled; - Prefs.queueShuffledBool.set(shuffled); + Prefs.queueShuffled.set(shuffled); if (save) { state.queues._saveCurrentQueue(); @@ -864,9 +918,13 @@ abstract class ContentControl { if (state.idMap.isNotEmpty && !modified && !shuffled && - type != QueueType.persistent && + type != QueueType.origin && type != QueueType.arbitrary) { state.idMap.clear(); + state.idMapDirty = false; + _idMapSerializer.save(state.idMap); + } else if (state.idMapDirty) { + state.idMapDirty = false; _idMapSerializer.save(state.idMap); } @@ -883,7 +941,7 @@ abstract class ContentControl { if (state.queues.current.isEmpty) { // Set queue to global if searched or shuffled are happened to be zero-length setQueue( - type: QueueType.all, + type: QueueType.allSongs, modified: false, shuffled: false, emitChangeEvent: false, @@ -915,20 +973,30 @@ abstract class ContentControl { /// Returns content of specified type. static List getContent([Type? contentType]) { - return contentPick Function()>( + return contentPick>>( contentType: contentType, song: () => state.allSongs.songs as List, album: () => state.albums.values.toList() as List, + playlist: () => state.playlists as List, + artist: () => state.artists as List, )(); } + /// Returns content of specified type with ID. + static T? getContentById(int id, [Type? contentType]) { + if ((contentType ?? T) == Album) + return state.albums[id] as T?; + return getContent(contentType).firstWhereOrNull((el) => el.id == id); + } + /// Refetches all the content. static Future refetchAll() async { await Future.wait([ for (final contentType in Content.enumerate()) refetch(contentType: contentType), ]); - return MusicPlayer.instance.restoreLastSong(); + if (!disposed) + await MusicPlayer.instance.restoreLastSong(); } /// Refetches content by the `T` content type. @@ -942,6 +1010,8 @@ abstract class ContentControl { bool updateQueues = true, bool emitChangeEvent = true, }) async { + if (disposed) + return; await contentPick( contentType: contentType, song: () async { @@ -956,13 +1026,38 @@ abstract class ContentControl { } }, album: () async { - if (disposed) - return; state.albums = await ContentChannel.retrieveAlbums(); - if (disposed) + if (disposed) { return; + } + final origin = state.queues.origin; + if (origin is Album && state.albums[origin.id] == null) { + resetQueueAsFallback(); + } sort(emitChangeEvent: false); - } + }, + playlist: () async { + state.playlists = await ContentChannel.retrievePlaylists(); + if (disposed) { + return; + } + final origin = state.queues.origin; + if (origin is Playlist && state.playlists.firstWhereOrNull((el) => el == origin) == null) { + resetQueueAsFallback(); + } + sort(emitChangeEvent: false); + }, + artist: () async { + state.artists = await ContentChannel.retrieveArtists(); + if (disposed) { + return; + } + final origin = state.queues.origin; + if (origin is Artist && state.artists.firstWhereOrNull((el) => el == origin) == null) { + resetQueueAsFallback(); + } + sort(emitChangeEvent: false); + }, )(); if (emitChangeEvent) { stateNullable?.emitContentChange(); @@ -976,16 +1071,17 @@ abstract class ContentControl { // Lowercase to bring strings to one format query = query.toLowerCase(); final words = query.split(' '); + // TODO: add filter by year, and perhaps make a whole filter system, so it would be easy to filter by any parameter // this should be some option in the UI like "Search by year", // i disabled it because it filtered out searches like "28 days later soundtrack". // // final year = int.tryParse(words[0]); - const year = null; + /// Splits string by spaces, or dashes, or bar, or paranthesis final abbreviationRegexp = RegExp(r'[\s\-\|\(\)]'); final l10n = staticl10n; - /// Checks whether a [string] is abbreviation. + /// Checks whether a [string] is abbreviation for the [query]. /// For example: "big baby tape - bbt" bool isAbbreviation(String string) { return string.toLowerCase() @@ -994,46 +1090,56 @@ abstract class ContentControl { .join() .contains(query); } - final contentInterable = contentPick Function()>( + final contentInterable = contentPick>>( contentType: contentType, song: () { return state.allSongs.songs.where((song) { // Exact query search - bool fullQuery; final wordsTest = words.map((word) => song.title.toLowerCase().contains(word) || - formatArtist(song.artist, l10n).toLowerCase().contains(word) || - song.album.toLowerCase().contains(word) + ContentUtils.localizedArtist(song.artist, l10n).toLowerCase().contains(word) || + (song.album?.toLowerCase().contains(word) ?? false) ).toList(); - // Exclude the year from query word tests - if (year != null) { - wordsTest.removeAt(0); - } - fullQuery = wordsTest.every((e) => e); + final fullQuery = wordsTest.every((e) => e); + // Abbreviation search final abbreviation = isAbbreviation(song.title); - // Filter by year - if (year != null && year != song.getAlbum().year) - return false; return fullQuery || abbreviation; }).cast(); }, album: () { return state.albums.values.where((album) { // Exact query search - bool fullQuery; final wordsTest = words.map((word) => - formatArtist(album.artist, l10n).toLowerCase().contains(word) || + ContentUtils.localizedArtist(album.artist, l10n).toLowerCase().contains(word) || album.album.toLowerCase().contains(word), ).toList(); - // Exclude the year from query word tests - if (year != null) { - wordsTest.removeAt(0); - } - fullQuery = wordsTest.every((e) => e); + final fullQuery = wordsTest.every((e) => e); + // Abbreviation search final abbreviation = isAbbreviation(album.album); - // Filter by year - if (year != null && year != album.year) - return false; + return fullQuery || abbreviation; + }).cast(); + }, + playlist: () { + return state.playlists.where((playlist) { + // Exact query search + final wordsTest = words.map((word) => + playlist.name.toLowerCase().contains(word), + ).toList(); + final fullQuery = wordsTest.every((e) => e); + // Abbreviation search + final abbreviation = isAbbreviation(playlist.name); + return fullQuery || abbreviation; + }).cast(); + }, + artist: () { + return state.artists.where((artist) { + // Exact query search + final wordsTest = words.map((word) => + artist.artist.toLowerCase().contains(word), + ).toList(); + final fullQuery = wordsTest.every((e) => e); + // Abbreviation search + final abbreviation = isAbbreviation(artist.artist); return fullQuery || abbreviation; }).cast(); }, @@ -1043,27 +1149,42 @@ abstract class ContentControl { /// Sorts songs, albums, etc. /// See [ContentState.sorts]. - static void sort({ Sort? sort, bool emitChangeEvent = true }) { + static void sort({ Type? contentType, Sort? sort, bool emitChangeEvent = true }) { final sorts = state.sorts; - sort ??= sorts.getValue() as Sort; + sort ??= sorts.getValue(contentType) as Sort; contentPick( + contentType: contentType, song: () { final _sort = sort! as SongSort; sorts.setValue(_sort); - Prefs.songSortString.set(jsonEncode(sort.toJson())); + Prefs.songSort.set(_sort); final comparator = _sort.comparator; state.allSongs.songs.sort(comparator); }, album: () { final _sort = sort! as AlbumSort; sorts.setValue(_sort); - Prefs.albumSortString.set(jsonEncode(_sort.toJson())); + Prefs.albumSort.set(_sort); final comparator = _sort.comparator; state.albums = Map.fromEntries(state.albums.entries.toList() ..sort((a, b) { return comparator(a.value, b.value); })); - } + }, + playlist: () { + final _sort = sort! as PlaylistSort; + sorts.setValue(_sort); + Prefs.playlistSort.set(_sort); + final comparator = _sort.comparator; + state.playlists.sort(comparator); + }, + artist: () { + final _sort = sort! as ArtistSort; + sorts.setValue(_sort); + Prefs.artistSort.set(_sort); + final comparator = _sort.comparator; + state.artists.sort(comparator); + }, )(); // Emit event to track change stream if (emitChangeEvent) { @@ -1071,36 +1192,69 @@ abstract class ContentControl { } } - /// Deletes songs by specified [idSet]. + /// Filters out non-source songs (with negative IDs), and asserts that. + static Set _ensureSongsAreSource(Set songs) { + return songs.fold>({}, (prev, el) { + if (el.id >= 0) { + // Taking precautions for release mode + prev.add(el); + } else { + assert(false, "All IDs must be source (non-negative)"); + } + return prev; + }).toSet(); + } + + /// Sets songs' favorite flag to [value]. /// - /// Ids must be source (not negative). - static Future deleteSongs(Set idSet) async { - final Set songsSet = {}; - // On Android R the deletion is performed with OS dialog. - if (_sdkInt >= 30) { - for (final id in idSet) { - final song = state.allSongs.byId.get(id); - if (song != null) { - songsSet.add(song); + /// The songs must have a source ID (non-negative). + static Future setSongsFavorite(Set songs, bool value) async { + // todo: implement + songs = _ensureSongsAreSource(songs); + if (sdkInt >= 30) { + try { + final result = await ContentChannel.setSongsFavorite(songs, value); + if (result) { + await refetch(); } + } catch (ex, stack) { + FirebaseCrashlytics.instance.recordError( + ex, + stack, + reason: 'in setSongsFavorite', + ); + ShowFunctions.instance.showToast( + msg: staticl10n.oopsErrorOccurred, + ); + debugPrint('setSongsFavorite error: $ex'); } } else { - for (final id in idSet) { - final song = state.allSongs.byId.get(id); - if (song != null) { - songsSet.add(song); - } - state.allSongs.byId.remove(id); - } + + } + } + + /// Deletes a set of songs. + /// + /// The songs must have a source ID (non-negative). + static Future deleteSongs(Set songs) async { + songs = _ensureSongsAreSource(songs); + + void _removeFromState() { + for (final song in songs) + state.allSongs.byId.remove(song.id); removeObsolete(); } + // On Android R the deletion is performed with OS dialog. + if (sdkInt < 30) { + _removeFromState(); + } + try { - final result = await ContentChannel.deleteSongs(songsSet); + final result = await ContentChannel.deleteSongs(songs); await refetchAll(); if (sdkInt >= 30 && result) { - idSet.forEach(state.allSongs.byId.remove); - removeObsolete(); + _removeFromState(); } } catch (ex, stack) { FirebaseCrashlytics.instance.recordError( @@ -1111,7 +1265,129 @@ abstract class ContentControl { ShowFunctions.instance.showToast( msg: staticl10n.deletionError, ); - print('Deletion error: $ex'); + debugPrint('deleteSongs error: $ex'); + } + } + + /// When playlists are being updated in any way, there's a chance + /// that after refetching a playlist, it will contain a song with + /// ID that we don't know yet. + /// + /// To avoid this, both songs and playlists should be refetched. + static Future refetchSongsAndPlaylists() async { + await Future.wait([ + refetch(emitChangeEvent: false), + refetch(emitChangeEvent: false), + ]); + stateNullable?.emitContentChange(); + } + + /// Checks if there's are playlists with names like "name" and "name (1)" and: + /// * if yes, increases the number by one from the max and returns string with it + /// * else returns the string unmodified. + static Future correctPlaylistName(String name) async { + // Update the playlist in case they are outdated + await refetch(emitChangeEvent: false); + + // If such name already exists, find the max duplicate number and make the name + // "name (max + 1)" instead. + if (state.playlists.firstWhereOrNull((el) => el.name == name) != null) { + // Regexp to search for names like "name" and "name (1)" + // Things like "name (1)(1)" will not be matched + // + // Part of it is taken from https://stackoverflow.com/a/17779833/9710294 + // + // Explanation: + // * `name`: playlist name + // * `(`: begin optional capturing group, because we need to match the name without parentheses + // * ` `: match space + // * `\(`: match an opening parentheses + // * `(`: begin capturing group + // * `[^)]+`: match one or more non ) characters + // * `)`: end capturing group + // * `\)` : match closing parentheses + // * `)?`: close optional capturing group\ + // * `$`: match string end + final regexp = RegExp(name.toString() + r'( \(([^)]+)\))?$'); + int? max; + for (final el in state.playlists) { + final match = regexp.firstMatch(el.name); + if (match != null) { + final capturedNumber = match.group(2); + final number = capturedNumber == null ? 0 : int.tryParse(capturedNumber); + if (number != null && (max == null || max < number)) { + max = number; + } + } + } + if (max != null) { + name = '$name (${max + 1})'; + } + } + + return name; + } + + /// Creates a playlist with a given name and returns a corrected with [correctPlaylistName] name. + static Future createPlaylist(String name) async { + name = await correctPlaylistName(name); + await ContentChannel.createPlaylist(name); + await refetchSongsAndPlaylists(); + return name; + } + + /// Renames a playlist and: + /// * if operation was successful returns a corrected with [correctPlaylistName] name + /// * else returns null + static Future renamePlaylist(Playlist playlist, String name) async { + try { + name = await correctPlaylistName(name); + await ContentChannel.renamePlaylist(playlist, name); + await refetchSongsAndPlaylists(); + return name; + } on ContentChannelException catch(ex) { + if (ex == ContentChannelException.playlistNotExists) + return null; + rethrow; + } + } + + /// Inserts songs in the playlist at the given [index]. + static Future insertSongsInPlaylist({ required int index, required List songs, required Playlist playlist }) async { + await ContentChannel.insertSongsInPlaylist(index: index, songs: songs, playlist: playlist); + await refetchSongsAndPlaylists(); + } + + /// Moves song in playlist, returned value indicates whether the operation was successful. + static Future moveSongInPlaylist({ required Playlist playlist, required int from, required int to, bool emitChangeEvent = true }) async { + if (from != to) { + await ContentChannel.moveSongInPlaylist(playlist: playlist, from: from, to: to); + if (emitChangeEvent) + await refetchSongsAndPlaylists(); + } + } + + /// Removes songs from playlist at given [indexes]. + static Future removeFromPlaylistAt({ required List indexes, required Playlist playlist }) async { + await ContentChannel.removeFromPlaylistAt(indexes: indexes, playlist: playlist); + await refetchSongsAndPlaylists(); + } + + /// Deletes playlists. + static Future deletePlaylists(List playlists) async { + try { + await ContentChannel.removePlaylists(playlists); + await refetchSongsAndPlaylists(); + } catch (ex, stack) { + FirebaseCrashlytics.instance.recordError( + ex, + stack, + reason: 'in deletePlaylists', + ); + ShowFunctions.instance.showToast( + msg: staticl10n.deletionError, + ); + debugPrint('deletePlaylists error: $ex'); } } @@ -1119,86 +1395,92 @@ abstract class ContentControl { /// Restores [sorts] from [Prefs]. static Future _restoreSorts() async { - state.sorts._map = { - Song: SongSort.fromJson(jsonDecode(await Prefs.songSortString.get())), - Album: AlbumSort.fromJson(jsonDecode(await Prefs.albumSortString.get())), - }; + state.sorts = ContentMap({ + Song: Prefs.songSort.get(), + Album: Prefs.albumSort.get(), + Playlist: Prefs.playlistSort.get(), + Artist: Prefs.artistSort.get(), + }); } /// Restores saved queues. /// /// * If stored queue becomes empty after restoration (songs do not exist anymore), will fall back to not modified [QueueType.all]. - /// * If saved persistent queue songs are restored successfully, but the playlist itself cannot be found, will fall back to [QueueType.arbitrary]. + /// * If saved song origin songs are restored successfully, but the playlist itself cannot be found, will fall back to [QueueType.arbitrary]. /// * In all other cases it will restore as it was. static Future _restoreQueue() async { - final shuffled = await Prefs.queueShuffledBool.get(); - final modified = await Prefs.queueModifiedBool.get(); - final persistentQueueId = await Prefs.persistentQueueId.get(); - final type = EnumToString.fromString( - QueueType.values, - await Prefs.queueTypeString.get(), - )!; + final shuffled = Prefs.queueShuffled.get(); + final modified = Prefs.queueModified.get(); + final songOrigin = Prefs.songOrigin.get(); + final type = Prefs.queueType.get(); + state.idMap = await _idMapSerializer.read(); final List queueSongs = []; - final rawQueue = await state.queues._queueSerializer.read(); - for (final item in rawQueue) { - final id = item['id']; - var song = state.allSongs.byId.get(Song.getSourceId(id)); - if (song != null) { - song = song.copyWith(id: id); - final origin = item['origin_type']; - if (origin != null) { - if (origin == 'album') { - song.origin = state.albums[item['origin_id']]; - } else { - assert(false); - } + try { + final rawQueue = await state.queues._queueSerializer.read(); + for (final item in rawQueue) { + final id = item.id; + final origin = SongOrigin.originFromEntry(item.originEntry); + var song = state.allSongs.byId.get(ContentUtils.getSourceId(id, origin: origin)); + if (song != null) { + song = song.copyWith(id: id); + song.duplicationIndex = item.duplicationIndex; + song.origin = origin; + queueSongs.add(song); } - queueSongs.add(song); } + } catch(ex, stack) { + FirebaseCrashlytics.instance.recordError( + ex, + stack, + reason: 'in rawQueue restoration', + ); } final List shuffledSongs = []; - if (shuffled == true) { - final rawShuffledQueue = await state.queues._shuffledSerializer.read(); - for (final item in rawShuffledQueue) { - final id = item['id']; - var song = state.allSongs.byId.get(Song.getSourceId(id)); - if (song != null) { - song = song.copyWith(id: id); - final origin = item['origin_type']; - if (origin != null) { - if (origin == 'album') { - song.origin = state.albums[item['origin_id']]; - } else { - assert(false); - } + try { + if (shuffled == true) { + final rawShuffledQueue = await state.queues._shuffledSerializer.read(); + for (final item in rawShuffledQueue) { + final id = item.id; + final origin = SongOrigin.originFromEntry(item.originEntry); + var song = state.allSongs.byId.get(ContentUtils.getSourceId(id, origin: origin)); + if (song != null) { + song = song.copyWith(id: id); + song.duplicationIndex = item.duplicationIndex; + song.origin = origin; + shuffledSongs.add(song); } - shuffledSongs.add(song); } } + } catch(ex, stack) { + FirebaseCrashlytics.instance.recordError( + ex, + stack, + reason: 'in rawShuffledQueue restoration', + ); } final songs = shuffled && shuffledSongs.isNotEmpty ? shuffledSongs : queueSongs; if (songs.isEmpty) { setQueue( - type: QueueType.all, + type: QueueType.allSongs, modified: false, // we must save it, so do not `save: false` ); - } else if (type == QueueType.persistent) { - if (persistentQueueId != null && - state.albums[persistentQueueId] != null) { + } else if (type == QueueType.origin) { + if (songOrigin != null) { setQueue( type: type, modified: modified, shuffled: shuffled, songs: songs, shuffleFrom: queueSongs, - persistentQueue: state.albums[persistentQueueId], + origin: songOrigin, save: false, + setIdMapFromPlaylist: false, ); } else { setQueue( @@ -1210,22 +1492,295 @@ abstract class ContentControl { ); } } else { - final arbitraryQueueOrigin = await Prefs.arbitraryQueueOrigin.get(); setQueue( type: type, shuffled: shuffled, modified: modified, songs: songs, shuffleFrom: queueSongs, - searchQuery: await Prefs.searchQueryString.get(), - arbitraryQueueOrigin: arbitraryQueueOrigin == null - ? null - : EnumToString.fromString( - ArbitraryQueueOrigin.values, - arbitraryQueueOrigin, - ), + searchQuery: Prefs.searchQuery.get(), save: false, ); } } } + + +class ContentUtils { + /// Android unknown artist. + static const unknownArtist = ''; + + /// If artist is unknown returns localized artist. + /// Otherwise returns artist as is. + static String localizedArtist(String artist, AppLocalizations l10n) { + return artist != unknownArtist ? artist : l10n.artistUnknown; + } + + static const String dot = '•'; + + /// Joins list with the [dot]. + static String joinDot(List list) { + if (list.isEmpty) + return ''; + var result = list.first; + for (int i = 1; i < list.length; i++) { + final string = list[i].toString(); + if (string.isNotEmpty) { + result += ' $dot $string'; + } + } + return result; + } + + /// Appends dot and year to [string]. + static String appendYearWithDot(String string, int year) { + return '$string $dot $year'; + } + + /// Checks whether a [Song] is currently playing. + /// Compares by [Song.sourceId]. + static bool songIsCurrent(Song song) { + return song.sourceId == ContentControl.state.currentSong.sourceId; + } + + /// Checks whether a song origin is currently playing. + static bool originIsCurrent(SongOrigin origin) { + final queues = ContentControl.state.queues; + return queues.type == QueueType.origin && origin == queues.origin || + queues.type != QueueType.origin && origin == ContentControl.state.currentSongOrigin; + } + + /// Returns a default icon for a [PersistentQueue]. + static IconData persistentQueueIcon(PersistentQueue queue) { + return persistentQueuePick( + contentType: queue.runtimeType, + album: Album.icon, + playlist: Playlist.icon, + ); + } + + /// Computes the duration of mulitple [songs] and returs it as formatted string. + static String bulkDuration(List songs) { + final duration = Duration(milliseconds: songs.fold(0, (prev, el) => prev + el.duration)); + final hours = duration.inHours; + final minutes = duration.inMinutes % 60; + final seconds = duration.inSeconds % 60; + final buffer = StringBuffer(); + if (hours > 0) { + if (hours.toString().length < 2) { + buffer.write(0); + } + buffer.write(hours); + buffer.write(':'); + } + if (minutes > 0) { + if (minutes.toString().length < 2) { + buffer.write(0); + } + buffer.write(minutes); + buffer.write(':'); + } + if (seconds > 0) { + if (seconds.toString().length < 2) { + buffer.write(0); + } + buffer.write(seconds); + } + return buffer.toString(); + } + + /// Joins and returns a list of all songs of specified [origins] list. + static List joinSongOrigins(List origins) { + final List songs = []; + for (final origin in origins) { + for (final song in origin.songs) { + song.origin = origin; + songs.add(song); + } + } + return songs; + } + + /// Joins specified [origins] list and returns a list of all songs and a + /// shuffled variant of it. + static ShuffleResult shuffleSongOrigins(List origins) { + final List songs = joinSongOrigins(origins); + final List shuffledSongs = []; + for (final origin in List.from(origins)..shuffle()) { + for (final song in origin.songs) { + song.origin = origin; + shuffledSongs.add(song); + } + } + return ShuffleResult( + songs, + shuffledSongs, + ); + } + + /// Accepts a collection of content, exctracts songs from each entry + /// and returns a one flattened array of songs. + static List flatten(List collection) { + final List songs = []; + for (final content in collection) { + if (content is Song) { + songs.add(content); + } else if (content is Album) { + songs.addAll(content.songs); + } else if (content is Playlist) { + songs.addAll(content.songs); + } else if (content is Artist) { + songs.addAll(content.songs); + } else { + throw UnimplementedError(); + } + assert(() { + contentPick( + song: null, + album: null, + playlist: null, + artist: null, + ); + return true; + }()); + } + return songs; + } + + /// Receives a selection data set, extracts all types of contents, + /// sorts them by index in ascending order and returns the result. + /// + /// See also discussion in [SelectionEntry]. + static SortAndPackResult selectionSortAndPack(Set> data) { + final List> songs = []; + final List> albums = []; + final List> playlists = []; + final List> artists = []; + for (final entry in data) { + if (entry is SelectionEntry) { + songs.add(entry); + } else if (entry is SelectionEntry) { + albums.add(entry); + } else if (entry is SelectionEntry) { + playlists.add(entry); + } else if (entry is SelectionEntry) { + artists.add(entry); + } else { + throw UnimplementedError(); + } + } + assert(() { + contentPick( + song: null, + album: null, + playlist: null, + artist: null, + ); + return true; + }()); + songs.sort((a, b) => a.index.compareTo(b.index)); + albums.sort((a, b) => a.index.compareTo(b.index)); + playlists.sort((a, b) => a.index.compareTo(b.index)); + artists.sort((a, b) => a.index.compareTo(b.index)); + return SortAndPackResult( + songs.map((el) => el.data).toList(), + albums.map((el) => el.data).toList(), + playlists.map((el) => el.data).toList(), + artists.map((el) => el.data).toList(), + ); + } + + /// Returns the source song ID based of the provided id map. + /// + /// If [idMap] is null, [ContentState.idMap] will be used. + static int getSourceId(int id, {required SongOrigin? origin, IdMap? idMap}) { + return id < 0 + ? (idMap ?? ContentControl.state.idMap)[IdMapKey(id: id, originEntry: origin?.toSongOriginEntry())]! + : id; + } + + /// Checks the [song] for being a duplicate within the [origin], and if + /// it is, changes its ID and saves the mapping to the original source ID to + /// an [idMap]. + /// + /// The [index] should be non-null if the [origin] is [DuplicatingSongOriginMixin], which create their own id map. + /// This is needed to later distinguish this key within the [ContentState.idMap]. + /// See the [PersistentQueueRoute] `currentTest` condition for example. + /// If [index] is null, the function will try to set an automatic index. + /// + /// The [list] is the list of songs contained in this origin. + /// + /// This must be called before the song is inserted to the queue, otherwise + /// the song might be conidiered as a duplicate of itself, which will be incorrect. + /// The function asserts that. + /// + /// Marks the queue as dirty, so the next [setQueue] will save it. + /// + /// The returned value indicates whether the duplicate song was found and + /// [source] was changed. + static bool deduplicateSong({ + required Song song, + required int? index, + required List list, + required IdMap idMap, + }) { + assert(() { + final sourceSong = ContentControl.state.allSongs.byId.get(song.sourceId); + if (identical(sourceSong, song)) { + throw ArgumentError( + "Tried to handle duplicate on the source song in `allSongs`. This may lead " + "to that the source song ID is lost, copy the song first", + ); + } + return true; + }()); + assert(() { + final sameSong = list.firstWhereOrNull((el) => identical(el, song)); + if (identical(sameSong, song)) { + throw ArgumentError( + "The provided `song` is contained in the given `list`. This is incorrect " + "usage of this function, it should be called before the song is inserted to " + "the `list`", + ); + } + return true; + }()); + final candidates = list.where((el) => el.id == song.id); + if (candidates.isNotEmpty) { + final map = idMap; + final newId = -(map.length + 1); + map[IdMapKey( + id: newId, + originEntry: song.origin?.toSongOriginEntry(), + )] = song.sourceId; + song.id = newId; + return true; + } + return false; + } +} + +/// Result of [ContentUtils.shuffleSongOrigins]. +class ShuffleResult { + const ShuffleResult(this.songs, this.shuffledSongs); + final List songs; + final List shuffledSongs; +} + + +/// Result of [ContentUtils.selectionSortAndPack]. +class SortAndPackResult { + final List songs; + final List albums; + final List playlists; + final List artists; + + SortAndPackResult(this.songs, this.albums, this.playlists, this.artists); + + List get merged => [ + ...songs, + ...albums, + ...playlists, + ...artists, + ]; +} \ No newline at end of file diff --git a/lib/logic/player/content_channel.dart b/lib/logic/player/content_channel.dart new file mode 100644 index 000000000..dd23a32ce --- /dev/null +++ b/lib/logic/player/content_channel.dart @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'dart:typed_data'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:sweyer/sweyer.dart'; +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); + +/// Class to cancel the [ContentChannel.loadAlbumArt]. +class CancellationSignal { + CancellationSignal() : _id = _uuid.v4(); + final String _id; + + /// Cancel loading of an album art. + Future cancel() { + return ContentChannel._channel.invokeMethod( + 'cancelAlbumArtLoading', + {'id': _id} + ); + } +} + +class ContentChannelException extends Enum with EquatableMixin { + const ContentChannelException._(String value, [this._exception]) : super(value); + + PlatformException get exception => _exception!; + // TODO: https://github.com/dart-lang/linter/issues/2718 + // ignore: use_late_for_private_fields_and_variables + final PlatformException? _exception; + + @override + List get props => [value]; + + /// Generic error. + static const unexpected = ContentChannelException._('UNEXPECTED_ERROR'); + + /// On Android 30 requets like `MediaStore.createDeletionRequest` require + /// calling `startIntentSenderForResult`, which might throw this exception. + static const intentSender = ContentChannelException._('INTENT_SENDER_ERROR'); + + static const io = ContentChannelException._('IO_ERROR'); + + /// API is unavailable on current SDK level. + static const sdk = ContentChannelException._('SDK_ERROR'); + + /// Operation cannot be performed because there's no such playlist + static const playlistNotExists = ContentChannelException._('PLAYLIST_NOT_EXISTS_ERROR'); + + static ContentChannelException _throw(PlatformException exception, List expectedExceptions) { + final toThrow = ContentChannelException._(exception.code, exception); + assert(toThrow == unexpected || expectedExceptions.contains(toThrow)); + return toThrow; + } + + @override + String toString() => _exception.toString(); +} + +/// Communication bridge with the platform for all content-related methods. +/// +/// Methods can thow various [ContentChannelException]s. +abstract class ContentChannel { + static const MethodChannel _channel = MethodChannel('content_channel'); + + /// Loads album art on Android Q (SDK 29) and above. + /// Calling this on versions below with throw. + /// + /// Can return `null` in case operation is cancelled before fetching if finished. + /// + /// Throws: + /// * [ContentChannelException.io] when art fails to load + /// * [ContentChannelException.sdk] when it's called below Android 29 + static Future loadAlbumArt({ required String uri, required Size size, required CancellationSignal signal }) async { + try { + return await _channel.invokeMethod( + 'loadAlbumArt', + { + 'id': signal._id, + 'uri': uri, + 'width': size.width.toInt(), + 'height': size.height.toInt(), + }, + ); + } on PlatformException catch(ex) { + throw ContentChannelException._throw(ex, const [ContentChannelException.io, ContentChannelException.sdk]); + } + } + + /// Tries to tell system to recreate album art by [albumId]. + /// + /// Sometimes `MediaStore` tells that there's an albumart for some song, but the actual file + /// by some path doesn't exist. Supposedly, what happens is that Android detects reads on this + /// entry from something like `InputStream` in Java and regenerates albumthumbs if they do not exist + /// (because this is just a caches that system tends to clear out if it thinks it should), + /// but (yet again, supposedly) this is not the case when Flutter accesses this file. System cannot + /// detect it and does not recreate the thumb, so we do this instead. + /// + /// See https://stackoverflow.com/questions/18606007/android-image-files-deleted-from-com-android-providers-media-albumthumbs-on-rebo + static Future fixAlbumArt(int albumId) async { + return _channel.invokeMethod( + 'fixAlbumArt', + {'id': albumId}, + ); + } + + static Future> retrieveSongs() async { + final maps = await _channel.invokeListMethod('retrieveSongs'); + return maps!.map((el) => Song.fromMap(el)).toList(); + } + + static Future> retrieveAlbums() async { + final maps = await _channel.invokeListMethod('retrieveAlbums'); + final Map albums = {}; + for (final map in maps!) { + albums[map['id'] as int] = Album.fromMap(map); + } + return albums; + } + + static Future> retrievePlaylists() async { + final maps = await _channel.invokeListMethod('retrievePlaylists'); + return maps!.map((el) => Playlist.fromMap(el)).toList(); + } + + static Future> retrieveArtists() async { + final maps = await _channel.invokeListMethod('retrieveArtists'); + return maps!.map((el) => Artist.fromMap(el)).toList(); + } + + static Future> retrieveGenres() async { + final maps = await _channel.invokeListMethod('retrieveGenres'); + return maps!.map((el) => Genre.fromMap(el)).toList(); + } + + /// Sets songs' favorite flag to [value]. + /// + /// The returned value indicates the success of the operation. + /// + /// Throws: + /// * [ContentChannelException.sdk] when it's called below Android 30 + /// * [ContentChannelException.intentSender] + static Future setSongsFavorite(Set songs, bool value) async { + try { + final res = await _channel.invokeMethod( + 'setSongsFavorite', + { + 'songs': songs.map((song) => song.toMap()).toList(), + 'value': value, + }, + ); + return res!; + } on PlatformException catch(ex) { + throw ContentChannelException._throw(ex, const [ContentChannelException.sdk, ContentChannelException.intentSender]); + } + } + + /// Deletes a set of songs. + /// + /// The returned value indicates the success of the operation. + /// + /// Throws: + /// * [ContentChannelException.intentSender] + static Future deleteSongs(Set songs) async { + try { + final res = await _channel.invokeMethod( + 'deleteSongs', + {'songs': songs.map((song) => song.toMap()).toList()}, + ); + return res!; + } on PlatformException catch(ex) { + throw ContentChannelException._throw(ex, const [ContentChannelException.intentSender]); + } + } + + static Future createPlaylist(String name) async { + try { + return await _channel.invokeMethod('createPlaylist', {'name': name}); + } on PlatformException catch(ex) { + throw ContentChannelException._throw(ex, const []); + } + } + + /// Throws: + /// * [ContentChannelException.playlistNotExists] when playlist doesn't exist. + static Future renamePlaylist(Playlist playlist, String name) async { + try { + return await _channel.invokeMethod( + 'renamePlaylist', + { + 'id': playlist.id, + 'name': name, + }, + ); + } on PlatformException catch(ex) { + throw ContentChannelException._throw(ex, const [ContentChannelException.playlistNotExists]); + } + } + + static Future removePlaylists(List playlists) async { + return _channel.invokeMethod('removePlaylists', {'ids': playlists.map((el) => el.id).toList()}); + } + + /// Throws: + /// * [ContentChannelException.playlistNotExists] when playlist doesn't exist. + static Future insertSongsInPlaylist({ required int index, required List songs, required Playlist playlist }) async { + assert(songs.isNotEmpty); + assert(index >= 0 && index <= playlist.songIds.length + 1); + try { + return await _channel.invokeMethod( + 'insertSongsInPlaylist', + { + 'id': playlist.id, + 'index': index, + 'songIds': songs.map((el) => el.sourceId).toList(), + }, + ); + } on PlatformException catch(ex) { + throw ContentChannelException._throw(ex, const [ContentChannelException.playlistNotExists]); + } + } + + /// Moves song in playlist, returned value indicates whether the operation was successful. + static Future moveSongInPlaylist({ required Playlist playlist, required int from, required int to }) async { + assert(from >= 0); + assert(to >= 0); + assert(from != to); + final res = await _channel.invokeMethod( + 'moveSongInPlaylist', + { + 'id': playlist.id, + 'from': from, + 'to': to, + }, + ); + return res!; + } + + /// Throws: + /// * [ContentChannelException.playlistNotExists] when playlist doesn't exist. + static Future removeFromPlaylistAt({ required List indexes, required Playlist playlist }) async { + assert(indexes.isNotEmpty); + try { + return await _channel.invokeMethod( + 'removeFromPlaylistAt', + { + 'id': playlist.id, + 'indexes': indexes, + }, + ); + } on PlatformException catch(ex) { + throw ContentChannelException._throw(ex, const [ContentChannelException.playlistNotExists]); + } + } + + /// Checks if open intent is view (user tried to open file with app). + static Future isIntentActionView() async { + final res = await _channel.invokeMethod('isIntentActionView'); + return res!; + } +} diff --git a/lib/logic/player/favorites.dart b/lib/logic/player/favorites.dart new file mode 100644 index 000000000..be7edf9f2 --- /dev/null +++ b/lib/logic/player/favorites.dart @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:sweyer/sweyer.dart'; + +// class FavoriteRepository { +// FavoriteRepository._(); +// static final instance = FavoriteRepository._(); + +// final _serializers = ContentMap(); + +// @override +// Future markFavorite(bool value) async { +// assert(T != Content); +// _serializers.getValue(); +// } +// } + +// class _FavoriteBatch { +// bool committed = false; + +// void commit() { +// assert(!committed); +// committed = true; +// } + +// void markFavorite(bool value) { +// assert(!committed); +// } +// } \ No newline at end of file diff --git a/lib/logic/player/music_player.dart b/lib/logic/player/music_player.dart index 00e18443b..54b1653f8 100644 --- a/lib/logic/player/music_player.dart +++ b/lib/logic/player/music_player.dart @@ -3,6 +3,8 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ +export 'backend.dart'; +export 'content_channel.dart'; export 'content.dart'; export 'queue.dart'; export 'serialization.dart'; @@ -10,26 +12,175 @@ export 'serialization.dart'; import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:just_audio/just_audio.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; import 'package:rxdart/rxdart.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:sweyer/sweyer.dart'; +/// Represents the path in browsed library. +/// +/// There may be: +/// * nested paths, like [albums] - which can have an [id] +/// * and not nested paths - like [songs], which just displays all songs class _BrowseParent extends Enum { - const _BrowseParent(String value, [this.id]) : super(value); + const _BrowseParent(String value, {this.id, required this.nested}) : super(value); + + final bool nested; /// This property is non-null for entries that have id, for currently only for albums. - final int id; + final int? id; bool get hasId => id != null; - static const root = _BrowseParent('root'); - static const tracks = _BrowseParent('tracks'); - static const albums = _BrowseParent('albums'); + /// Shows all other parents. + static const root = _BrowseParent('root', nested: false); + + /// Path for all songs. + static const songs = _BrowseParent('songs', nested: false); + + /// Path for all albums. + static const albums = _BrowseParent('albums', nested: true); + + /// Path for all playlists. + static const playlists = _BrowseParent('playlists', nested: true); + + /// Path for all artists. + static const artists = _BrowseParent('artists', nested: true); _BrowseParent withId(int id) { - assert(this == albums, 'This parent cannot have id'); - return _BrowseParent(value, id); + assert(nested, "This parent cannot have and id, because it's not nested"); + return _BrowseParent(value, id: id, nested: false); + } +} + +class _BrowserParentProvider { + /// Current parent. + _BrowseParent _parent = _BrowseParent.root; + _BrowseParent get parent => _parent; + + /// Handles [AudioHandler.getChildren] call. + List handleGetChildren(String parentMediaId) { + final id = int.tryParse(parentMediaId); + if (id != null) { + _parent = _parent.withId(id); + } else { + switch (parentMediaId) { + case 'root': + _parent = _BrowseParent.root; break; + case 'songs': + _parent = _BrowseParent.songs; break; + case 'albums': + _parent = _BrowseParent.albums; break; + case 'playlists': + _parent = _BrowseParent.playlists; break; + case 'artists': + _parent = _BrowseParent.artists; break; + } + } + + switch (parentMediaId) { + case AudioService.recentRootId: + return ContentControl.state.queues.current + .songs + .map((song) => song.toMediaItem()) + .toList(); + case 'root': + return [ + MediaItem( + id: _BrowseParent.songs.value, + album: '', + title: staticl10n.contents(), + playable: false, + ), + MediaItem( + id: _BrowseParent.albums.value, + album: '', + title: staticl10n.contents(), + playable: false, + ), + MediaItem( + id: _BrowseParent.playlists.value, + album: '', + title: staticl10n.contents(), + playable: false, + ), + MediaItem( + id: _BrowseParent.artists.value, + album: '', + title: staticl10n.contents(), + playable: false, + ), + ]; + case 'songs': + return ContentControl.getContent() + .map((el) => el.toMediaItem()) + .toList(); + case 'albums': + return ContentControl.getContent() + .map((el) => el.toMediaItem()) + .toList(); + case 'playlists': + return ContentControl.getContent() + .map((el) => el.toMediaItem()) + .toList(); + case 'artists': + return ContentControl.getContent() + .map((el) => el.toMediaItem()) + .toList(); + default: + if (id == null) { + throw StateError(''); + } + switch (_parent.value) { + case 'albums': + return ContentControl.getContentById(id) + !.songs + .map((song) => song.toMediaItem()) + .toList(); + case 'playlists': + return ContentControl.getContentById(id) + !.songs + .map((song) => song.toMediaItem()) + .toList(); + case 'artists': + return ContentControl.getContentById(id) + !.songs + .map((song) => song.toMediaItem()) + .toList(); + default: + throw UnimplementedError(); + } + } + } + + /// Updates the queue. + /// Should be called when media item changes. + void handleMediaItemChange() { + if (!parent.hasId) { + ContentControl.resetQueue(); + } else { + switch (parent.value) { + case 'albums': + final album = ContentControl.getContentById(parent.id!); + if (album != null) { + ContentControl.setOriginQueue(origin: album, songs: album.songs); + } + break; + case 'playlists': + final playlist = ContentControl.getContentById(parent.id!); + if (playlist != null) { + ContentControl.setOriginQueue(origin: playlist, songs: playlist.songs); + } + break; + case 'artists': + final artist = ContentControl.getContentById(parent.id!); + if (artist != null) { + ContentControl.setOriginQueue(origin: artist, songs: artist.songs); + } + break; + default: + throw UnimplementedError(); + } + } } } @@ -39,16 +190,17 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs } bool _disposed = false; + bool _running = false; MusicPlayer player = MusicPlayer.instance; final BehaviorSubject contentChangeSubject = BehaviorSubject(); Future _init() async { - WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance!.addObserver(this); - DateTime _lastEvent; + DateTime? _lastEvent; player.positionStream.listen((event) { final now = DateTime.now(); - if (_lastEvent == null || now.difference(_lastEvent) > const Duration(milliseconds: 1000)) { + if (_lastEvent == null || now.difference(_lastEvent!) > const Duration(milliseconds: 1000)) { _lastEvent = now; _setState(); } @@ -56,6 +208,8 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs player.playingStream.listen((playing) { _setState(); _lastEvent = DateTime.now(); + if (playing) + _running = true; }); player.loopingStream.listen((event) => _setState()); ContentControl.state.onSongChange.listen((song) { @@ -74,33 +228,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs void dispose() { _disposed = true; - WidgetsBinding.instance.removeObserver(this); - } - - - T _parentWithIdPick({ @required T albums }) { - assert(parent.hasId); - switch(parent.value) { - case 'albums': - return albums; - default: - throw UnimplementedError(); - } - } - - void _handleMediaItemChange() { - if (!parent.hasId) { - ContentControl.resetQueue(); - } else { - _parentWithIdPick( - albums: () { - final album = ContentControl.state.albums[parent.id]; - if (album != null) { - ContentControl.setPersistentQueue(queue: album, songs: album.songs); - } - }, - )(); - } + WidgetsBinding.instance!.removeObserver(this); } @override @@ -109,8 +237,8 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs } @override - Future prepareFromMediaId(String mediaId, [Map extras]) async { - _handleMediaItemChange(); + Future prepareFromMediaId(String mediaId, [Map? extras]) async { + parentProvider.handleMediaItemChange(); final song = ContentControl.state.allSongs.byId.get(int.parse(mediaId)); if (song != null) { await player.setSong(song); @@ -118,7 +246,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs } @override - Future prepareFromSearch(String query, [Map extras]) async { + Future prepareFromSearch(String query, [Map? extras]) async { final songs = ContentControl.search(query); if (songs.isNotEmpty) { ContentControl.setSearchedQueue(query, songs); @@ -127,7 +255,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs } @override - Future prepareFromUri(Uri uri, [Map extras]) { + Future prepareFromUri(Uri uri, [Map? extras]) { // TODO: implement prepareFromUri throw UnimplementedError(); return super.prepareFromUri(uri, extras); @@ -139,8 +267,8 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs } @override - Future playFromMediaId(String mediaId, [Map extras]) async { - _handleMediaItemChange(); + Future playFromMediaId(String mediaId, [Map? extras]) async { + parentProvider.handleMediaItemChange(); final song = ContentControl.state.allSongs.byId.get(int.parse(mediaId)); if (song != null) { await player.setSong(song); @@ -149,7 +277,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs } @override - Future playFromSearch(String query, [Map extras]) async { + Future playFromSearch(String query, [Map? extras]) async { final songs = ContentControl.search(query); if (songs.isNotEmpty) { ContentControl.setSearchedQueue(query, songs); @@ -159,7 +287,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs } @override - Future playFromUri(Uri uri, [Map extras]) { + Future playFromUri(Uri uri, [Map? extras]) { // TODO: implement playFromUri throw UnimplementedError(); return super.playFromUri(uri, extras); @@ -177,7 +305,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs @override Future pause() => player.pause(); - Timer hookTimer; + Timer? hookTimer; int hookPressedCount = 0; @override @@ -207,6 +335,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs @override Future stop() async { + _running = false; // TODO: currently stop seeks to the beginning, use stop when https://github.com/ryanheise/just_audio/issues/366 is resolved // await player.stop(); await player.pause(); @@ -224,7 +353,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs @override Future addQueueItems(List mediaItems) async { final songs = mediaItems.map((mediaItem) { - return ContentControl.state.allSongs.byId.get(int.parse(mediaItem.id)); + return ContentControl.state.allSongs.byId.get(int.parse(mediaItem.id))!; }); if (songs.isNotEmpty) { ContentControl.addToQueue(songs.toList()); @@ -235,7 +364,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs Future insertQueueItem(int index, MediaItem mediaItem) async { final song = ContentControl.state.allSongs.byId.get(int.parse(mediaItem.id)); if (song != null) { - ContentControl.insertToQueue(index, song); + ContentControl.insertToQueue(index, [song]); } } @@ -243,10 +372,11 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs Future updateQueue(List queue) async { if (queue.isNotEmpty) { final songs = queue.map((el) { - return ContentControl.state.allSongs.byId.get(int.parse(el.id)); + return ContentControl.state.allSongs.byId.get(int.parse(el.id))!; }).toList(); ContentControl.setQueue( type: QueueType.arbitrary, + shuffled: false, songs: songs, ); if (!songs.contains(ContentControl.state.currentSong)) { @@ -265,7 +395,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs @override Future removeQueueItem(MediaItem mediaItem) async { ContentControl.removeFromQueue( - ContentControl.state.allSongs.byId.get(int.parse(mediaItem.id)), + ContentControl.state.allSongs.byId.get(int.parse(mediaItem.id))!, ); } @@ -294,13 +424,13 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs } @override - Future seek(Duration position) { + Future seek(Duration position) async { + await player.seek(position); _setState(); - return player.seek(position); } @override - Future setRating(Rating rating, Map extras) { + Future setRating(Rating rating, Map? extras) { // TODO: implement setRating throw UnimplementedError(); return super.setRating(rating, extras); @@ -335,67 +465,11 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs return player.setSpeed(speed); } - /// Current parent. - _BrowseParent parent = _BrowseParent.root; + final parentProvider = _BrowserParentProvider(); @override - Future> getChildren(String parentMediaId, [Map options]) async { - if (parentMediaId != AudioService.recentRootId) { - final id = int.tryParse(parentMediaId); - if (id != null) { - parent = _BrowseParent.albums.withId(id); - } else { - switch (parentMediaId) { - case AudioService.browsableRootId: - parent = _BrowseParent.root; - break; - case 'tracks': - parent = _BrowseParent.tracks; - break; - case 'albums': - parent = _BrowseParent.albums; - break; - default: - throw ArgumentError(); - } - } - } - switch (parentMediaId) { - case AudioService.recentRootId: - return ContentControl.state.queues.current - .songs - .map((song) => song.toMediaItem()) - .toList(); - case AudioService.browsableRootId: - return [ - MediaItem( - id: 'tracks', - album: '', - title: staticl10n.tracks, - playable: false, - ), - MediaItem( - id: 'albums', - album: '', - title: staticl10n.albums, - playable: false, - ), - ]; - case 'tracks': - return ContentControl.state.allSongs - .songs - .map((song) => song.toMediaItem()) - .toList(); - case 'albums': - return ContentControl.state.albums.values - .map((album) => album.toMediaItem()) - .toList(); - default: - return ContentControl.state.albums[int.parse(parentMediaId)] - .songs - .map((song) => song.toMediaItem()) - .toList(); - } + Future> getChildren(String parentMediaId, [Map? options]) async { + return parentProvider.handleGetChildren(parentMediaId); } @override @@ -410,11 +484,11 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs @override Future getMediaItem(String mediaId) async { - return ContentControl.state.allSongs.byId.get(int.parse(mediaId)).toMediaItem(); + return ContentControl.state.allSongs.byId.get(int.parse(mediaId))!.toMediaItem(); } @override - Future> search(String query, [Map extras]) async { + Future> search(String query, [Map? extras]) async { return ContentControl.search(query) .map((song) => song.toMediaItem()) .toList(); @@ -429,12 +503,13 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs case 'pause': case 'play': return player.playPause(); case 'play_next': return player.playNext(); - case 'stop': stop(); + case 'stop': return stop(); + default: throw UnimplementedError(); } } @override - Future didChangeLocales(List locales) async { + Future didChangeLocales(List? locales) async { await AppLocalizations.init(); mediaItem.add(ContentControl.state.currentSong.toMediaItem()); queue.add(ContentControl.state.queues.current @@ -451,14 +526,14 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs /// Broadcasts the current state to all clients. void _setState() { - if (_disposed) + if (_disposed || !_running) return; final playing = player.playing; final l10n = staticl10n; - final color = WidgetsBinding.instance.window.platformBrightness == Brightness.dark + final color = WidgetsBinding.instance!.window.platformBrightness == Brightness.dark ? 'white' : 'black'; - playbackState.add(playbackState.value.copyWith( + playbackState.add(playbackState.value!.copyWith( controls: [ // TODO: currently using custom API from my fork, see https://github.com/ryanheise/audio_service/issues/633 if (player.looping) @@ -542,7 +617,7 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs ProcessingState.completed: AudioProcessingState.completed, }[ // Excluding idle state because it makes notification to reappear. player.processingState == ProcessingState.idle - ? AudioProcessingState.loading + ? ProcessingState.loading : player.processingState ], playing: playing, @@ -557,18 +632,18 @@ class _AudioHandler extends BaseAudioHandler with SeekHandler, WidgetsBindingObs /// Player that plays the content provided by provided [ContentControl]. class MusicPlayer extends AudioPlayer { MusicPlayer._(); - static MusicPlayer _instance; + static MusicPlayer? _instance; static MusicPlayer get instance { return _instance ??= MusicPlayer._(); } - static _AudioHandler _handler; + static _AudioHandler? _handler; /// Updates service state media item. void updateServiceMediaItem() { final song = ContentControl.state.currentSongNullable; - if (song != null) { - _handler.mediaItem.add(song.toMediaItem()); + if (song != null && _handler!._running) { + _handler!.mediaItem.add(song.toMediaItem()); } } @@ -605,13 +680,13 @@ class MusicPlayer extends AudioPlayer { }); positionStream.listen((position) { - Prefs.songPositionInt.set(position.inSeconds); + Prefs.songPosition.set(position.inSeconds); }); // Restore from prefs. await Future.wait([ - seek(Duration(seconds: await Prefs.songPositionInt.get())), - setLoopMode(await Prefs.loopModeBool.get() + seek(Duration(seconds: Prefs.songPosition.get())), + setLoopMode(Prefs.loopMode.get() ? LoopMode.one : LoopMode.off), ]); @@ -631,10 +706,13 @@ class MusicPlayer extends AudioPlayer { final current = ContentControl.state.queues.current; // songsEmpty condition is here to avoid errors when trying to get first song index if (current.isNotEmpty) { - final songId = await Prefs.songIdInt.get(); - await setSong(songId == null - ? current.songs[0] - : current.byId.get(songId) ?? current.songs[0]); + final songId = Prefs.songId.get(); + await setSong( + songId == null + ? current.songs[0] + : current.byId.get(songId) ?? current.songs[0], + fromBeginningWhenSame: false, + ); } } @@ -650,7 +728,7 @@ class MusicPlayer extends AudioPlayer { mode = LoopMode.off; } await super.setLoopMode(mode); - Prefs.loopModeBool.set(looping); + Prefs.loopMode.set(looping); } /// Switches the [looping]. @@ -658,31 +736,29 @@ class MusicPlayer extends AudioPlayer { return setLoopMode(looping ? LoopMode.off : LoopMode.one); } - static bool _hasDuplicates(Song song) { - final queues = ContentControl.state.queues; - return (queues.type != QueueType.all || queues.modified) && - queues.current.songs.where((el) => el.id == song.id).length > 1; - } - /// Prepare the [song] to be played. - Future setSong(Song song, { bool fromBeginning = false, bool duplicate }) async { + /// The song position is set to 0. + /// + /// Calling this function with the same song will just seek to the + /// beginning. To disable this [fromBeginningWhenSame] can be set to false. + Future setSong(Song? song, { bool fromBeginningWhenSame = true }) async { song ??= ContentControl.state.allSongs.songs[0]; + final previousCurrentSong = ContentControl.state.currentSongNullable; ContentControl.state.changeSong(song); + if (previousCurrentSong == ContentControl.state.currentSong) { + if (fromBeginningWhenSame) + await seek(Duration.zero); + return; + } try { - final _duplicate = duplicate ?? _hasDuplicates(song); - if (_duplicate && duplicate == null) { - ContentControl.handleDuplicate(song); - } - await setAudioSource( ProgressiveAudioSource(Uri.parse(song.contentUri)), - initialPosition: fromBeginning ? const Duration() : null, ); } catch (e) { if (e is PlayerInterruptedException) { - + // Do nothing } else if (e is PlayerException) { - final message = getl10n(AppRouter.instance.navigatorKey.currentContext).playbackErrorMessage; + final message = getl10n(AppRouter.instance.navigatorKey.currentContext!).playbackError; ShowFunctions.instance.showToast(msg: message); playNext(song: song); ContentControl.state.allSongs.remove(song); @@ -696,7 +772,7 @@ class MusicPlayer extends AudioPlayer { /// The [index] parameter is made no-op. @override - Future seek(Duration position, {void index}) async { + Future seek(Duration? position, {void index}) async { return super.seek(position); } @@ -709,80 +785,38 @@ class MusicPlayer extends AudioPlayer { } /// Plays the song after current, or if speceified, then after [song]. - Future playNext({ Song song }) async { + Future playNext({ Song? song }) async { song = ContentControl.state.queues.current.getNext( song ?? ContentControl.state.currentSong, ); if (song != null) { - await setSong(song, fromBeginning: true); + await setSong(song); await play(); } else { final songs = ContentControl.state.allSongs.songs; if (songs.isNotEmpty) { song = songs[0]; - await setSong(song, fromBeginning: true); + await setSong(song); await play(); } } } /// Plays the song before current, or if speceified, then before [song]. - Future playPrev({ Song song }) async { + Future playPrev({ Song? song }) async { song = ContentControl.state.queues.current.getPrev( song ?? ContentControl.state.currentSong, ); if (song != null) { - await setSong(song, fromBeginning: true); + await setSong(song); await play(); } else { final songs = ContentControl.state.allSongs.songs; if (songs.isNotEmpty) { song = songs[0]; - await setSong(song, fromBeginning: true); + await setSong(song); await play(); } } } - - /// Function that handles click on track tile. - /// - /// Opens player route. - Future handleSongClick( - BuildContext context, - Song clickedSong, { - SongClickBehavior behavior = SongClickBehavior.play, - }) async { - final hasDuplicates = _hasDuplicates(clickedSong); - if (hasDuplicates) { - ContentControl.handleDuplicate(clickedSong); - } - final isSame = clickedSong == ContentControl.state.currentSong; - if (behavior == SongClickBehavior.play) { - playerRouteController.open(); - await setSong(clickedSong, duplicate: hasDuplicates, fromBeginning: true); - await play(); - } else { - if (isSame) { - if (!playing) { - playerRouteController.open(); - await play(); - } else { - await pause(); - } - } else { - playerRouteController.open(); - await setSong(clickedSong, duplicate: hasDuplicates, fromBeginning: true); - await play(); - } - } - } -} - -/// Describes how to respond to song tile clicks. -enum SongClickBehavior { - /// Always start the clicked song from the beginning. - play, - - /// Allow play/pause on the clicked song. - playPause } diff --git a/lib/logic/player/queue.dart b/lib/logic/player/queue.dart index 5db30f85d..07466661d 100644 --- a/lib/logic/player/queue.dart +++ b/lib/logic/player/queue.dart @@ -3,23 +3,28 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @dart = 2.12 - -import 'package:enum_to_string/enum_to_string.dart'; import 'package:collection/collection.dart'; import 'package:sweyer/sweyer.dart'; /// The type of currently playing queue. enum QueueType { /// Queue of all songs. - all, + allSongs, + + /// Queue of all albums. + allAlbums, + + /// Queue of all playlists. + allPlaylists, + + /// Queue of all artistt. + allArtists, /// Queue of searched tracks. searched, - /// Some persistent queue on user device, has an id. - /// See [PersistentQueue]. - persistent, + /// Queue that has same [SongOrigin]. + origin, /// Some arbitrary queue. Сannot have modified state. /// @@ -30,10 +35,6 @@ enum QueueType { arbitrary, } -extension QueueTypeSerialization on QueueType { - String get value => EnumToString.convertToString(this); -} - /// Class, representing a queue in application /// /// It is more array-like, as it has shuffle methods and explicit indexing. @@ -115,8 +116,8 @@ class Queue implements _QueueOperations { } @override - void remove(Song song) { - byId.remove(song.id); + bool remove(Song song) { + return byId.remove(song.id); } @override @@ -152,7 +153,7 @@ class Queue implements _QueueOperations { /// it if doesn't find it. void compareAndRemoveObsolete(Queue queue) { songs.removeWhere((song) { - return queue.get(song) == null; + return queue.songs.firstWhereOrNull((el) => el.sourceId == song.sourceId) == null; }); } } @@ -163,7 +164,7 @@ abstract class _QueueOperations { bool contains(T arg); /// Removes song. - void remove(T arg); + bool remove(T arg); /// Retruns song. Song? get(T arg); @@ -189,8 +190,16 @@ class _QueueOperationsById implements _QueueOperations { } @override - void remove(int id) { - queue.songs.removeWhere((el) => el.id == id); + bool remove(int id) { + bool removed = false; + queue.songs.removeWhere((el) { + if (el.id == id) { + removed = true; + return true; + } + return false; + }); + return removed; } @override diff --git a/lib/logic/player/serialization.dart b/lib/logic/player/serialization.dart index af78b2c58..b0117bd50 100644 --- a/lib/logic/player/serialization.dart +++ b/lib/logic/player/serialization.dart @@ -27,7 +27,7 @@ abstract class JsonSerializer { /// Create file json if it does not exists or of it is empty then write to it empty array. Future init() async { final file = await getFile(); - if (!await file.exists()) { + if (!file.existsSync()) { await file.create(); await file.writeAsString(jsonEncode(initialValue)); } else if (await file.readAsString() == '') { @@ -47,20 +47,89 @@ abstract class JsonSerializer { Future save(S data); } +/// Serializes a list of integers. +class IntListSerializer extends JsonSerializer, List> { + const IntListSerializer(this.fileName); + + @override + final String fileName; + + @override + List get initialValue => []; + + @override + Future> read() async { + try { + final file = await getFile(); + final jsonContent = await file.readAsString(); + return jsonDecode(jsonContent).cast(); + } catch (ex, stack) { + FirebaseCrashlytics.instance.recordError( + ex, + stack, + reason: 'in IntListSerializer.read, fileName: $fileName', + ); + ShowFunctions.instance.showError( + errorDetails: buildErrorReport(ex, stack), + ); + debugPrint('$fileName: error reading integer list json, setting to empty list'); + return []; + } + } + + @override + Future save(List data) async { + final file = await getFile(); + await file.writeAsString(jsonEncode(data)); + // debugPrint('$fileName: json saved'); + } +} + +/// Item for [QueueSerializer]. +class _SerializedQueueItem { + const _SerializedQueueItem({ + required this.id, + required this.duplicationIndex, + required this.originEntry, + }); + + final int id; + final int? duplicationIndex; + final SongOriginEntry? originEntry; + + + factory _SerializedQueueItem.fromMap(Map map) { + final rawOriginEntry = map['origin']; + return _SerializedQueueItem( + id: map['id'], + duplicationIndex: map['duplicationIndex'], + originEntry: rawOriginEntry == null ? null : SongOriginEntry.fromMap(rawOriginEntry), + ); + } + Map toMap() => { + 'id': id, + if (duplicationIndex != null) + 'duplicationIndex': duplicationIndex, + if (originEntry != null) + 'origin': originEntry!.toMap(), + }; +} + /// Used to serialize queue. /// /// Saves only songs ids, so you have to search indexes in 'all' queue to restore. -class QueueSerializer extends JsonSerializer>, List> { +class QueueSerializer extends JsonSerializer, List> { const QueueSerializer(this.fileName); @override final String fileName; + @override List get initialValue => []; /// Returns a list of song ids. @override - Future>> read() async { + Future> read() async { try { final file = await getFile(); final jsonContent = await file.readAsString(); @@ -69,11 +138,13 @@ class QueueSerializer extends JsonSerializer>, List>(); + return list.map((el) => _SerializedQueueItem.fromMap(el)).toList(); } catch (ex, stack) { FirebaseCrashlytics.instance.recordError( ex, @@ -83,58 +154,65 @@ class QueueSerializer extends JsonSerializer>, List save(List data) async { final file = await getFile(); - final json = jsonEncode(data.map((song) { - final origin = song.origin; - return { - 'id': song.id, - if (origin != null) - 'origin_type': origin is Album ? 'album' : throw UnimplementedError(), - if (origin != null) - 'origin_id': origin.id, - }; - }).toList()); + final json = jsonEncode(data.map((song) => + _SerializedQueueItem( + id: song.id, + duplicationIndex: song.duplicationIndex, + originEntry: song.origin?.toSongOriginEntry() + ).toMap() + ).toList()); await file.writeAsString(json); // debugPrint('$fileName: json saved'); } } /// Used to serialize song id map. -class IdMapSerializer extends JsonSerializer, Map> { +class IdMapSerializer extends JsonSerializer { IdMapSerializer._(); static final instance = IdMapSerializer._(); @override String get fileName => 'id_map.json'; + @override - Map get initialValue => {}; + IdMap get initialValue => {}; @override - Future> read() async { + Future read() async { try { final file = await getFile(); final jsonContent = await file.readAsString(); - return jsonDecode(jsonContent).cast(); + final json = jsonDecode(jsonContent) as Map; + final IdMap idMap = {}; + for (final entry in json.entries) { + final IdMapKey? key; + final decodedKey = jsonDecode(entry.key); + // Initially the id map was saved as just `Map` where: + // key was negative song id, + // value was the source positve id + // + // This ensures there will be no errors in case someone migrates from + // the old version. + // + // Changed in 1.0.4 + if (decodedKey is String) { + key = IdMapKey(id: int.parse(decodedKey), originEntry: null); + } else { + key = IdMapKey.fromMap(decodedKey as Map); + } + if (key != null) { + idMap[key] = entry.value as int; + } + } + return idMap; } catch (ex, stack) { FirebaseCrashlytics.instance.recordError( ex, @@ -144,7 +222,7 @@ class IdMapSerializer extends JsonSerializer, Map> ShowFunctions.instance.showError( errorDetails: buildErrorReport(ex, stack), ); - debugPrint('$fileName: Error reading songs json, setting to empty songs list'); + debugPrint('$fileName: error reading id map json, setting to empty map'); return {}; } } @@ -152,9 +230,11 @@ class IdMapSerializer extends JsonSerializer, Map> /// Serializes provided map as id map. /// Used on dart side to saved cleared map, in other cases used on native. @override - Future save(Map data) async { + Future save(IdMap data) async { final file = await getFile(); - await file.writeAsString(jsonEncode(data)); + await file.writeAsString(jsonEncode(data.map((key, value) => + MapEntry(jsonEncode(key.toMap()), value), + ))); // debugPrint('$fileName: json saved'); } } diff --git a/lib/logic/prefs.dart b/lib/logic/prefs.dart index 200af6696..803c95ed6 100644 --- a/lib/logic/prefs.dart +++ b/lib/logic/prefs.dart @@ -3,12 +3,6 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @dart = 2.12 - -import 'dart:convert'; - -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; @@ -21,65 +15,73 @@ import 'package:sweyer/constants.dart' as Constants; /// It also contains prefs that aren't used in dart side directly, but only on native. abstract class Prefs { /// Search history list. - static final Pref> searchHistoryStringList = - Pref>(key: 'search_history', defaultValue: []); + static const searchHistory = StringListPref('search_history', []); - /// Track position. - /// Stored in seconds. - static final Pref songPositionInt = - Pref(key: 'song_position', defaultValue: 0); + /// Track position, stored in seconds. + static const songPosition = IntPref('song_position', 0); /// Last playing track id. - static final Pref songIdInt = - Pref(key: 'song_id', defaultValue: null); + static const songId = NullableIntPref('song_id'); /// Loop mode. - static final Pref loopModeBool = - Pref(key: 'loop_mode', defaultValue: false); + static const loopMode = BoolPref('loop_mode', false); //****************** Sorts *********************** /// Sort feature used for song list. - static final Pref songSortString = Pref( - key: 'songs_sort', - defaultValue: jsonEncode( - SongSort.defaultOrder(SongSortFeature.dateModified).toJson(), - ), + static final songSort = JsonPref( + 'songs_sort', + SongSort.defaultOrder(SongSortFeature.dateModified), + fromJson: (value) => SongSort.fromMap(value as Map), + toJson: (value) => value.toMap(), ); /// Sort feature used for album list. - static final Pref albumSortString = Pref( - key: 'album_sort', - defaultValue: jsonEncode( - AlbumSort.defaultOrder(AlbumSortFeature.year).toJson(), - ), + static final albumSort = JsonPref( + 'album_sort', + AlbumSort.defaultOrder(AlbumSortFeature.year), + fromJson: (value) => AlbumSort.fromMap(value as Map), + toJson: (value) => value.toMap(), + ); + + /// Sort feature used for playlist list. + static final playlistSort = JsonPref( + 'playlist_sort', + PlaylistSort.defaultOrder(PlaylistSortFeature.dateAdded), + fromJson: (value) => PlaylistSort.fromMap(value as Map), + toJson: (value) => value.toMap(), + ); + + /// Sort feature used for artist list. + static final artistSort = JsonPref( + 'artist_sort', + ArtistSort.defaultOrder(ArtistSortFeature.name), + fromJson: (value) => ArtistSort.fromMap(value as Map), + toJson: (value) => value.toMap(), ); /// Last played [QueueType]. - static final Pref queueTypeString = Pref( - key: 'queue_type', - defaultValue: QueueType.all.value, + static final queueType = EnumPref( + 'queue_type', + QueueType.values, + QueueType.allSongs, ); - /// Last persistent queue. - static final Pref persistentQueueId = - Pref(key: 'persistent_queue_id', defaultValue: null); + /// Last [SongOrigin]. + static final songOrigin = NullableJsonPref( + 'song_origin', + fromJson: (value) => value == null ? null : SongOrigin.originFromEntry(SongOriginEntry.fromMap(value as Map)), + toJson: (value) => value?.toSongOriginEntry().toMap(), + ); /// Last search query. - static final Pref searchQueryString = - Pref(key: 'search_query', defaultValue: null); + static const searchQuery = NullableStringPref('search_query'); - /// Last [ArbitraryQueueOrigin]. - static final Pref arbitraryQueueOrigin = - Pref(key: 'arbitrary_queue_origin', defaultValue: null); + /// Whether the saved queue is modified. + static const queueModified = BoolPref('queue_modified', false); - /// Whether the saved queue is modified or not. - static final Pref queueModifiedBool = - Pref(key: 'queue_modified', defaultValue: false); - - /// Whether the saved queue is shuffled or not. - static final Pref queueShuffledBool = - Pref(key: 'queue_shuffled', defaultValue: false); + /// Whether the saved queue is shuffled. + static const queueShuffled = BoolPref('queue_shuffled', false); /// Developer mode pref. /// @@ -87,36 +89,41 @@ abstract class Prefs { /// * special dev menu in the drawer gets unlocked /// * error snackbars are shown /// * song info button available in the top right menu of [PlayerRoute]. - static final Pref devModeBool = - Pref(key: 'dev_mode', defaultValue: false); + static final devMode = PrefNotifier(const BoolPref('dev_mode', false)); } class SearchHistory { SearchHistory._(); static final instance = SearchHistory._(); + /// Before accessing this variable, you mast call [load]. List? history; + /// Loads the history. Future load() async { - history ??= await Prefs.searchHistoryStringList.get(); + history ??= List.from(Prefs.searchHistory.get()); } + /// Clears the history. Future clear() async { if (history != null) { history!.clear(); } else { history = []; } - await Prefs.searchHistoryStringList.set(const []); + await Prefs.searchHistory.set(const []); } - Future remove(int index) async { + /// Removes an entry from history at [index]. + Future removeAt(int index) async { await load(); history!.removeAt(index); - await Prefs.searchHistoryStringList.set(history!); + await Prefs.searchHistory.set(history!); } - Future save(String entry) async { + /// Adds an [entry] to history. + /// Automatically calls [load]. + Future add(String entry) async { entry = entry.trim(); if (entry.isNotEmpty) { await load(); @@ -126,7 +133,7 @@ class SearchHistory { if (history!.length > Constants.Config.SEARCH_HISTORY_LENGTH) { history!.removeLast(); } - await Prefs.searchHistoryStringList.set(history!); + await Prefs.searchHistory.set(history!); } } } @@ -137,12 +144,8 @@ abstract class Settings { /// /// * `true` means light /// * `false` means dark - static final Pref lightThemeBool = - Pref(key: 'setting_light_theme', defaultValue: false); + static final lightThemeBool = PrefNotifier(const BoolPref('setting_light_theme', false)); /// Stores primary color int value. - static final Pref primaryColorInt = Pref( - key: 'setting_primary_color', - defaultValue: Constants.Theme.defaultPrimaryColor.value, - ); + static final primaryColorInt = PrefNotifier(IntPref('setting_primary_color', Constants.Theme.defaultPrimaryColor.value)); } diff --git a/lib/logic/theme.dart b/lib/logic/theme.dart index cabd321bf..530f0720a 100644 --- a/lib/logic/theme.dart +++ b/lib/logic/theme.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:async/async.dart'; import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:sweyer/sweyer.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; @@ -15,8 +15,8 @@ import 'package:sweyer/constants.dart' as Constants; abstract class ThemeControl { static bool _ready = false; - static Color _colorForBlend; - static Brightness _brightness; + static late Color _colorForBlend; + static late Brightness _brightness; /// Whether the start up ui animation has ended. static bool get ready => _ready; @@ -59,10 +59,10 @@ abstract class ThemeControl { /// Inits theme, fetches brightness from [Prefs]. /// /// NOTE that this does NOT call [emitThemeChange]. - static Future init() async { - final lightTheme = await Settings.lightThemeBool.get(); + static void init() { + final lightTheme = Settings.lightThemeBool.get(); _brightness = lightTheme ? Brightness.light : Brightness.dark; - final primaryColor = Color(await Settings.primaryColorInt.get()); + final primaryColor = Color(Settings.primaryColorInt.get()); _applyPrimaryColor(primaryColor); } @@ -78,7 +78,7 @@ abstract class ThemeControl { statusBarIconBrightness: Brightness.light, )); await Future.delayed(const Duration(milliseconds: 500)); - if (SystemUiStyleController.lastUi.systemNavigationBarColor != Colors.black) { + if (SystemUiStyleController.lastUi.systemNavigationBarColor != Constants.UiTheme.black.auto.systemNavigationBarColor) { final ui = Constants.UiTheme.grey.auto; await SystemUiStyleController.animateSystemUiOverlay( to: ui, @@ -90,10 +90,7 @@ abstract class ThemeControl { } /// Changes theme to opposite and saves new value to pref. - /// - /// By default performs an animation of system ui to [Constants.UiTheme.topGrey]. - /// Optional [systemUiOverlayStyle] allows change that behavior. - static Future switchTheme({ SystemUiStyleController systemUiOverlayStyle }) async { + static Future switchTheme() async { _rebuildOperation?.cancel(); _brightness = _brightness == Brightness.dark ? Brightness.light : Brightness.dark; Settings.lightThemeBool.set(_brightness == Brightness.light); @@ -105,15 +102,18 @@ abstract class ThemeControl { AppRouter.instance.updateTransitionSettings(themeChanged: true); - emitThemeChange(true); - _rebuildOperation = CancelableOperation.fromFuture(() async { - await Future.delayed(dilate(const Duration(milliseconds: 300))); + themeChaning.add(true); + _rebuildOperation = CancelableOperation.fromFuture( + Future.delayed(dilate(const Duration(milliseconds: 300))) + ) + ..value.then((value) async { App.rebuildAllChildren(); await Future.delayed(dilate(const Duration(milliseconds: 20))); - emitThemeChange(false); - }()); + }) + ..value.then((value) => themeChaning.add(false)); + await SystemUiStyleController.animateSystemUiOverlay( - to: systemUiOverlayStyle ?? Constants.UiTheme.black.auto, + to: Constants.UiTheme.black.auto, curve: Curves.easeIn, duration: const Duration(milliseconds: 160), ); @@ -124,19 +124,21 @@ abstract class ThemeControl { _rebuildOperation?.cancel(); _applyPrimaryColor(color); Settings.primaryColorInt.set(color.value); - emitThemeChange(true); + themeChaning.add(true); MusicPlayer.instance.updateServiceMediaItem(); - _rebuildOperation = CancelableOperation.fromFuture(() async { - await Future.delayed(dilate(primaryColorChangeDuration)); + _rebuildOperation = CancelableOperation.fromFuture( + Future.delayed(dilate(primaryColorChangeDuration)) + ) + ..value.then((value) async { App.rebuildAllChildren(); await Future.delayed(dilate(const Duration(milliseconds: 20))); - emitThemeChange(false); - }()); + }) + ..value.then((value) => themeChaning.add(false)); } static void _applyPrimaryColor(Color color) { AppRouter.instance.updateTransitionSettings(themeChanged: true); - _colorForBlend = getColorForBlend(color); + _colorForBlend = ContentArt.getColorToBlendInDefaultArt(color); Constants.Theme.app = Constants.Theme.app.copyWith( light: Constants.Theme.app.light.copyWith( primaryColor: color, @@ -183,26 +185,10 @@ abstract class ThemeControl { ); } - static final StreamController _controller = StreamController.broadcast(); static const Duration primaryColorChangeDuration = Duration(milliseconds: 240); - static CancelableOperation _rebuildOperation; - - /// Gets stream of changes on theme. - /// - /// Boolean value emitted to the stream indicates that theme animation is now playing and - /// some interface that can be hidden for the animation optimization. - static Stream get onThemeChange => _controller.stream; - static bool get themeChaning => _themeChaning; + static CancelableOperation? _rebuildOperation; - /// Whether the theme is curretly chaning. - static bool _themeChaning = false; - - /// Emit theme change into stream - /// - /// The [value] indicates that theme animation is now playing and - /// some interface that can be hidden for the animation optimization. - static void emitThemeChange(bool value) { - _themeChaning = value; - _controller.add(value); - } + /// If `true` - that means theme animation is now being performed and + /// some interface can hidden for optimization sake. + static final BehaviorSubject themeChaning = BehaviorSubject.seeded(false); } diff --git a/lib/main.dart b/lib/main.dart index 463ddd111..e0e5ff7a8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,194 +1,8 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ +// @dart = 2.7 -import 'dart:async'; -import 'dart:isolate'; - -import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/foundation.dart'; import 'package:sweyer/sweyer.dart'; -import 'package:sweyer/constants.dart' as Constants; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; - -import 'routes/routes.dart'; - -/// Builds up the error report message from the exception and stacktrace. -String buildErrorReport(dynamic ex, dynamic stack) { - return ''' -$ex - -$stack'''; -} - -Future reportError(dynamic ex, StackTrace stack) async { - if (await Prefs.devModeBool.get()) { - ShowFunctions.instance.showError( - errorDetails: buildErrorReport(ex, stack), - ); - } - await FirebaseCrashlytics.instance.recordError( - ex, - stack, - ); -} - -Future reportFlutterError(FlutterErrorDetails details) async { - if (await Prefs.devModeBool.get()) { - ShowFunctions.instance.showError( - errorDetails: buildErrorReport(details.exception, details.stack), - ); - } - await FirebaseCrashlytics.instance.recordFlutterError(details); -} - - -class _WidgetsBindingObserver extends WidgetsBindingObserver { - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); - if (state == AppLifecycleState.resumed) { - /// This ensures that proper UI will be applied when activity is resumed. - /// - /// See: - /// * https://github.com/flutter/flutter/issues/21265 - /// * https://github.com/ryanheise/audio_service/issues/662 - /// - /// [SystemUiOverlayStyle.statusBarBrightness] is only honored on iOS, - /// so I can safely use that here. - final lastUi = SystemUiStyleController.lastUi; - SystemUiStyleController.setSystemUiOverlay(SystemUiStyleController.lastUi.copyWith( - statusBarBrightness: - lastUi.statusBarBrightness == null || - lastUi.statusBarBrightness == Brightness.dark - ? Brightness.light - : Brightness.dark - )); - /// Defensive programming if I some time later decide to add iOS support. - SystemUiStyleController.setSystemUiOverlay(SystemUiStyleController.lastUi.copyWith( - statusBarBrightness: lastUi.statusBarBrightness == Brightness.dark - ? Brightness.light - : Brightness.dark - )); - } - } -} - -Future main() async { - // Disabling automatic system UI adjustment, which causes system nav bar - // color to be reverted to black when the bottom player route is being expanded. - // - // Related to https://github.com/flutter/flutter/issues/40590 - final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized(); - binding.renderView.automaticSystemUiAdjustment = false; - - await Firebase.initializeApp(); - if (kDebugMode) { - // Force disable Crashlytics collection while doing every day development. - // Temporarily toggle this to true if you want to test crash reporting in your app. - await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false); - } - Isolate.current.addErrorListener(RawReceivePort((pair) async { - final List errorAndStacktrace = pair; - await reportError(errorAndStacktrace.first, errorAndStacktrace.last); - }).sendPort); - FlutterError.onError = reportFlutterError; - runZonedGuarded>(() async { - WidgetsBinding.instance.addObserver(_WidgetsBindingObserver()); - - await AppLocalizations.init(); - await ThemeControl.init(); - ThemeControl.initSystemUi(); - await Permissions.init(); - await ContentControl.init(); - runApp(const App()); - }, reportError); -} - -class App extends StatefulWidget { - const App({Key key}) : super(key: key); - - static NFThemeData nfThemeData = NFThemeData( - systemUiStyle: Constants.UiTheme.black.auto, - modalSystemUiStyle: Constants.UiTheme.modal.auto, - bottomSheetSystemUiStyle: Constants.UiTheme.bottomSheet.auto, - ); - - static void rebuildAllChildren() { - void rebuild(Element el) { - el.markNeedsBuild(); - el.visitChildren(rebuild); - } - (AppRouter.instance.navigatorKey.currentContext as Element).visitChildren(rebuild); - } - - @override - _AppState createState() => _AppState(); -} - -SlidableController _playerRouteController; -SlidableController _drawerController; -SlidableController get playerRouteController => _playerRouteController; -SlidableController get drawerController => _drawerController; - -class _AppState extends State with TickerProviderStateMixin { - - @override - void initState() { - super.initState(); - _drawerController = SlidableController(vsync: this); - _playerRouteController = SlidableController( - vsync: this, - springDescription: playerRouteSpringDescription, - ); - NFWidgets.init( - navigatorKey: AppRouter.instance.navigatorKey, - routeObservers: [routeObserver, homeRouteObserver], - ); - } - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: ThemeControl.onThemeChange, - builder: (BuildContext context, AsyncSnapshot snapshot) { - return NFTheme( - data: App.nfThemeData, - child: MaterialApp.router( - // showPerformanceOverlay: true, - title: Constants.Config.APPLICATION_TITLE, - color: ThemeControl.theme.colorScheme.primary, - supportedLocales: Constants.Config.supportedLocales, - scrollBehavior: _ScrollBehavior(), - localizationsDelegates: const [ - AppLocalizations.delegate, - NFLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ], - theme: ThemeControl.theme, - routerDelegate: AppRouter.instance, - routeInformationParser: AppRouteInformationParser(), - ), - ); - }, - ); - } -} -class _ScrollBehavior extends ScrollBehavior { - @override - Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) { - return GlowingOverscrollIndicator( - axisDirection: axisDirection, - color: ThemeControl.theme.colorScheme.background, - child: child, - ); - } -} +// TODO: remove when sound null safety is possible +void main() { + realMain(); +} \ No newline at end of file diff --git a/lib/real_main.dart b/lib/real_main.dart new file mode 100644 index 000000000..968a58256 --- /dev/null +++ b/lib/real_main.dart @@ -0,0 +1,203 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'dart:async'; +import 'dart:isolate'; + +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:sweyer/sweyer.dart'; +import 'package:sweyer/constants.dart' as Constants; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; + +import 'routes/routes.dart'; + +/// Builds up the error report message from the exception and stacktrace. +String buildErrorReport(dynamic ex, dynamic stack) { + return ''' +$ex + +$stack'''; +} + +Future reportError(dynamic ex, StackTrace stack) async { + if (Prefs.devMode.get()) { + ShowFunctions.instance.showError( + errorDetails: buildErrorReport(ex, stack), + ); + } + await FirebaseCrashlytics.instance.recordError( + ex, + stack, + ); +} + +Future reportFlutterError(FlutterErrorDetails details) async { + if (Prefs.devMode.get()) { + ShowFunctions.instance.showError( + errorDetails: buildErrorReport(details.exception, details.stack), + ); + } + await FirebaseCrashlytics.instance.recordFlutterError(details); +} + + +class _WidgetsBindingObserver extends WidgetsBindingObserver { + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) { + /// This ensures that proper UI will be applied when activity is resumed. + /// + /// See: + /// * https://github.com/flutter/flutter/issues/21265 + /// * https://github.com/ryanheise/audio_service/issues/662 + /// + /// [SystemUiOverlayStyle.statusBarBrightness] is only honored on iOS, + /// so I can safely use that here. + final lastUi = SystemUiStyleController.lastUi; + SystemUiStyleController.setSystemUiOverlay(SystemUiStyleController.lastUi.copyWith( + statusBarBrightness: + lastUi.statusBarBrightness == null || + lastUi.statusBarBrightness == Brightness.dark + ? Brightness.light + : Brightness.dark + )); + /// Defensive programming if I some time later decide to add iOS support. + SystemUiStyleController.setSystemUiOverlay(SystemUiStyleController.lastUi.copyWith( + statusBarBrightness: lastUi.statusBarBrightness == Brightness.dark + ? Brightness.light + : Brightness.dark + )); + } + } +} + +Future realMain() async { + // Disabling automatic system UI adjustment, which causes system nav bar + // color to be reverted to black when the bottom player route is being expanded. + // + // Related to https://github.com/flutter/flutter/issues/40590 + final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized(); + binding.renderView.automaticSystemUiAdjustment = false; + await NFPrefs.initialize(); + + await Firebase.initializeApp(); + if (kDebugMode) { + FirebaseFunctions.instance.useFunctionsEmulator(origin: 'http://localhost:5001'); + + // Force disable Crashlytics collection while doing every day development. + // Temporarily toggle this to true if you want to test crash reporting in your app. + await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false); + } + + Isolate.current.addErrorListener(RawReceivePort((pair) async { + final List errorAndStacktrace = pair; + await reportError(errorAndStacktrace.first, errorAndStacktrace.last); + }).sendPort); + FlutterError.onError = reportFlutterError; + + runZonedGuarded>(() async { + WidgetsBinding.instance!.addObserver(_WidgetsBindingObserver()); + + await AppLocalizations.init(); + ThemeControl.init(); + ThemeControl.initSystemUi(); + await Permissions.init(); + await ContentControl.init(); + runApp(const App()); + }, reportError); +} + +class App extends StatefulWidget { + const App({Key? key}) : super(key: key); + + static NFThemeData nfThemeData = NFThemeData( + systemUiStyle: Constants.UiTheme.black.auto, + modalSystemUiStyle: Constants.UiTheme.modal.auto, + bottomSheetSystemUiStyle: Constants.UiTheme.bottomSheet.auto, + ); + + static void rebuildAllChildren() { + void rebuild(Element el) { + el.markNeedsBuild(); + el.visitChildren(rebuild); + } + (AppRouter.instance.navigatorKey.currentContext as Element?)!.visitChildren(rebuild); + } + + @override + _AppState createState() => _AppState(); +} + +late SlidableController _playerRouteController; +late SlidableController _drawerController; +SlidableController get playerRouteController => _playerRouteController; +SlidableController get drawerController => _drawerController; + +class _AppState extends State with TickerProviderStateMixin { + @override + void initState() { + super.initState(); + _drawerController = SlidableController( + vsync: this, + springDescription: DismissibleRoute.springDescription, + ); + _playerRouteController = SlidableController( + vsync: this, + springDescription: playerRouteSpringDescription, + ); + NFWidgets.init( + navigatorKey: AppRouter.instance.navigatorKey, + routeObservers: [routeObserver, homeRouteObserver], + ); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: ThemeControl.themeChaning, + builder: (BuildContext context, AsyncSnapshot snapshot) { + return NFTheme( + data: App.nfThemeData, + child: MaterialApp.router( + // debugShowCheckedModeBanner: false, + // showPerformanceOverlay: true, + // checkerboardRasterCacheImages: true, + title: Constants.Config.APPLICATION_TITLE, + color: ThemeControl.theme.colorScheme.primary, + supportedLocales: Constants.Config.supportedLocales, + scrollBehavior: _ScrollBehavior(), + localizationsDelegates: const [ + AppLocalizations.delegate, + NFLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + theme: ThemeControl.theme, + routerDelegate: AppRouter.instance, + routeInformationParser: AppRouteInformationParser(), + ), + ); + }, + ); + } +} + +class _ScrollBehavior extends ScrollBehavior { + @override + Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) { + return GlowingOverscrollIndicator( + axisDirection: axisDirection, + color: ThemeControl.theme.colorScheme.background, + child: child, + ); + } +} diff --git a/lib/routes/dev_route.dart b/lib/routes/dev_route.dart index a54886bf9..618c0d0db 100644 --- a/lib/routes/dev_route.dart +++ b/lib/routes/dev_route.dart @@ -6,13 +6,12 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart' show timeDilation; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; class DevRoute extends StatelessWidget { - const DevRoute({Key key}) : super(key: key); + const DevRoute({Key? key}) : super(key: key); void _testToast() { ShowFunctions.instance.showToast( @@ -21,9 +20,10 @@ class DevRoute extends StatelessWidget { ); } - Future _quitDevMode(AppLocalizations l10n, NFLocalizations nfl10n) async { + Future _quitDevMode(BuildContext context, AppLocalizations l10n) async { + final nfl10n = NFLocalizations.of(context); final res = await ShowFunctions.instance.showDialog( - AppRouter.instance.navigatorKey.currentContext, + context, title: Text(l10n.areYouSure), content: Text(l10n.quitDevModeDescription), buttonSplashColor: Constants.Theme.glowSplashColor.auto, @@ -33,18 +33,21 @@ class DevRoute extends StatelessWidget { textStyle: const TextStyle(color: Constants.AppColors.red), ), ); - if (res != null && res) { - ContentControl.setDevMode(false); - AppRouter.instance.navigatorKey.currentState.pop(); + if (res != null && res as bool) { + Prefs.devMode.set(false); + Navigator.of(context).pop(); } } @override Widget build(BuildContext context) { final l10n = getl10n(context); - return NFPageBase( - name: l10n.debug, - child: Column( + return Scaffold( + appBar: AppBar( + title: Text(l10n.debug), + leading: const NFBackButton(), + ), + body: Column( children: [ Expanded( child: ListView( @@ -61,7 +64,7 @@ class DevRoute extends StatelessWidget { NFListTile( title: Text(l10n.quitDevMode), splashColor: ThemeControl.theme.colorScheme.error, - onTap: () => _quitDevMode(l10n, NFLocalizations.of(context)), + onTap: () => _quitDevMode(context, l10n), ), ], ), @@ -70,14 +73,14 @@ class DevRoute extends StatelessWidget { } class _TimeDilationSlider extends StatefulWidget { - const _TimeDilationSlider({Key key}) : super(key: key); + const _TimeDilationSlider({Key? key}) : super(key: key); @override _TimeDilationSliderState createState() => _TimeDilationSliderState(); } class _TimeDilationSliderState extends State<_TimeDilationSlider> { - double _value; + late double _value; @override void initState() { diff --git a/lib/routes/home_route/album_route.dart b/lib/routes/home_route/album_route.dart deleted file mode 100644 index 04215841e..000000000 --- a/lib/routes/home_route/album_route.dart +++ /dev/null @@ -1,347 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter_sticky_header/flutter_sticky_header.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; -import 'package:sweyer/sweyer.dart'; -import 'package:sweyer/constants.dart' as Constants; - -class AlbumRoute extends StatefulWidget { - AlbumRoute({Key key, @required this.album}) - : assert(album != null), - super(key: key); - - final Album album; - - @override - _AlbumRouteState createState() => _AlbumRouteState(); -} - -class _AlbumRouteState extends State with TickerProviderStateMixin, SelectionHandler { - final ScrollController scrollController = ScrollController(); - AnimationController appBarController; - ContentSelectionController> selectionController; - List songs; - - static const _appBarHeight = kNFAppBarPreferredSize - 8.0; - static const _albumArtSize = 130.0; - static const _albumSectionTopPadding = 10.0; - static const _albumSectionBottomPadding = 24.0; - static const _albumSectionHeight = _albumArtSize + _albumSectionTopPadding + _albumSectionBottomPadding; - - static const _buttonSectionButtonHeight = 38.0; - static const _buttonSectionBottomPadding = 12.0; - static const _buttonSectionHeight = _buttonSectionButtonHeight + _buttonSectionBottomPadding; - - /// Amount of pixels user always can scroll. - static const _alwaysCanScrollExtent = _albumSectionHeight + _buttonSectionHeight; - - @override - void initState() { - super.initState(); - songs = widget.album.songs; - appBarController = AnimationController( - vsync: AppRouter.instance.navigatorKey.currentState, - value: 1.0, - ); - scrollController.addListener(_handleScroll); - selectionController = ContentSelectionController.forContent( - this, - closeButton: true, - counter: true, - ignoreWhen: () => playerRouteController.opened, - ) - ..addListener(handleSelection) - ..addStatusListener(handleSelectionStatus); - } - - @override - void dispose() { - selectionController.dispose(); - appBarController.dispose(); - scrollController.removeListener(_handleScroll); - super.dispose(); - } - - void _handleScroll() { - appBarController.value = 1.0 - scrollController.offset / _albumSectionHeight; - } - - Widget _buildAlbumInfo() { - final l10n = getl10n(context); - return Padding( - padding: const EdgeInsets.only( - left: 13.0, - right: 10.0, - ), - child: Column( - children: [ - FadeTransition( - opacity: appBarController, - child: Container( - padding: const EdgeInsets.only( - top: _albumSectionTopPadding, - bottom: _albumSectionBottomPadding, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AlbumArt( - size: 130.0, - highRes: true, - assetScale: 1.4, - source: AlbumArtSource( - path: widget.album.albumArt, - contentUri: widget.album.contentUri, - albumId: widget.album.id, - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 14.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.album.album, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w900, - height: 1.1, - fontSize: 24.0, - ), - ), - Padding( - padding: const EdgeInsets.only( - top: 8.0, - ), - child: ArtistWidget( - artist: widget.album.artist, - overflow: TextOverflow.clip, - textStyle: TextStyle( - fontWeight: FontWeight.w900, - fontSize: 15.0, - color: ThemeControl.theme.colorScheme.onBackground, - ), - ), - ), - Text( - '${l10n.album} • ${widget.album.year}', - style: TextStyle( - color: ThemeControl.theme.textTheme.subtitle2.color, - fontWeight: FontWeight.w900, - fontSize: 14.0, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: _buttonSectionBottomPadding), - child: SizedBox( - height: _buttonSectionButtonHeight, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: _AlbumPlayButton( - text: l10n.shuffleContentList, - icon: const Icon(Icons.shuffle_rounded, size: 22.0), - color: Constants.Theme.contrast.auto, - textColor: ThemeControl.theme.colorScheme.background, - splashColor: Constants.Theme.glowSplashColorOnContrast.auto, - onPressed: () { - ContentControl.setPersistentQueue( - queue: widget.album, - songs: songs, - shuffled: true, - ); - MusicPlayer.instance.setSong(ContentControl.state.queues.current.songs[0]); - MusicPlayer.instance.play(); - playerRouteController.open(); - }, - ), - ), - const SizedBox(width: 16.0), - Expanded( - child: _AlbumPlayButton( - text: l10n.playContentList, - icon: const Icon(Icons.play_arrow_rounded, size: 28.0), - onPressed: () { - ContentControl.setPersistentQueue(queue: widget.album, songs: songs); - MusicPlayer.instance.setSong(songs[0]); - MusicPlayer.instance.play(); - playerRouteController.open(); - }, - ), - ), - ], - ), - ), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final theme = ThemeControl.theme; - - return Scaffold( - body: LayoutBuilder( - builder: (context, constraints) { - // TODO: add comment how this is working - final height = constraints.maxHeight - - _appBarHeight - - kSongTileHeight * songs.length - - AppBarBorder.height - - MediaQuery.of(context).padding.top; - - return ScrollConfiguration( - behavior: const GlowlessScrollBehavior(), - child: StreamBuilder( - stream: ContentControl.state.onSongChange, - builder: (context, snapshot) => CustomScrollView( - controller: scrollController, - slivers: [ - AnimatedBuilder( - animation: appBarController, - child: NFBackButton( - onPressed: () { - selectionController.close(); - Navigator.of(context).pop(); - }, - ), - builder: (context, child) => SliverAppBar( - pinned: true, - elevation: 0.0, - automaticallyImplyLeading: false, - toolbarHeight: _appBarHeight, - leading: child, - titleSpacing: 0.0, - backgroundColor: appBarController.isDismissed - ? theme.colorScheme.background - : theme.colorScheme.background.withOpacity(0.0), - title: AnimatedOpacity( - opacity: 1.0 - appBarController.value > 0.35 - ? 1.0 - : 0.0, - curve: Curves.easeOut, - duration: const Duration(milliseconds: 400), - child: Text(widget.album.album), - ), - ), - ), - - SliverToBoxAdapter( - child: IgnoreInSelection( - controller: selectionController, - child: _buildAlbumInfo() - ), - ), - - SliverStickyHeader( - overlapsContent: false, - header: AnimatedBuilder( - animation: appBarController, - builder: (context, child) => AppBarBorder( - shown: scrollController.offset > _alwaysCanScrollExtent, - ), - ), - sliver: ContentListView.sliver( - list: songs, - selectionController: selectionController, - currentTest: (index) => ContentControl.state.queues.persistent == widget.album && - songs[index].sourceId == ContentControl.state.currentSong.sourceId, - songTileVariant: SongTileVariant.number, - onItemTap: () => ContentControl.setPersistentQueue( - queue: widget.album, - songs: songs, - ), - ), - ), - - SliverToBoxAdapter( - child: height <= 0 - ? const SizedBox.shrink() - : Container(height: height), - ), - ], - ), - ), - ); - }, - ), - ); - } -} - -/// A button with text and icon. -class _AlbumPlayButton extends StatelessWidget { - const _AlbumPlayButton({ - Key key, - @required this.text, - @required this.icon, - @required this.onPressed, - this.color, - this.textColor, - this.splashColor, - }) : super(key: key); - - final String text; - final Icon icon; - final VoidCallback onPressed; - final Color color; - final Color textColor; - final Color splashColor; - - @override - Widget build(BuildContext context) { - return Theme( - data: ThemeControl.theme.copyWith( - splashFactory: NFListTileInkRipple.splashFactory, - ), - child: ElevatedButton( - onPressed: onPressed, - // ignore: missing_required_param - style: const ElevatedButton().defaultStyleOf(context).copyWith( - backgroundColor: MaterialStateProperty.all(color), - foregroundColor: MaterialStateProperty.all(textColor), - overlayColor: MaterialStateProperty.resolveWith((_) => splashColor ?? Constants.Theme.glowSplashColor.auto), - splashFactory: NFListTileInkRipple.splashFactory, - shadowColor: MaterialStateProperty.all(Colors.transparent), - textStyle: MaterialStateProperty.resolveWith((_) => TextStyle( - fontFamily: ThemeControl.theme.textTheme.headline1.fontFamily, - fontWeight: FontWeight.w700, - fontSize: 15.0, - )), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - icon, - const SizedBox(width: 6.0), - Padding( - padding: const EdgeInsets.only(bottom: 1.0), - child: Text(text), - ), - const SizedBox(width: 8.0), - ], - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/routes/home_route/artist_content_route.dart b/lib/routes/home_route/artist_content_route.dart new file mode 100644 index 000000000..fe572d03b --- /dev/null +++ b/lib/routes/home_route/artist_content_route.dart @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:sweyer/sweyer.dart'; + +class ArtistContentRoute extends StatefulWidget { + const ArtistContentRoute({ + Key? key, + required this.arguments, + }) : super(key: key); + + final ArtistContentArguments arguments; + + @override + State> createState() => _ArtistContentRouteState(); +} + +class _ArtistContentRouteState extends State> { + late StreamSubscription _contentChangeSubscription; + late List list; + + @override + void initState() { + super.initState(); + list = widget.arguments.list; + _contentChangeSubscription = ContentControl.state.onContentChange.listen((event) { + setState(() { + // Update contents + list = contentPick>>( + song: () => widget.arguments.artist.songs as List, + album: () => widget.arguments.artist.albums as List, + playlist: () => throw UnimplementedError(), + artist: () => throw UnimplementedError(), + )(); + }); + }); + } + + @override + void dispose() { + _contentChangeSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + final artist = widget.arguments.artist; + final selectionRoute = selectionRouteOf(context); + return ContentSelectionControllerCreator( + builder: (context, selectionController, child) => Scaffold( + appBar: AppBar( + title: AnimationSwitcher( + animation: CurvedAnimation( + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + parent: selectionController.animation, + ), + child1: Text(ContentUtils.localizedArtist(artist.artist, l10n)), + child2: SelectionCounter(controller: selectionController), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(left: 5.0, right: 5.0), + child: AnimationSwitcher( + animation: CurvedAnimation( + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + parent: selectionController.animation, + ), + builder2: SelectionAppBar.defaultSelectionActionsBuilder, + child1: const SizedBox.shrink(), + child2: Row(children: [ + SelectAllSelectionAction( + controller: selectionController, + entryFactory: (content, index) => SelectionEntry.fromContent( + content: content, + index: index, + context: context, + ), + getAll: () => list, + ), + ]), + ), + ), + ], + leading: const NFBackButton(), + ), + body: StreamBuilder( + stream: ContentControl.state.onSongChange, + builder: (context, snapshot) => ContentListView( + list: list, + selectionController: selectionController, + leading: selectionRoute + ? ContentListHeader.onlyCount(count: list.length) + : ContentListHeader( + count: list.length, + selectionController: selectionController, + trailing: Padding( + padding: const EdgeInsets.only(bottom: 1.0), + child: Row( + children: [ + ContentListHeaderAction( + icon: const Icon(Icons.shuffle_rounded), + onPressed: () { + contentPick( + song: () { + ContentControl.setOriginQueue( + origin: artist, + shuffled: true, + songs: list as List, + ); + }, + album: () { + final shuffleResult = ContentUtils.shuffleSongOrigins(list as List); + ContentControl.setOriginQueue( + origin: artist, + shuffled: true, + songs: shuffleResult.songs, + shuffledSongs: shuffleResult.shuffledSongs, + ); + }, + playlist: () => throw UnimplementedError(), + artist: () => throw UnimplementedError(), + )(); + MusicPlayer.instance.setSong(ContentControl.state.queues.current.songs[0]); + MusicPlayer.instance.play(); + playerRouteController.open(); + }, + ), + ContentListHeaderAction( + icon: const Icon(Icons.play_arrow_rounded), + onPressed: () { + contentPick( + song: () { + ContentControl.setOriginQueue( + origin: artist, + songs: list as List, + ); + }, + album: () { + ContentControl.setOriginQueue( + origin: artist, + songs: ContentUtils.joinSongOrigins(list as List), + ); + }, + playlist: () => throw UnimplementedError(), + artist: () => throw UnimplementedError(), + )(); + MusicPlayer.instance.setSong(ContentControl.state.queues.current.songs[0]); + MusicPlayer.instance.play(); + playerRouteController.open(); + }, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/routes/home_route/artist_route.dart b/lib/routes/home_route/artist_route.dart new file mode 100644 index 000000000..5ac44d6ea --- /dev/null +++ b/lib/routes/home_route/artist_route.dart @@ -0,0 +1,449 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'dart:async'; +import 'dart:ui' as ui; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:sweyer/sweyer.dart'; +import 'package:sweyer/constants.dart' as Constants; + +class ArtistRoute extends StatefulWidget { + ArtistRoute({Key? key, required this.artist}) : super(key: key); + + final Artist artist; + + @override + _ArtistRouteState createState() => _ArtistRouteState(); +} + +class _ArtistRouteState extends State with TickerProviderStateMixin, SelectionHandlerMixin { + final ScrollController scrollController = ScrollController(); + late AnimationController appBarController; + late AnimationController backButtonAnimationController; + late Animation backButtonAnimation; + late List songs; + late List albums; + late StreamSubscription _contentChangeSubscription; + + static const _appBarHeight = NFConstants.toolbarHeight - 8.0 + AppBarBorder.height; + + static const _buttonSectionButtonHeight = 38.0; + static const _buttonSectionBottomPadding = 12.0; + static const _buttonSectionHeight = _buttonSectionButtonHeight + _buttonSectionBottomPadding; + + static const _albumsSectionHeight = 280.0; + + /// Amount of pixels user always can scroll. + double get _alwaysCanScrollExtent => (_artScrollExtent + _buttonSectionHeight).ceilToDouble(); + + /// Amount of pixels after art will be fully hidden and appbar will have background color + /// instead of being transparent. + double get _artScrollExtent => mediaQuery.size.width - _fullAppBarHeight; + + /// Full size of app bar. + double get _fullAppBarHeight => _appBarHeight + mediaQuery.padding.top; + + /// Whether the title is visible. + bool get _appBarTitleVisible => 1.0 - appBarController.value > 0.85; + + late MediaQueryData mediaQuery; + + @override + void initState() { + super.initState(); + _updateContent(false); + appBarController = AnimationController( + vsync: this, + value: 1.0, + ); + backButtonAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + backButtonAnimation = CurvedAnimation( + parent: backButtonAnimationController, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ); + scrollController.addListener(_handleScroll); + + initSelectionController(() => ContentSelectionController.create( + vsync: AppRouter.instance.navigatorKey.currentState!, + context: context, + closeButton: true, + ignoreWhen: () => playerRouteController.opened, + )); + + _contentChangeSubscription = ContentControl.state.onContentChange.listen(_handleContentChange); + } + + @override + void dispose() { + _contentChangeSubscription.cancel(); + disposeSelectionController(); + appBarController.dispose(); + scrollController.removeListener(_handleScroll); + super.dispose(); + } + + void _handleContentChange(void event) { + setState(() { + _updateContent(); + }); + } + + void _updateContent([bool postFrame = false]) { + songs = widget.artist.songs; + albums = widget.artist.albums; + if (songs.isEmpty && albums.isEmpty) { + if (postFrame) { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + if (mounted) { + _quitBecauseNotFound(); + } + }); + } else { + _quitBecauseNotFound(); + } + } + } + + void _quitBecauseNotFound() { + ContentControl.refetchAll(); + final l10n = getl10n(context); + ShowFunctions.instance.showToast(msg: l10n.artistNotFound); + Navigator.of(context).pop(); + } + + void _handleScroll() { + appBarController.value = 1.0 - scrollController.offset / _artScrollExtent; + // ThemeData.estimateBrightnessForColor(color); + if (1.0 - appBarController.value > 0.5) { + backButtonAnimationController.forward(); + } else { + backButtonAnimationController.reverse(); + } + } + + PaletteGenerator? palette; + static Future _isolate(image) => createPalette(image); + + Widget _buildInfo() { + final l10n = getl10n(context); + final theme = ThemeControl.theme; + final artSize = mediaQuery.size.width; + final summary = ContentUtils.joinDot([ + l10n.contentsPluralWithCount(songs.length), + if (albums.isNotEmpty) + l10n.contentsPluralWithCount(albums.length), + ContentUtils.bulkDuration(songs), + ]); + return Column( + children: [ + FadeTransition( + opacity: appBarController, + child: RepaintBoundary( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Stack( + children: [ + ContentArt( + assetHighRes: true, + size: artSize, + borderRadius: 0.0, + defaultArtIcon: Artist.icon, + defaultArtIconScale: 4.5, + // onLoad: (image) async { + // palette = await compute(_isolate, image); + // palette = await _isolate(image); + // if (mounted) { + // WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + // if (mounted) { + // setState(() {}); + // _debugOverlay = DebugOverlay( + // (context) => PaletteSwatches(generator: palette), + // ); + // } + // }); + // } + // }, + source: ContentArtSource.artist(widget.artist), + ), + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [theme.colorScheme.background.withOpacity(0.0), theme.colorScheme.background], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ) + ), + ), + ), + Positioned.fill( + bottom: 22.0, + left: 13.0, + right: 13.0, + child: Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + ContentUtils.localizedArtist(widget.artist.artist, l10n), + textAlign: TextAlign.center, + style: TextStyle( + height: 1.0, + fontWeight: FontWeight.w800, + color: Constants.Theme.contrast.auto, + fontSize: 36.0, + ), + ), + const SizedBox(height: 7.0), + Text( + summary, + style: TextStyle( + fontSize: 16.0, + height: 1.0, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + bottom: _buttonSectionBottomPadding, + left: 13.0, + right: 13.0, + ), + child: SizedBox( + height: _buttonSectionButtonHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ShuffleQueueButton( + onPressed: () { + ContentControl.setOriginQueue( + origin: widget.artist, + songs: songs, + shuffled: true, + ); + MusicPlayer.instance.setSong(ContentControl.state.queues.current.songs[0]); + MusicPlayer.instance.play(); + playerRouteController.open(); + }, + ), + ), + const SizedBox(width: 16.0), + Expanded( + child: PlayQueueButton( + onPressed: () { + ContentControl.setOriginQueue(origin: widget.artist, songs: songs); + MusicPlayer.instance.setSong(songs[0]); + MusicPlayer.instance.play(); + playerRouteController.open(); + }, + ), + ), + ], + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final theme = ThemeControl.theme; + final l10n = getl10n(context); + mediaQuery = MediaQuery.of(context); + + return Scaffold( + body: LayoutBuilder( + builder: (context, constraints) { + /// The height to add at the end of the scroll view to make the top info part of the route + /// always be fully scrollable, even if there's not enough content for that. + var additionalHeight = constraints.maxHeight - + _fullAppBarHeight - + kSongTileHeight * math.min(songs.length, 5) - + 48.0; + + if (albums.isNotEmpty) { + additionalHeight -= _albumsSectionHeight + 48.0; + } + + return ScrollConfiguration( + behavior: const GlowlessScrollBehavior(), + child: StreamBuilder( + stream: ContentControl.state.onSongChange, + builder: (context, snapshot) => Stack( + children: [ + Positioned.fill( + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: IgnoreInSelection( + controller: selectionController, + child: _buildInfo(), + ), + ), + + if (songs.isNotEmpty) + SliverToBoxAdapter( + child: ContentSection( + list: songs, + selectionController: selectionController, + maxPreviewCount: 5, + onHeaderTap: selectionController.inSelection && !selectionRoute || songs.length <= 5 ? null : () { + HomeRouter.of(context).goto(HomeRoutes.factory.artistContent(widget.artist, songs)); + }, + contentTileTapHandler: () { + ContentControl.setOriginQueue( + origin: widget.artist, + songs: songs, + ); + }, + ), + ), + + if (albums.isNotEmpty) + MultiSliver( + children: [ + ContentSection.custom( + list: albums, + onHeaderTap: selectionController.inSelection && !selectionRoute ? null : () { + HomeRouter.of(context).goto(HomeRoutes.factory.artistContent(widget.artist, albums)); + }, + child: SizedBox( + height: _albumsSectionHeight, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + scrollDirection: Axis.horizontal, + itemCount: albums.length, + itemBuilder: (context, index) { + return PersistentQueueTile.selectable( + queue: albums[index], + selectionIndex: index, + selected: selectionController.data + .firstWhereOrNull((el) => el.data == albums[index]) != null, + selectionController: selectionController, + grid: true, + ); + }, + separatorBuilder: (BuildContext context, int index) { + return const SizedBox(width: 16.0); + }, + ), + ), + ), + ], + ), + + if (additionalHeight > 0) + SliverToBoxAdapter( + child: Container(height: additionalHeight), + ), + ], + ), + ), + Positioned.fill( + bottom: null, + child: AnimatedBuilder( + animation: appBarController, + child: AnimatedBuilder( + animation: backButtonAnimation, + builder: (context, child) { + final colorAnimation = ColorTween( + begin: Colors.white, + end: theme.iconTheme.color, + ).animate(backButtonAnimation); + + final splashColorAnimation = ColorTween( + begin: Constants.Theme.glowSplashColorOnContrast.auto, + end: theme.splashColor, + ).animate(backButtonAnimation); + + return NFIconButton( + icon: const Icon(Icons.arrow_back_rounded), + color: colorAnimation.value, + splashColor: splashColorAnimation.value, + onPressed: () { + Navigator.of(context).pop(); + }, + ); + }, + ), + builder: (context, child) => SizedBox( + height: _fullAppBarHeight, + child: AppBar( + elevation: 0.0, + leading: child, + automaticallyImplyLeading: false, + titleSpacing: 0.0, + backgroundColor: appBarController.isDismissed + ? theme.colorScheme.background + : theme.colorScheme.background.withOpacity(0.0), + title: AnimationSwitcher( + animation: CurvedAnimation( + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + parent: selectionController.animation, + ), + child1: AnimatedOpacity( + opacity: _appBarTitleVisible + ? 1.0 + : 0.0, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 400), + child: RepaintBoundary( + child: Text( + ContentUtils.localizedArtist(widget.artist.artist, l10n), + ), + ), + ), + child2: SelectionCounter(controller: selectionController), + ), + actions: const [], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(AppBarBorder.height), + child: scrollController.offset <= _artScrollExtent + ? const SizedBox(height: 1) + : AppBarBorder( + shown: scrollController.offset > _alwaysCanScrollExtent, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/routes/home_route/home_route.dart b/lib/routes/home_route/home_route.dart index 5bbb5c003..38de5dd18 100644 --- a/lib/routes/home_route/home_route.dart +++ b/lib/routes/home_route/home_route.dart @@ -5,18 +5,19 @@ import 'dart:async'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; import 'package:sweyer/sweyer.dart'; import 'package:flutter/material.dart'; import 'package:sweyer/constants.dart' as Constants; -export 'album_route.dart'; +export 'artist_content_route.dart'; +export 'artist_route.dart'; +export 'persistent_queue_route.dart'; export 'player_route.dart'; export 'search_route.dart'; export 'tabs_route.dart'; class InitialRoute extends StatefulWidget { - const InitialRoute({Key key}) : super(key: key); + const InitialRoute({Key? key}) : super(key: key); @override _InitialRouteState createState() => _InitialRouteState(); @@ -57,7 +58,7 @@ class _InitialRouteState extends State { _animateNotMainUi(); return const _SearchingSongsScreen(); } - if (ContentControl.state == null || ContentControl.state.allSongs.isEmpty) { + if (ContentControl.state.allSongs.isEmpty) { _animateNotMainUi(); return const _SongsEmptyScreen(); } @@ -67,7 +68,7 @@ class _InitialRouteState extends State { ); } return StreamBuilder( - stream: ThemeControl.onThemeChange, + stream: ThemeControl.themeChaning, builder: (context, snapshot) { if (snapshot.data == true) return const SizedBox.shrink(); @@ -86,27 +87,15 @@ class _InitialRouteState extends State { /// Main app's content screen. /// Displayed only there's some content. class Home extends StatefulWidget { - const Home({Key key}) : super(key: key); + const Home({Key? key}) : super(key: key); @override HomeState createState() => HomeState(); } class HomeState extends State { - static GlobalKey overlayKey; - final router = HomeRouter(); - - @override - void initState() { - super.initState(); - overlayKey = GlobalKey(); - } - - @override - void dispose() { - overlayKey = null; - super.dispose(); - } + static GlobalKey overlayKey = GlobalKey(); + final router = HomeRouter.main(); @override Widget build(BuildContext context) { @@ -121,7 +110,7 @@ class HomeState extends State { routeInformationParser: HomeRouteInformationParser(), routeInformationProvider: HomeRouteInformationProvider(), backButtonDispatcher: HomeRouteBackButtonDispatcher( - Router.of(context).backButtonDispatcher, + Router.of(context).backButtonDispatcher!, ), ), ), @@ -136,7 +125,7 @@ class HomeState extends State { /// Screen displayed when songs array is empty and searching is being performed class _SearchingSongsScreen extends StatelessWidget { - const _SearchingSongsScreen({Key key}) : super(key: key); + const _SearchingSongsScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { @@ -150,7 +139,7 @@ class _SearchingSongsScreen extends StatelessWidget { /// Screen displayed when no songs had been found class _SongsEmptyScreen extends StatefulWidget { - const _SongsEmptyScreen({Key key}) : super(key: key); + const _SongsEmptyScreen({Key? key}) : super(key: key); @override _SongsEmptyScreenState createState() => _SongsEmptyScreenState(); @@ -192,7 +181,7 @@ class _SongsEmptyScreenState extends State<_SongsEmptyScreen> { /// Screen displayed when there are not permissions class _NoPermissionsScreen extends StatefulWidget { - const _NoPermissionsScreen({Key key}) : super(key: key); + const _NoPermissionsScreen({Key? key}) : super(key: key); @override _NoPermissionsScreenState createState() => _NoPermissionsScreenState(); diff --git a/lib/routes/home_route/persistent_queue_route.dart b/lib/routes/home_route/persistent_queue_route.dart new file mode 100644 index 000000000..c7a2b8a92 --- /dev/null +++ b/lib/routes/home_route/persistent_queue_route.dart @@ -0,0 +1,816 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'dart:ui'; +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_sticky_header/flutter_sticky_header.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:sweyer/sweyer.dart'; +import 'package:sweyer/constants.dart' as Constants; + +class _ReorderOperation { + final int oldIndex; + final int newIndex; + _ReorderOperation(this.oldIndex, this.newIndex); +} + +class PersistentQueueRoute extends StatefulWidget { + PersistentQueueRoute({Key? key, required this.arguments}) + : super(key: key); + + final PersistentQueueArguments arguments; + + @override + _PersistentQueueRouteState createState() => _PersistentQueueRouteState(); +} + +class _PersistentQueueRouteState extends State with SelectionHandlerMixin { + final ScrollController scrollController = ScrollController(); + late AnimationController appBarController; + late PersistentQueue queue; + late List queueSongs; + late StreamSubscription _contentChangeSubscription; + + static const _appBarHeight = NFConstants.toolbarHeight - 8.0; + static const _artSize = 130.0; + static const _infoSectionTopPadding = 10.0; + static const _infoSectionBottomPadding = 24.0; + static const _infoSectionHeight = _artSize + _infoSectionTopPadding + _infoSectionBottomPadding; + + static const _buttonSectionButtonHeight = 38.0; + static const _buttonSectionBottomPadding = 12.0; + static const _buttonSectionHeight = _buttonSectionButtonHeight + _buttonSectionBottomPadding; + + /// Amount of pixels user always can scroll. + static const _alwaysCanScrollExtent = _infoSectionHeight + _buttonSectionHeight; + + bool get isAlbum => queue is Album; + bool get isPlaylist => queue is Playlist; + Album get album => queue as Album; + Playlist get playlist => queue as Playlist; + // List get songs => editing ? editingSongs : queueSongs; + List get songs => queueSongs; + + @override + void initState() { + super.initState(); + + _updateContent(true); + if (widget.arguments.editing) { + _startEditing(true); + } + + appBarController = AnimationController( + vsync: AppRouter.instance.navigatorKey.currentState!, + value: 1.0, + ); + scrollController.addListener(_handleScroll); + + initSelectionController(() => ContentSelectionController.create( + vsync: AppRouter.instance.navigatorKey.currentState!, + context: context, + closeButton: true, + ignoreWhen: () => playerRouteController.opened, + )); + + playerRouteController.addListener(_handlePlayerRouteController); + _contentChangeSubscription = ContentControl.state.onContentChange.listen(_handleContentChange); + } + + @override + void dispose() { + playerRouteController.removeListener(_handlePlayerRouteController); + _contentChangeSubscription.cancel(); + disposeSelectionController(); + appBarController.dispose(); + scrollController.removeListener(_handleScroll); + super.dispose(); + } + + void _handlePlayerRouteController() { + FocusManager.instance.primaryFocus?.unfocus(); + } + + void _handleScroll() { + appBarController.value = 1.0 - scrollController.offset / _infoSectionHeight; + } + + void _handleContentChange(void event) { + setState(() { + _updateContent(); + }); + } + + PersistentQueue? _findOriginalQueue() { + final queue = widget.arguments.queue; + if (queue is Album) { + return ContentControl.state.albums[queue.id]; + } else if (queue is Playlist) { + return ContentControl.state.playlists.firstWhereOrNull((el) => el == queue); + } + throw UnimplementedError(); + } + + /// Updates content. + /// If such playlist no longer exist, will automatically call [_quitBecauseNotFound]. + void _updateContent([bool init = false]) { + final queue = _findOriginalQueue(); + final queueSongs = queue?.songs; + + if (queue == null || queue is Album && queueSongs!.isEmpty) { + if (init) { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + if (mounted) { + _quitBecauseNotFound(); + } + }); + } else { + _quitBecauseNotFound(); + } + return; + } + this.queue = queue; + this.queueSongs = queueSongs!; + if (!init && editing) { + // If received an update, discard any edits and start editing anew + _startEditing(); + } + } + + void _quitBecauseNotFound() { + ContentControl.refetchAll(); + final l10n = getl10n(context); + String message = ''; + if (isAlbum) { + message = l10n.albumNotFound; + } else if (isPlaylist) { + message = l10n.playlistDoesNotExistError; + } else { + assert(false); + } + ShowFunctions.instance.showToast(msg: message); + Navigator.of(context).pop(); + } + + void _handleAddTracks() { + // With null it will be considered as "insert to the start" + Song? selectedSong = songs.lastOrNull; + bool tapped = false; + AppRouter.instance.goto(AppRoutes.selection.withArguments(SelectionArguments( + title: (context) => getl10n(context).addToPlaylist, + settingsPageBuilder: (context) { + final l10n = getl10n(context); + return Scaffold( + appBar: AppBar( + title: Text( + l10n.trackAfterWhichToInsert, + style: const TextStyle(fontSize: 20.0), + ), + leading: const NFBackButton(), + ), + body: StreamBuilder( + stream: ContentControl.state.onContentChange, + builder: (context, snapshot) { + final selectedTileColor = ThemeControl.theme.colorScheme.primary; + final selectedSplashColor = Constants.Theme.glowSplashColorOnContrast.auto; + late StateSetter setListState; + if (!tapped || selectedSong != null && !songs.contains(selectedSong)) { + // Set last song on start and when the songs update and selected song was deleted + selectedSong = songs.lastOrNull; + } + return StreamBuilder( + stream: ContentControl.state.onSongChange, + builder: (context, snapshot) => StatefulBuilder( + builder: (context, setState) { + setListState = setState; + return ContentListView( + list: songs, + enableDefaultOnTap: false, + leading: InListContentAction.song( + color: selectedSong == null ? selectedTileColor : null, + iconColor: selectedSong == null ? ThemeControl.theme.colorScheme.onPrimary : null, + textColor: selectedSong == null ? ThemeControl.theme.colorScheme.onPrimary : null, + splashColor: selectedSong == null ? selectedSplashColor : null, + icon: SweyerIcons.play_next, + text: l10n.insertAtTheBeginning, + onTap: () { + setListState(() { + tapped = true; + selectedSong = null; + }); + }, + ), + backgroundColorBuilder: (index) { + if (selectedSong == songs[index]) + return selectedTileColor; + return Colors.transparent; + }, + itemBuilder: (context, index, child) { + final theme = ThemeControl.theme; + final selected = selectedSong == songs[index]; + return Theme( + data: theme.copyWith( + splashColor: selected ? selectedSplashColor : theme.splashColor, + textTheme: theme.textTheme.copyWith( + // Title + headline6: theme.textTheme.headline6?.copyWith(color: selected ? theme.colorScheme.onPrimary : null), + // Subtitle + subtitle2: theme.textTheme.subtitle2?.copyWith(color: selected ? theme.colorScheme.onPrimary : null), + ) + ), + child: child, + ); + }, + onItemTap: (index) { + setListState(() { + tapped = true; + selectedSong = songs[index]; + }); + }, + ); + } + ), + ); + }, + ), + ); + }, + onSubmit: (entries) { + int index; + if (selectedSong == null) { + index = 1; + } else { + index = songs.indexOf(selectedSong!) + 2; + if (index <= 0) { + index = 1; + } + } + ContentControl.insertSongsInPlaylist( + index: index, + songs: ContentUtils.flatten(ContentUtils.selectionSortAndPack(entries).merged), + playlist: playlist, + ); + }, + ))); + } + + bool editing = false; + late List editingSongs; + late final List<_ReorderOperation> reorderOperations = []; + late final TextEditingController textEditingController = TextEditingController.fromValue( + TextEditingValue(text: queue.title), + ); + + bool get _canSubmit => _renamed || _reordered; + bool get _renamed => textEditingController.text.isNotEmpty && textEditingController.text != queue.title; + bool get _reordered => reorderOperations.isNotEmpty; + + void _startEditing([bool init = false]) { + if (isAlbum) { + assert(false); + return; + } + editing = true; + editingSongs = List.from(queueSongs); + textEditingController.text = queue.title; + reorderOperations.clear(); + if (!init) { + setState(() {}); + } + } + + Future _submitEditing() async { + if (_canSubmit) { + await Future.wait([ + _commitRename(), + _commitReorder(), + ]); + setState(() { + editing = false; + editingSongs.clear(); + reorderOperations.clear(); + }); + await ContentControl.refetchSongsAndPlaylists(); + } else { + setState(() { + editing = false; + editingSongs.clear(); + reorderOperations.clear(); + }); + } + } + + void _cancelEditing() { + setState(() { + editing = false; + textEditingController.text = queue.title; + reorderOperations.clear(); + }); + } + + Future _commitRename() async { + if (!_renamed) + return; + final correctedName = await ContentControl.renamePlaylist(playlist, textEditingController.text); + if (correctedName == null) { + _quitBecauseNotFound(); + } else { + textEditingController.text = correctedName; + } + } + + Future _commitReorder() async { + if (!_reordered) + return; + final songIds = List.from(playlist.songIds); + for (final operation in reorderOperations) { + final oldIndex = operation.oldIndex; + final newIndex = operation.newIndex; + final id = songIds.removeAt(oldIndex); + songIds.insert(newIndex, id); + await ContentControl.moveSongInPlaylist( + playlist: playlist, + from: oldIndex, + to: newIndex, + emitChangeEvent: false, + ); + } + final index = ContentControl.state.playlists.indexOf(playlist); + ContentControl.state.playlists[index] = playlist.copyWith(songIds: songIds); + } + + void _handleReorder(int oldIndex, int newIndex) { + if (oldIndex < newIndex) { + newIndex -= 1; + } + setState(() { + final song = songs.removeAt(oldIndex); + songs.insert(newIndex, song); + reorderOperations.add(_ReorderOperation(oldIndex, newIndex)); + }); + } + + Widget _buildInfo() { + final l10n = getl10n(context); + final textScaleFactor = MediaQuery.of(context).textScaleFactor; + const titleFontSize = 24.0; + final title = Text( + isPlaylist ? textEditingController.text : queue.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w900, + height: 1.0, + fontSize: titleFontSize, + ), + ); + return Padding( + padding: const EdgeInsets.only( + left: 13.0, + right: 10.0, + ), + child: Column( + children: [ + FadeTransition( + opacity: appBarController, + child: RepaintBoundary( + child: Container( + padding: const EdgeInsets.only( + top: _infoSectionTopPadding, + bottom: _infoSectionBottomPadding, + ), + child: Row( + children: [ + ContentArt( + size: 130.0, + defaultArtIcon: ContentUtils.persistentQueueIcon(queue), + defaultArtIconScale: 2, + assetHighRes: true, + assetScale: 1.5, + source: ContentArtSource.persistentQueue(queue), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 14.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isAlbum) + title + else if (isPlaylist) + Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 4.0), + child: Stack( + alignment: Alignment.bottomLeft, + children: [ + AnimatedSwitcher( + layoutBuilder: (Widget? currentChild, List previousChildren) { + return Stack( + alignment: Alignment.centerLeft, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: editing + ? AppTextField( + controller: textEditingController, + isDense: true, + contentPadding: const EdgeInsets.only(top: -9.0, bottom: -6.0), + textStyle: const TextStyle( + fontSize: 24.0, + fontWeight: FontWeight.w800, + decoration: TextDecoration.underline, + ), + hintStyle: const TextStyle(fontSize: 22.0, height: 1.1, fontWeight: FontWeight.w800), + ) + : SizedBox( + height: titleFontSize * textScaleFactor, + child: title, + ), + ), + ], + ), + ), + if (isAlbum) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ArtistWidget( + artist: album.artist, + overflow: TextOverflow.clip, + textStyle: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 15.0, + color: ThemeControl.theme.colorScheme.onBackground, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 3.0, bottom: 3.0), + child: Text( + ContentUtils.joinDot([ + if (isAlbum) + l10n.album + else + l10n.playlist, + if (isAlbum) + album.year + else + l10n.contentsPluralWithCount(queue.length).toLowerCase(), + ContentUtils.bulkDuration(songs), + ]), + style: TextStyle( + color: ThemeControl.theme.textTheme.subtitle2!.color, + height: 1.2, + fontWeight: FontWeight.w900, + fontSize: 14.0, + ), + ), + ), + if (isPlaylist && !selectionRoute) + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 240), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: Row( + key: ValueKey(editing), + children: !editing + ? [ + _ActionIconButton( + icon: const Icon(Icons.edit_rounded), + iconSize: 20.0, + onPressed: selectionController.inSelection ? null : _startEditing, + ), + _ActionIconButton( + icon: const Icon(Icons.add_rounded), + iconSize: 25.0, + onPressed: selectionController.inSelection ? null : _handleAddTracks, + ), + ] + : [ + _ActionIconButton( + icon: const Icon(Icons.close_rounded), + onPressed: _cancelEditing, + ), + AnimatedBuilder( + animation: textEditingController, + builder: (context, child) => _ActionIconButton( + icon: const Icon(Icons.done_rounded), + onPressed: _canSubmit ? _submitEditing : null, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + bottom: _buttonSectionBottomPadding, + // Compensate the padding difference up the tree + right: 3.0 + ), + child: SizedBox( + height: _buttonSectionButtonHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ShuffleQueueButton( + onPressed: songs.isEmpty ? null : () { + ContentControl.setOriginQueue( + origin: queue, + songs: songs, + shuffled: true, + ); + MusicPlayer.instance.setSong(ContentControl.state.queues.current.songs[0]); + MusicPlayer.instance.play(); + if (!selectionController.inSelection) + playerRouteController.open(); + }, + ), + ), + const SizedBox(width: 16.0), + Expanded( + child: PlayQueueButton( + onPressed: songs.isEmpty ? null : () { + ContentControl.setOriginQueue(origin: queue, songs: songs); + MusicPlayer.instance.setSong(songs[0]); + MusicPlayer.instance.play(); + if (!selectionController.inSelection) + playerRouteController.open(); + }, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + final theme = ThemeControl.theme; + + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: Scaffold( + resizeToAvoidBottomInset: false, + body: LayoutBuilder( + builder: (context, constraints) { + final mediaQuery = MediaQuery.of(context); + final showAddSongsAction = isPlaylist && !selectionRoute; + /// The height to add at the end of the scroll view to make the top info part of the route + /// always be fully scrollable, even if there's not enough items for that. + final additionalHeight = constraints.maxHeight - + _appBarHeight - + AppBarBorder.height - + mediaQuery.padding.top - + kSongTileHeight * songs.length - + (showAddSongsAction ? kSongTileHeight : 0.0); // InListContentAction + + return ScrollConfiguration( + behavior: const GlowlessScrollBehavior(), + child: StreamBuilder( + stream: ContentControl.state.onSongChange, + builder: (context, snapshot) => CustomScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + controller: scrollController, + slivers: [ + AnimatedBuilder( + animation: appBarController, + child: const NFBackButton(), + builder: (context, child) => SliverAppBar( + pinned: true, + elevation: 0.0, + automaticallyImplyLeading: false, + toolbarHeight: _appBarHeight, + leading: child, + titleSpacing: 0.0, + backgroundColor: appBarController.isDismissed + ? theme.colorScheme.background + : theme.colorScheme.background.withOpacity(0.0), + title: AnimationSwitcher( + animation: CurvedAnimation( + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + parent: selectionController.animation, + ), + child1: AnimatedOpacity( + opacity: 1.0 - appBarController.value > 0.35 + ? 1.0 + : 0.0, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 400), + child: Text(queue.title), + ), + child2: SelectionCounter(controller: selectionController), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(left: 5.0, right: 5.0), + child: AnimationSwitcher( + animation: CurvedAnimation( + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + parent: selectionController.animation, + ), + builder2: SelectionAppBar.defaultSelectionActionsBuilder, + child1: const SizedBox.shrink(), + child2: Row(children: [ + if (isPlaylist && !selectionRoute) + RemoveFromPlaylistSelectionAction(playlist: playlist, controller: selectionController), + SelectAllSelectionAction( + controller: selectionController, + entryFactory: (content, index) => SelectionEntry.fromContent( + content: content, + index: index, + context: context, + ), + getAll: () => songs, + ), + ]), + ), + ), + ], + ), + ), + + SliverToBoxAdapter( + child: _buildInfo(), + ), + + SliverStickyHeader( + overlapsContent: false, + header: AnimatedBuilder( + animation: appBarController, + builder: (context, child) => AppBarBorder( + shown: scrollController.offset > _alwaysCanScrollExtent, + ), + ), + sliver: MultiSliver( + children: [ + ContentListView.reorderableSliver( + list: songs, + selectionController: selectionController, + reorderingEnabled: editing, + onReorder: _handleReorder, + currentTest: (index) { + final song = songs[index]; + return ContentUtils.originIsCurrent(queue) && + song.sourceId == ContentControl.state.currentSong.sourceId && + (!isPlaylist || (isPlaylist && song.duplicationIndex == ContentControl.state.currentSong.duplicationIndex)); + }, + songTileVariant: isAlbum ? SongTileVariant.number : SongTileVariant.albumArt, + enableDefaultOnTap: !editing, + onItemTap: editing ? null : (index) => ContentControl.setOriginQueue( + origin: queue, + songs: songs, + ), + ), + if (showAddSongsAction) + SliverToBoxAdapter( + child: InListContentAction.song( + onTap: editing || selectionController.inSelection ? null : _handleAddTracks, + icon: Icons.add_rounded, + text: '${l10n.add} ${l10n.tracks.toLowerCase()}', + ), + ), + ], + ), + ), + + if (additionalHeight > 0) + SliverToBoxAdapter( + child: Container( + height: additionalHeight, + alignment: Alignment.center, + child: songs.isNotEmpty || showAddSongsAction ? null : Padding( + padding: const EdgeInsets.only(bottom: _alwaysCanScrollExtent + 30.0), + child: Text( + l10n.nothingHere, + style: TextStyle( + fontWeight: FontWeight.w700, + color: ThemeControl.theme.hintColor, + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} + +class _ActionIconButton extends StatefulWidget { + const _ActionIconButton({ + Key? key, + required this.icon, + required this.onPressed, + this.iconSize = 21.0, + }) : super(key: key); + + final Widget icon; + final VoidCallback? onPressed; + final double iconSize; + + @override + State<_ActionIconButton> createState() => _ActionIconButtonState(); +} + +class _ActionIconButtonState extends State<_ActionIconButton> with SingleTickerProviderStateMixin { + late final controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + ); + late final colorAnimation = ColorTween( + begin: ThemeControl.theme.colorScheme.onSurface.withOpacity(0.12), + end: ThemeControl.theme.iconTheme.color, + ).animate(CurvedAnimation( + parent: controller, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + )); + + bool get enabled => widget.onPressed != null; + + @override + void initState() { + super.initState(); + if (enabled) { + controller.forward(); + } + } + + @override + void didUpdateWidget(covariant _ActionIconButton oldWidget) { + if (oldWidget.onPressed != widget.onPressed) { + if (enabled) { + controller.forward(); + } else { + controller.reverse(); + } + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, child) => IgnorePointer( + ignoring: const IgnoringStrategy( + reverse: true, + dismissed: true, + ).ask(controller), + child: NFIconButton( + size: 30.0, + iconSize: widget.iconSize, + icon: widget.icon, + onPressed: () { + widget.onPressed?.call(); + }, + color: colorAnimation.value, + ), + ), + ); + } +} diff --git a/lib/routes/home_route/player_route.dart b/lib/routes/home_route/player_route.dart index 3a8c70fa6..b79846389 100644 --- a/lib/routes/home_route/player_route.dart +++ b/lib/routes/home_route/player_route.dart @@ -5,8 +5,10 @@ import 'dart:async'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/physics.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; import 'package:flutter/material.dart'; @@ -18,33 +20,36 @@ final SpringDescription playerRouteSpringDescription = SpringDescription.withDam ); class PlayerRoute extends StatefulWidget { - const PlayerRoute({Key key}) : super(key: key); + const PlayerRoute({Key? key}) : super(key: key); @override _PlayerRouteState createState() => _PlayerRouteState(); } class _PlayerRouteState extends State - with SingleTickerProviderStateMixin, SelectionHandler { + with SingleTickerProviderStateMixin, SelectionHandlerMixin { final _queueTabKey = GlobalKey<_QueueTabState>(); - List _tabs; - SlidableController controller; - SharedAxisTabController tabController; - ContentSelectionController> selectionController; + late List _tabs; + late SlidableController controller; + late SharedAxisTabController tabController; - Animation _queueTabAnimation; + Animation? _queueTabAnimation; SlideDirection slideDirection = SlideDirection.up; @override void initState() { super.initState(); - selectionController = ContentSelectionController.forContent( - this, - counter: true, - closeButton: true, - ) - ..addListener(handleSelection) - ..addStatusListener(handleSelectionStatus); + initSelectionController(() => ContentSelectionController.create( + vsync: this, + context: context, + closeButton: true, + additionalPlayActionsBuilder: (context) => const [ + RemoveFromQueueSelectionAction(), + ], + ), + listenStatus: true, + ); + _tabs = [ const _MainTab(), _QueueTab( @@ -55,9 +60,9 @@ class _PlayerRouteState extends State tabController = SharedAxisTabController(length: 2); tabController.addListener(() { if (tabController.index == 0) { - _queueTabKey.currentState.opened = false; + _queueTabKey.currentState!.opened = false; } else if (tabController.index == 1) { - _queueTabKey.currentState.opened = true; + _queueTabKey.currentState!.opened = true; } }); controller = playerRouteController; @@ -110,13 +115,14 @@ class _PlayerRouteState extends State if (tabController.index == 0 && status == AnimationStatus.dismissed) { /// When the main tab is fully visible and the queue tab is not, /// reset the scroll controller. - _queueTabKey.currentState.jumpOnTabChange(); + _queueTabKey.currentState!.jumpOnTabChange(); } } @override Widget build(BuildContext context) { final backgroundColor = ThemeControl.theme.colorScheme.background; + final screenHeight = MediaQuery.of(context).size.height; return Slidable( controller: controller, start: 1.0 - kSongTileHeight / screenHeight, @@ -137,7 +143,7 @@ class _PlayerRouteState extends State if (child is _QueueTab) { if (animation != _queueTabAnimation) { if (_queueTabAnimation != null) { - _queueTabAnimation.removeStatusListener(_handleQueueTabAnimationStatus); + _queueTabAnimation!.removeStatusListener(_handleQueueTabAnimationStatus); } _queueTabAnimation = animation; animation.addStatusListener( @@ -151,9 +157,7 @@ class _PlayerRouteState extends State ); }, ), - TrackPanel( - onTap: controller.open, - ), + TrackPanel(onTap: controller.open), ], ), ), @@ -163,66 +167,44 @@ class _PlayerRouteState extends State class _QueueTab extends StatefulWidget { _QueueTab({ - Key key, - @required this.selectionController, + Key? key, + required this.selectionController, }) : super(key: key); - final SelectionController selectionController; + final ContentSelectionController selectionController; @override _QueueTabState createState() => _QueueTabState(); } -class _QueueTabState extends State<_QueueTab> - with SingleTickerProviderStateMixin, SelectionHandler { +class _QueueTabState extends State<_QueueTab> with SelectionHandlerMixin { static const double appBarHeight = 81.0; - /// How much tracks to list end to apply [endScrollAlignment] - int edgeOffset; - int songsPerScreen; - - /// Min index to start from applying [endScrollAlignment]. - int get edgeScrollIndex => queueLength - 1 - edgeOffset; - int prevSongIndex = ContentControl.state.currentSongIndex; - int get queueLength => ContentControl.state.queues.current.length; - /// This is set in parent via global key bool opened = false; final ScrollController scrollController = ScrollController(); /// A bool var to disable show/hide in tracklist controller listener when manual [scrollToSong] is performing - StreamSubscription _songChangeSubscription; - StreamSubscription _contentChangeSubscription; + late StreamSubscription _songChangeSubscription; + late StreamSubscription _contentChangeSubscription; QueueType get type => ContentControl.state.queues.type; - bool get isAlbum => ContentControl.state.queues.persistent is Album; - Album get album { - assert(isAlbum); - return ContentControl.state.queues.persistent as Album; - } @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { jumpToSong(ContentControl.state.currentSongIndex); }); - songsPerScreen = (screenHeight / kSongTileHeight).ceil() - 2; - edgeOffset = (screenHeight / kSongTileHeight / 2).ceil(); - - widget.selectionController - ..addListener(handleSelection) - ..addStatusListener(handleSelectionStatus); + widget.selectionController.addListener(handleSelection); _contentChangeSubscription = ContentControl.state.onContentChange.listen((event) async { if (ContentControl.state.allSongs.isNotEmpty) { - // Reset value when queue changes - prevSongIndex = ContentControl.state.currentSongIndex; setState(() {/* update ui list as data list may have changed */}); if (!opened) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { // Jump when tracklist changes (e.g. shuffle happened) jumpToSong(); // Post framing it because we need to be sure that list gets updated before we jump. @@ -243,9 +225,7 @@ class _QueueTabState extends State<_QueueTab> @override void dispose() { - widget.selectionController - ..removeListener(handleSelection) - ..removeStatusListener(handleSelectionStatus); + widget.selectionController.removeListener(handleSelection); _contentChangeSubscription.cancel(); _songChangeSubscription.cancel(); super.dispose(); @@ -254,13 +234,14 @@ class _QueueTabState extends State<_QueueTab> /// Scrolls to current song. /// /// If optional [index] is provided - scrolls to it. - Future scrollToSong([int index]) async { + Future scrollToSong([int? index]) async { index ??= ContentControl.state.currentSongIndex; final extent = index * kSongTileHeight; final pixels = scrollController.position.pixels; final min = scrollController.position.minScrollExtent; final max = scrollController.position.maxScrollExtent; final delta = (extent - pixels).abs(); + final screenHeight = MediaQuery.of(context).size.height; if (delta >= screenHeight) { final directionForward = extent > pixels; if (directionForward) { @@ -279,7 +260,9 @@ class _QueueTabState extends State<_QueueTab> /// Jumps to current song. /// /// If optional [index] is provided - jumps to it. - void jumpToSong([int index]) { + void jumpToSong([int? index]) { + if (!mounted) + return; index ??= ContentControl.state.currentSongIndex; final min = scrollController.position.minScrollExtent; final max = scrollController.position.maxScrollExtent; @@ -294,41 +277,59 @@ class _QueueTabState extends State<_QueueTab> void _handleTitleTap() { switch (type) { case QueueType.searched: - final query = ContentControl.state.queues.searchQuery; - assert(query != null); - if (query != null) { - ShowFunctions.instance.showSongsSearch( - query: query, - openKeyboard: false - ); - playerRouteController.close(); - SearchHistory.instance.save(query); - } + final query = ContentControl.state.queues.searchQuery!; + ShowFunctions.instance.showSongsSearch( + context, + query: query, + openKeyboard: false, + ); + SearchHistory.instance.add(query); return; - case QueueType.persistent: - if (isAlbum) { - HomeRouter.instance.goto(HomeRoutes.factory.album(album)); - } else { - throw InvalidCodePathError(); - } + case QueueType.origin: + final origin = ContentControl.state.queues.origin!; + if (origin is Album) + HomeRouter.instance.goto(HomeRoutes.factory.content(origin)); + else if (origin is Playlist) + HomeRouter.instance.goto(HomeRoutes.factory.content(origin)); + else if (origin is Artist) + HomeRouter.instance.goto(HomeRoutes.factory.content(origin)); + else + throw UnimplementedError; return; - case QueueType.all: + case QueueType.allSongs: + case QueueType.allAlbums: + case QueueType.allPlaylists: + case QueueType.allArtists: case QueueType.arbitrary: return; - default: - throw InvalidCodePathError(); } } + double _getBorderRadius(SongOrigin origin) { + if (origin is PersistentQueue) + return 8.0; + else if (origin is Artist) + return kArtistTileArtSize; + throw UnimplementedError(); + } + List _getQueueType(AppLocalizations l10n) { final List text = []; switch (ContentControl.state.queues.type) { - case QueueType.all: + case QueueType.allSongs: text.add(TextSpan(text: l10n.allTracks)); break; + case QueueType.allAlbums: + text.add(TextSpan(text: l10n.allAlbums)); + break; + case QueueType.allPlaylists: + text.add(TextSpan(text: l10n.allPlaylists)); + break; + case QueueType.allArtists: + text.add(TextSpan(text: l10n.allArtists)); + break; case QueueType.searched: - final query = ContentControl.state.queues.searchQuery; - assert(query != null); + final query = ContentControl.state.queues.searchQuery!; text.add(TextSpan( text: '${l10n.found} ${l10n.byQuery.toLowerCase()} ', )); @@ -340,45 +341,52 @@ class _QueueTabState extends State<_QueueTab> ), )); break; - case QueueType.persistent: - if (isAlbum) { + case QueueType.origin: + final origin = ContentControl.state.queues.origin!; + if (origin is Album) { text.add(TextSpan(text: '${l10n.album} ')); text.add(TextSpan( - text: album.album + _getYear(), + text: origin.nameDotYear, + style: TextStyle( + fontWeight: FontWeight.w800, + color: ThemeControl.theme.colorScheme.onBackground, + ), + )); + } else if (origin is Playlist) { + text.add(TextSpan(text: '${l10n.playlist} ')); + text.add(TextSpan( + text: origin.name, + style: TextStyle( + fontWeight: FontWeight.w800, + color: ThemeControl.theme.colorScheme.onBackground, + ), + )); + } else if (origin is Artist) { + text.add(TextSpan(text: '${l10n.artist} ')); + text.add(TextSpan( + text: ContentUtils.localizedArtist(origin.artist, l10n), style: TextStyle( fontWeight: FontWeight.w800, color: ThemeControl.theme.colorScheme.onBackground, ), )); } else { - throw InvalidCodePathError(); + throw UnimplementedError(); } break; case QueueType.arbitrary: - text.add(TextSpan(text: - l10n.arbitraryQueueOrigin(ContentControl.state.queues.arbitraryQueueOrigin) - ?? l10n.arbitraryQueue, - )); + text.add(TextSpan(text: l10n.arbitraryQueue)); break; - default: - throw InvalidCodePathError(); } return text; } - String _getYear() { - final year = album.year; - if (year == null) { - return ''; - } - return ' • $year'; - } - Text _buildTitleText(List text) { return Text.rich( TextSpan(children: text), + key: ValueKey(text.fold('', (prev, el) => prev + el.text!)), overflow: TextOverflow.ellipsis, - style: ThemeControl.theme.textTheme.subtitle2.copyWith( + style: ThemeControl.theme.textTheme.subtitle2!.copyWith( fontSize: 14.0, height: 1.0, fontWeight: FontWeight.w700, @@ -386,11 +394,46 @@ class _QueueTabState extends State<_QueueTab> ); } + AnimatedCrossFade _crossFade(bool showFirst, Widget firstChild, Widget secondChild) { + return AnimatedCrossFade( + crossFadeState: showFirst + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 400), + layoutBuilder: (Widget topChild, Key topChildKey, Widget bottomChild, Key bottomChildKey) { + // TODO: remove `layoutBuilder` build when https://github.com/flutter/flutter/issues/82614 is resolved + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + key: bottomChildKey, + top: 0.0, + left: 0.0, + bottom: 0.0, + child: bottomChild, + ), + Positioned( + key: topChildKey, + child: topChild, + ), + ], + ); + }, + firstCurve: Curves.easeOutCubic, + secondCurve: Curves.easeOutCubic, + sizeCurve: Curves.easeOutCubic, + alignment: Alignment.centerLeft, + firstChild: firstChild, + secondChild: secondChild, + ); + } + @override Widget build(BuildContext context) { final currentSongIndex = ContentControl.state.currentSongIndex; final l10n = getl10n(context); - final horizontalPadding = isAlbum ? 12.0 : 20.0; + final theme = ThemeControl.theme; + final origin = ContentControl.state.queues.origin; final topScreenPadding = MediaQuery.of(context).padding.top; final appBarHeightWithPadding = appBarHeight + topScreenPadding; final fadeAnimation = CurvedAnimation( @@ -399,98 +442,140 @@ class _QueueTabState extends State<_QueueTab> ); final appBar = Material( elevation: 2.0, - color: ThemeControl.theme.appBarTheme.color, + color: theme.appBarTheme.color, child: Container( height: appBarHeight, margin: EdgeInsets.only(top: topScreenPadding), - padding: EdgeInsets.only( - left: horizontalPadding, - right: horizontalPadding, + padding: const EdgeInsets.only( top: 24.0, bottom: 0.0, ), - child: AnimatedBuilder( - animation: playerRouteController, - builder: (context, child) => FadeTransition( - opacity: fadeAnimation, - child: child, - ), - child: GestureDetector( - onTap: _handleTitleTap, - child: Row( - children: [ - if (isAlbum) - Padding( - padding: const EdgeInsets.only(bottom: 12.0, right: 10.0), - child: AlbumArt( - source: AlbumArtSource( - path: album.albumArt, - contentUri: album.contentUri, - albumId: album.id, + child: FadeTransition( + opacity: fadeAnimation, + child: RepaintBoundary( + child: GestureDetector( + onTap: _handleTitleTap, + child: AnimationSwitcher( + animation: CurvedAnimation( + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + parent: widget.selectionController.animation, + ), + child2: Padding( + padding: const EdgeInsets.only( + bottom: 10.0, + left: 42.0, + right: 12.0, + ), + child: Row( + children: [ + SelectionCounter(controller: widget.selectionController), + const Spacer(), + SelectAllSelectionAction( + controller: widget.selectionController, + entryFactory: (content, index) => SelectionEntry.fromContent( + content: content, + index: index, + context: context, + ), + getAll: () => ContentControl.state.queues.current.songs, ), - borderRadius: 8, - size: kSongTileArtSize - 8.0, - ), + ], ), - Flexible( - child: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - l10n.upNext, - style: ThemeControl.theme.textTheme.headline6.copyWith( - fontSize: 24, - height: 1.2, - ), + ), + child1: Padding( + padding: EdgeInsets.only( + left: origin != null ? 12.0 : 20.0, + right: 12.0, + ), + child: Row( + children: [ + if (origin != null) + Padding( + padding: const EdgeInsets.only(bottom: 12.0, right: 10.0), + child: ContentArt( + source: ContentArtSource.origin(origin), + borderRadius: _getBorderRadius(origin), + size: kSongTileArtSize - 8.0, ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeInCubic, - child: !ContentControl.state.queues.modified - ? const SizedBox.shrink() - : const Padding( + ), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + l10n.upNext, + style: theme.textTheme.headline6!.copyWith( + fontSize: 24, + height: 1.2, + ), + ), + _crossFade( + !ContentControl.state.queues.modified, + const SizedBox(height: 18.0), + const Padding( padding: EdgeInsets.only(left: 5.0), child: Icon( Icons.edit_rounded, size: 18.0, ), - ), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeInCubic, - child: !ContentControl.state.queues.shuffled - ? const SizedBox.shrink() - : const Padding( + ) + ), + _crossFade( + !ContentControl.state.queues.shuffled, + const SizedBox(height: 20.0), + const Padding( padding: EdgeInsets.only(left: 2.0), child: Icon( Icons.shuffle_rounded, size: 20.0, ), ), - ), - ], - ), - Row( - children: [ - Flexible(child: _buildTitleText(_getQueueType(l10n))), - if (isAlbum || type == QueueType.searched) - Icon( - Icons.chevron_right_rounded, - size: 18.0, - color: ThemeControl.theme.textTheme.subtitle2.color, + ), + ], ), - ], + Row( + children: [ + Flexible( + child: AnimatedSwitcher( + layoutBuilder: (Widget? currentChild, List previousChildren) { + return Stack( + alignment: Alignment.centerLeft, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + duration: const Duration(milliseconds: 400), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _buildTitleText(_getQueueType(l10n)), + ), + ), + if (origin != null || type == QueueType.searched) + Icon( + Icons.chevron_right_rounded, + size: 18.0, + color: theme.textTheme.subtitle2!.color, + ), + ], + ), + ], + ), ), + Column( + children: const [ + _SaveQueueAsPlaylistAction(), + ], + ) ], ), ), - ], + ), ), ), ), @@ -503,7 +588,7 @@ class _QueueTabState extends State<_QueueTab> children: [ Padding( padding: EdgeInsets.only(top: appBarHeightWithPadding), - child: ValueListenableBuilder( + child: ValueListenableBuilder( valueListenable: ContentControl.state.selectionNotifier, builder: (context, value, child) { return ContentListView( @@ -514,10 +599,10 @@ class _QueueTabState extends State<_QueueTab> top: 4.0, bottom: value == null ? 0.0 : kSongTileHeight + 4.0, ), - songTileVariant: ContentControl.state.queues.persistent is Album + songTileVariant: ContentControl.state.queues.origin is Album ? SongTileVariant.number : SongTileVariant.albumArt, - songClickBehavior: SongClickBehavior.playPause, + songTileClickBehavior: SongTileClickBehavior.playPause, currentTest: (index) => index == currentSongIndex, alwaysShowScrollbar: true, ); @@ -537,7 +622,7 @@ class _QueueTabState extends State<_QueueTab> } class _MainTab extends StatefulWidget { - const _MainTab({Key key}) : super(key: key); + const _MainTab({Key? key}) : super(key: key); @override _MainTabState createState() => _MainTabState(); @@ -546,9 +631,10 @@ class _MainTab extends StatefulWidget { class _MainTabState extends State<_MainTab> { @override Widget build(BuildContext context) { + final theme = ThemeControl.theme; final animation = ColorTween( - begin: ThemeControl.theme.colorScheme.secondary, - end: ThemeControl.theme.colorScheme.background, + begin: theme.colorScheme.secondary, + end: theme.colorScheme.background, ).animate(playerRouteController); final fadeAnimation = CurvedAnimation( curve: const Interval(0.6, 1.0), @@ -556,32 +642,38 @@ class _MainTabState extends State<_MainTab> { ); return AnimatedBuilder( animation: playerRouteController, - builder: (context, child) => NFPageBase( + builder: (context, child) => Scaffold( + body: child, resizeToAvoidBottomInset: false, - enableElevation: false, backgroundColor: animation.value, - appBarBackgroundColor: Colors.transparent, - backButton: FadeTransition( - opacity: fadeAnimation, - child: NFIconButton( - icon: const Icon(Icons.keyboard_arrow_down_rounded), - size: 40.0, - onPressed: playerRouteController.close, - ), - ), - actions: [ - ValueListenableBuilder( - valueListenable: ContentControl.devMode, - builder: (context, value, child) => value - ? child - : const SizedBox.shrink(), - child: FadeTransition( - opacity: fadeAnimation, - child: const _InfoButton(), + appBar: AppBar( + elevation: 0.0, + backgroundColor: Colors.transparent, + leading: FadeTransition( + opacity: fadeAnimation, + child: RepaintBoundary( + child: NFIconButton( + icon: const Icon(Icons.keyboard_arrow_down_rounded), + size: 40.0, + onPressed: playerRouteController.close, + ), ), ), - ], - child: child, + actions: [ + ValueListenableBuilder( + valueListenable: Prefs.devMode, + builder: (context, value, child) => value + ? child! + : const SizedBox.shrink(), + child: FadeTransition( + opacity: fadeAnimation, + child: const RepaintBoundary( + child: _InfoButton(), + ), + ), + ), + ], + ), ), child: Center( child: Column( @@ -613,7 +705,7 @@ class _MainTabState extends State<_MainTab> { } class _PlaybackButtons extends StatelessWidget { - const _PlaybackButtons({Key key}) : super(key: key); + const _PlaybackButtons({Key? key}) : super(key: key); static const buttonMargin = 18.0; @override @@ -669,7 +761,7 @@ class _PlaybackButtons extends StatelessWidget { } class _InfoButton extends StatelessWidget { - const _InfoButton({Key key}) : super(key: key); + const _InfoButton({Key? key}) : super(key: key); @override Widget build(BuildContext context) { @@ -681,12 +773,11 @@ class _InfoButton extends StatelessWidget { size: 40.0, onPressed: () { String songInfo = ContentControl.state.currentSong - ?.toMap() + .toMap() .toString() .replaceAll(r', ', ',\n'); - if (songInfo != null) { - songInfo = songInfo.substring(1, songInfo.length - 1); - } + // Remove curly braces + songInfo = songInfo.substring(1, songInfo.length - 1); ShowFunctions.instance.showAlert( context, title: Text( @@ -694,11 +785,22 @@ class _InfoButton extends StatelessWidget { textAlign: TextAlign.center, ), contentPadding: defaultAlertContentPadding.copyWith(top: 4.0), - content: SelectableText( - songInfo ?? 'null', - style: const TextStyle(fontSize: 13.0), - selectionControls: NFTextSelectionControls( - backgroundColor: ThemeControl.theme.colorScheme.background, + content: PrimaryScrollController( + controller: ScrollController(), + child: Builder( + builder: (context) { + return AppScrollbar( + child: SingleChildScrollView( + child: SelectableText( + songInfo, + style: const TextStyle(fontSize: 13.0), + selectionControls: NFTextSelectionControls( + backgroundColor: ThemeControl.theme.colorScheme.background, + ), + ), + ), + ); + } ), ), additionalActions: [ @@ -713,31 +815,95 @@ class _InfoButton extends StatelessWidget { /// A widget that displays all information about current song class _TrackShowcase extends StatefulWidget { - const _TrackShowcase({Key key}) : super(key: key); + const _TrackShowcase({Key? key}) : super(key: key); @override _TrackShowcaseState createState() => _TrackShowcaseState(); } -class _TrackShowcaseState extends State<_TrackShowcase> { - StreamSubscription _songChangeSubscription; +class _TrackShowcaseState extends State<_TrackShowcase> with TickerProviderStateMixin { + late StreamSubscription _songChangeSubscription; + late AnimationController controller; + late AnimationController fadeController; + Widget? art; + + static const defaultDuration = Duration(milliseconds: 160); + + /// When `true`, should use fade animation instaed of scale. + bool get useFade => playerRouteController.value == 0.0; @override void initState() { super.initState(); + controller = AnimationController( + vsync: this, + duration: defaultDuration, + ); + fadeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + ); + controller.addStatusListener(_handleStatus); _songChangeSubscription = ContentControl.state.onSongChange.listen((event) async { + if (useFade) { + fadeController.reset(); + fadeController.forward(); + } else { + controller.forward(); + } setState(() {/* update track in ui */}); }); } + void _handleStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) + controller.reverse(); + } + @override void dispose() { + controller.dispose(); + fadeController.dispose(); _songChangeSubscription.cancel(); super.dispose(); } + Widget _fade(Widget child) { + final fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + curve: const Interval(0.4, 1.0, curve: Curves.easeOut), + reverseCurve: const Interval(0.4, 1.0, curve: Curves.easeIn), + parent: fadeController, + )); + final scaleAnimation = Tween( + begin: 1.06, + end: 1.0, + ).animate(CurvedAnimation( + curve: const Interval(0.5, 1.0, curve: Curves.easeOutCubic), + reverseCurve:const Interval(0.5, 1.0, curve: Curves.easeInCubic), + parent: fadeController, + )); + return FadeTransition( + opacity: fadeAnimation, + child: ScaleTransition( + scale: scaleAnimation, + child: child, + ), + ); + } + @override Widget build(BuildContext context) { + final animation = Tween( + begin: 1.0, + end: 0.91, + ).animate(CurvedAnimation( + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + parent: controller, + )); final currentSong = ContentControl.state.currentSong; return Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -745,7 +911,7 @@ class _TrackShowcaseState extends State<_TrackShowcase> { Padding( padding: const EdgeInsets.symmetric(horizontal: 30.0), child: NFMarquee( - key: ValueKey(ContentControl.state.currentSong.id), + key: ValueKey(currentSong), fontWeight: FontWeight.w900, text: currentSong.title, fontSize: 20.0, @@ -768,14 +934,30 @@ class _TrackShowcaseState extends State<_TrackShowcase> { top: 10.0, ), child: LayoutBuilder( - builder: (context, constraints) => AlbumArt.playerRoute( - size: constraints.maxWidth, - loadAnimationDuration: const Duration(milliseconds: 500), - source: AlbumArtSource( - path: currentSong.albumArt, - contentUri: currentSong.contentUri, - albumId: currentSong.albumId, - ), + builder: (context, constraints) => AnimatedBuilder( + animation: controller, + builder: (context, child) { + final newArt = ContentArt.playerRoute( + key: ValueKey(currentSong), + size: constraints.maxWidth, + loadAnimationDuration: Duration.zero, + source: ContentArtSource.song(currentSong), + ); + if (art == null || + controller.status == AnimationStatus.reverse || controller.status == AnimationStatus.dismissed || + useFade) { + art = newArt; + } + return ScaleTransition( + scale: animation, + child: Stack( + children: [ + Opacity(opacity: useFade ? 0.0 : 1.0, child: newArt), + _fade(art!), + ], + ), + ); + }, ), ), ), @@ -783,3 +965,83 @@ class _TrackShowcaseState extends State<_TrackShowcase> { ); } } + +class _SaveQueueAsPlaylistAction extends StatefulWidget { + const _SaveQueueAsPlaylistAction({Key? key}) : super(key: key); + + @override + State<_SaveQueueAsPlaylistAction> createState() => _SaveQueueAsPlaylistActionState(); +} + +class _SaveQueueAsPlaylistActionState extends State<_SaveQueueAsPlaylistAction> with TickerProviderStateMixin { + Future _handleTap() async { + + final l10n = getl10n(context); + final theme = ThemeControl.theme; + final songs = ContentControl.state.queues.current.songs; + final playlist = await ShowFunctions.instance.showCreatePlaylist(this, context); + + if (playlist != null) { + bool success = false; + try { + await ContentControl.insertSongsInPlaylist( + index: 1, + songs: songs, + playlist: playlist, + ); + success = true; + } catch (ex, stack) { + FirebaseCrashlytics.instance.recordError( + ex, + stack, + reason: 'in _SaveQueueAsPlaylistActionState', + ); + } finally { + if (success) { + final key = GlobalKey(); + NFSnackbarController.showSnackbar(NFSnackbarEntry( + globalKey: key, + important: true, + child: NFSnackbar( + leading: Icon(Icons.done_rounded, color: theme.colorScheme.onPrimary), + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + top: 0.0, + bottom: 0.0, + ), + title: Text(l10n.saved, style: TextStyle(fontSize: 15.0, color: theme.colorScheme.onPrimary)), + trailing: AppButton( + text: l10n.view, + onPressed: () { + key.currentState!.close(); + HomeRouter.instance.goto(HomeRoutes.factory.content(playlist)); + }, + ), + ), + )); + } else { + NFSnackbarController.showSnackbar(NFSnackbarEntry( + important: true, + child: NFSnackbar( + leading: Icon(Icons.error_outline_rounded, color: theme.colorScheme.onError), + title: Text(l10n.oopsErrorOccurred, style: TextStyle(fontSize: 15.0, color: theme.colorScheme.onError)), + color: theme.colorScheme.error, + ), + )); + } + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + return NFIconButton( + icon: const Icon(Icons.queue_rounded), + iconSize: 23.0, + tooltip: l10n.saveQueueAsPlaylist, + onPressed: _handleTap, + ); + } +} \ No newline at end of file diff --git a/lib/routes/home_route/search_route.dart b/lib/routes/home_route/search_route.dart index 41cb74ea3..958fb1678 100644 --- a/lib/routes/home_route/search_route.dart +++ b/lib/routes/home_route/search_route.dart @@ -7,13 +7,14 @@ *--------------------------------------------------------------------------------------------*/ import 'dart:async'; -import 'dart:math' as math; import 'package:animations/animations.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart' hide SearchDelegate; -import 'package:collection/collection.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + import 'package:sweyer/constants.dart' as Constants; import 'package:sweyer/sweyer.dart'; @@ -23,7 +24,9 @@ class _Notifier extends ChangeNotifier { } } -class SearchDelegate { +class ContentSearchDelegate { + ContentSearchDelegate(); + /// Whether to automatically open the keyboard when page is opened. bool autoKeyboard = false; @@ -33,11 +36,14 @@ class SearchDelegate { _setStateNotifier.notify(); } + /// Used in [HomeRouter.drawerCanBeOpened]. + bool get chipsBarDragged => _chipsBarDragged; + bool _chipsBarDragged = false; + /// The current query string shown in the [AppBar]. String get query => _queryTextController.text; final TextEditingController _queryTextController = TextEditingController(); set query(String value) { - assert(query != null); _queryTextController.text = value; } } @@ -45,13 +51,15 @@ class SearchDelegate { /// Search results container. class _Results { - Map> map = { - Song: [], - Album: [], - }; + ContentMap> map = ContentMap({ + for (final contentType in Content.enumerate()) + contentType: [], + }); - List get songs => map[Song]; - List get albums => map[Album]; + List get songs => map.getValue().cast(); + List get albums => map.getValue().cast(); + List get playlists => map.getValue().cast(); + List get artists => map.getValue().cast(); bool get empty => map.values.every((element) => element.isEmpty); bool get notEmpty => map.values.any((element) => element.isNotEmpty); @@ -62,23 +70,21 @@ class _Results { } } - void search(query) { - map[Song] = ContentControl.search(query); - map[Album] = ContentControl.search(query); + void search(String query) { + for (final contentType in Content.enumerate()) { + map.setValue( + ContentControl.search(query, contentType: contentType), + key: contentType, + ); + } } } class _SearchStateDelegate { - _SearchStateDelegate(this.searchDelegate) + _SearchStateDelegate(this.selectionController, this.searchDelegate) : scrollController = ScrollController(), - singleListScrollController = ScrollController(), - selectionController = ContentSelectionController.forContent( - AppRouter.instance.navigatorKey.currentState, - closeButton: true, - ignoreWhen: () => playerRouteController.opened || HomeRouter.instance.routes.last != HomeRoutes.search, - ) - { - selectionController.addListener(setState); + singleListScrollController = ScrollController() + { /// Initalize [prevQuery] and [trimmedQuery] values. onQueryChange(); } @@ -89,10 +95,9 @@ class _SearchStateDelegate { final ScrollController scrollController; final ScrollController singleListScrollController; final ContentSelectionController selectionController; - final SearchDelegate searchDelegate; + final ContentSearchDelegate searchDelegate; /// Used to check whether the body is scrolled. final ValueNotifier bodyScrolledNotifier = ValueNotifier(false); - final ValueNotifier contentTypeNotifier = ValueNotifier(null); _Results results = _Results(); String prevQuery = ''; String trimmedQuery = ''; @@ -102,11 +107,9 @@ class _SearchStateDelegate { scrollController.dispose(); singleListScrollController.dispose(); bodyScrolledNotifier.dispose(); - contentTypeNotifier.dispose(); - selectionController.dispose(); } - static _SearchStateDelegate _of(BuildContext context) { + static _SearchStateDelegate? _of(BuildContext context) { return _DelegateProvider.of(context).delegate; } @@ -116,17 +119,35 @@ class _SearchStateDelegate { searchDelegate.setState(); } + ValueListenable get onContentTypeChange => selectionController.onContentTypeChange; + /// Content type to filter results by. /// - /// When null results are displayed as list of sections, see [_ContentSection]. - Type get contentType => contentTypeNotifier.value; - set contentType(Type value) { - contentTypeNotifier.value = value; + /// When null results are displayed as list of sections, see [ContentSection]. + Type? get contentType => selectionController.primaryContentType; + set contentType(Type? value) { + selectionController.primaryContentType = value; bodyScrolledNotifier.value = false; + if (value != null) { + // Scroll to chip + ensureVisible( + chipContextMap.getValue(value), + duration: kTabScrollDuration, + alignment: 0.5, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + ); + } + } + + ContentMap chipContextMap = ContentMap(); + /// Saves chips context to be able to scroll it when [contentType] changes. + void registerChipContext(BuildContext context, Type contentType) { + chipContextMap.setValue(context, key: contentType); } void onSubmit() { - SearchHistory.instance.save(query); + + SearchHistory.instance.add(query); } void onQueryChange() { @@ -140,16 +161,16 @@ class _SearchStateDelegate { bodyScrolledNotifier.value = false; results.search(trimmedQuery); } - if (scrollController?.hasClients ?? false) + if (scrollController.hasClients) scrollController.jumpTo(0); - if (singleListScrollController?.hasClients ?? false) + if (singleListScrollController.hasClients) singleListScrollController.jumpTo(0); } prevQuery = trimmedQuery; } /// Handles tap to different content tiles. - VoidCallback getContentTileTapHandler([Type contentType]) { + void handleContentTap([Type? contentType]) { return contentPick( contentType: contentType, song: () { @@ -157,47 +178,155 @@ class _SearchStateDelegate { ContentControl.setSearchedQueue(query, results.songs); }, album: onSubmit, - ); + playlist: onSubmit, + artist: onSubmit, + )(); + } + + /// Scrolls the scrollables that enclose the given context so as to make the + /// given context visible. + /// + /// Copied from [Scrollable.ensureVisible]. + static Future ensureVisible( + BuildContext context, { + double alignment = 0.0, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, + }) { + final List> futures = >[]; + + // The `targetRenderObject` is used to record the first target renderObject. + // If there are multiple scrollable widgets nested, we should let + // the `targetRenderObject` as visible as possible to improve the user experience. + // Otherwise, let the outer renderObject as visible as possible maybe cause + // the `targetRenderObject` invisible. + // Also see https://github.com/flutter/flutter/issues/65100 + RenderObject? targetRenderObject; + ScrollableState? scrollable = Scrollable.of(context); + while (scrollable != null) { + futures.add(_ensureVisible( + scrollable.position, + context.findRenderObject()!, + alignment: alignment, + duration: duration, + curve: curve, + alignmentPolicy: alignmentPolicy, + targetRenderObject: targetRenderObject, + )); + + targetRenderObject = targetRenderObject ?? context.findRenderObject(); + context = scrollable.context; + scrollable = Scrollable.of(context); + } + + if (futures.isEmpty || duration == Duration.zero) + return Future.value(); + if (futures.length == 1) + return futures.single; + return Future.wait(futures).then((List _) => null); + } + + /// Copied from [ScrollPosition.ensureVisible]. + /// + /// By default [ScrollPosition.ensureVisible] will always scroll to the + /// given alignment, no matter what. I need it to scroll only in certain + /// conditions, so I changed it a little bit. + static Future _ensureVisible( + ScrollPosition position, + RenderObject object, { + double alignment = 0.0, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, + RenderObject? targetRenderObject, + }) { + assert(object.attached); + final RenderAbstractViewport viewport = RenderAbstractViewport.of(object)!; + + Rect? targetRect; + if (targetRenderObject != null && targetRenderObject != object) { + targetRect = MatrixUtils.transformRect( + targetRenderObject.getTransformTo(object), + object.paintBounds.intersect(targetRenderObject.paintBounds), + ); + } + + double target; + switch (alignmentPolicy) { + case ScrollPositionAlignmentPolicy.explicit: + target = viewport.getOffsetToReveal(object, alignment, rect: targetRect).offset + .clamp(position.minScrollExtent, position.maxScrollExtent).toDouble(); + break; + case ScrollPositionAlignmentPolicy.keepVisibleAtEnd: + target = viewport.getOffsetToReveal(object, 1.0, rect: targetRect).offset + .clamp(position.minScrollExtent, position.maxScrollExtent).toDouble(); + if (target < position.pixels) { + target = position.pixels; + } + break; + case ScrollPositionAlignmentPolicy.keepVisibleAtStart: + target = viewport.getOffsetToReveal(object, 0.0, rect: targetRect).offset + .clamp(position.minScrollExtent, position.maxScrollExtent).toDouble(); + if (target > position.pixels) { + target = position.pixels; + } + break; + } + + if (position.pixels > position.viewportDimension / 2 && + (position.pixels - target).abs() < position.viewportDimension / 2 - 50) { + return Future.value(); + } + + if (duration == Duration.zero) { + position.jumpTo(target); + return Future.value(); + } + + return position.animateTo(target, duration: duration, curve: curve); } } class SearchPage extends Page { const SearchPage({ - LocalKey key, - @required this.delegate, + LocalKey? key, + required this.child, this.transitionSettings, - String name, + String? name, }) : super(key: key, name: name); - final SearchDelegate delegate; - final RouteTransitionSettings transitionSettings; + final Widget child; + final RouteTransitionSettings? transitionSettings; @override - _SearchPageRoute createRoute(BuildContext context) { - return _SearchPageRoute(page: this); + SearchPageRoute createRoute(BuildContext context) { + return SearchPageRoute( + settings: this, + child: child, + transitionSettings: transitionSettings, + ); } } -class _SearchPageRoute extends RouteTransition<_SearchPage> { - _SearchPageRoute({ - @required this.page, +class SearchPageRoute extends RouteTransition { + SearchPageRoute({ + required this.child, + RouteSettings? settings, + RouteTransitionSettings? transitionSettings, }) : super( - settings: page, - transitionSettings: page.transitionSettings, + settings: settings, + transitionSettings: transitionSettings, ); - final SearchPage page; + final Widget child; @override bool get maintainState => true; @override Widget buildContent(BuildContext context) { - return _SearchPage( - route: this, - animation: animation, - delegate: page.delegate, - ); + return child; } @override @@ -213,33 +342,46 @@ class _SearchPageRoute extends RouteTransition<_SearchPage> { } } -class _SearchPage extends StatefulWidget { - const _SearchPage({ - Key key, - this.route, - this.animation, - this.delegate, +class SearchRoute extends StatefulWidget { + SearchRoute({ + Key? key, + required this.delegate, }) : super(key: key); - final PageRoute route; - final Animation animation; - final SearchDelegate delegate; + final ContentSearchDelegate delegate; @override - State createState() => _SearchPageState(); + _SearchRouteState createState() => _SearchRouteState(); } -class _SearchPageState extends State<_SearchPage> with TickerProviderStateMixin { - _SearchStateDelegate stateDelegate; +class _SearchRouteState extends State with SelectionHandlerMixin { + late _SearchStateDelegate stateDelegate; FocusNode get focusNode => stateDelegate.focusNode; + late ModalRoute _route; + late Animation _animation; @override void initState() { super.initState(); - stateDelegate = _SearchStateDelegate(widget.delegate); + + initSelectionController(() => ContentSelectionController.create( + vsync: AppRouter.instance.navigatorKey.currentState!, + context: context, + closeButton: true, + ignoreWhen: () => playerRouteController.opened || + HomeRouter.instance.currentRoute.hasDifferentLocation(HomeRoutes.search), + )); + + stateDelegate = _SearchStateDelegate(selectionController, widget.delegate); widget.delegate._setStateNotifier.addListener(_handleSetState); widget.delegate._queryTextController.addListener(_onQueryChanged); - widget.animation.addStatusListener(_onAnimationStatusChanged); + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + if (mounted) { + _route = ModalRoute.of(context)!; + _animation = _route.animation!; + _animation.addStatusListener(_onAnimationStatusChanged); + } + }); focusNode.addListener(_onFocusChanged); playerRouteController.addStatusListener(_handlePlayerRouteStatusChange); } @@ -247,9 +389,11 @@ class _SearchPageState extends State<_SearchPage> with TickerProviderState @override void dispose() { stateDelegate.dispose(); + disposeSelectionController(); widget.delegate._setStateNotifier.removeListener(_handleSetState); widget.delegate._queryTextController.removeListener(_onQueryChanged); - widget.animation.removeStatusListener(_onAnimationStatusChanged); + widget.delegate._chipsBarDragged = false; + _animation.removeStatusListener(_onAnimationStatusChanged); playerRouteController.removeStatusListener(_handlePlayerRouteStatusChange); super.dispose(); } @@ -273,14 +417,14 @@ class _SearchPageState extends State<_SearchPage> with TickerProviderState if (status != AnimationStatus.completed) { return; } - widget.animation.removeStatusListener(_onAnimationStatusChanged); + _animation.removeStatusListener(_onAnimationStatusChanged); if (widget.delegate.autoKeyboard) { focusNode.requestFocus(); } } @override - void didUpdateWidget(_SearchPage oldWidget) { + void didUpdateWidget(SearchRoute oldWidget) { super.didUpdateWidget(oldWidget); if (widget.delegate != oldWidget.delegate) { oldWidget.delegate._queryTextController.removeListener(_onQueryChanged); @@ -307,9 +451,9 @@ class _SearchPageState extends State<_SearchPage> with TickerProviderState /// /// The value provided for [result] is used as the return value. void close(BuildContext context, dynamic result) { - focusNode?.unfocus(); + focusNode.unfocus(); Navigator.of(context) - ..popUntil((Route route) => route == widget.route) + ..popUntil((Route route) => route == _route) ..pop(result); } @@ -336,10 +480,6 @@ class _SearchPageState extends State<_SearchPage> with TickerProviderState Widget buildLeading() { return NFBackButton( onPressed: () { - final selectionController = stateDelegate.selectionController; - if (selectionController.inSelection) { - selectionController.close(); - } close(context, null); }, ); @@ -374,30 +514,50 @@ class _SearchPageState extends State<_SearchPage> with TickerProviderState ? AppBarBorder.height + 34.0 + bottomPadding : AppBarBorder.height, ), - child: ValueListenableBuilder( - valueListenable: stateDelegate.contentTypeNotifier, + child: ValueListenableBuilder( + valueListenable: stateDelegate.onContentTypeChange, builder: (context, contentTypeValue, child) { - return !showChips - ? child - : Column( - children: [ - SizedBox( - height: 34.0, - child: ListView.separated( + if (!showChips) + return child!; + + final List children = []; + for (int i = 0; i < contentTypeEntries.length; i++) { + children.add(_ContentChip( + delegate: stateDelegate, + contentType: contentTypeEntries[i].key, + )); + if (i != contentTypeEntries.length - 1) { + children.add(const SizedBox(width: 8.0)); + } + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 34.0, + child: GestureDetector( + onPanDown: (_) { + widget.delegate._chipsBarDragged = true; + }, + onPanCancel: () { + widget.delegate._chipsBarDragged = false; + }, + onPanEnd: (_) { + widget.delegate._chipsBarDragged = false; + }, + child: SingleChildScrollView( padding: const EdgeInsets.only(left: 12.0), scrollDirection: Axis.horizontal, - itemCount: contentTypeEntries.length, - separatorBuilder: (context, index) => const SizedBox(width: 8.0), - itemBuilder: (context, index) => _ContentChip( - delegate: stateDelegate, - contentType: contentTypeEntries[index].key, - ), + child: Row( + children: children, + ) ), ), - const SizedBox(height: bottomPadding), - child, - ], - ); + ), + const SizedBox(height: bottomPadding), + child!, + ], + ); }, child: ValueListenableBuilder( valueListenable: stateDelegate.bodyScrolledNotifier, @@ -413,7 +573,7 @@ class _SearchPageState extends State<_SearchPage> with TickerProviderState final theme = buildAppBarTheme(); final bottom = buildBottom(); final String searchFieldLabel = MaterialLocalizations.of(context).searchFieldLabel; - String routeName; + String? routeName; switch (theme.platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: @@ -425,6 +585,22 @@ class _SearchPageState extends State<_SearchPage> with TickerProviderState case TargetPlatform.windows: routeName = searchFieldLabel; } + final title = Padding( + padding: const EdgeInsets.only(right: 8.0), + child: TextField( + selectionControls: NFTextSelectionControls(), + controller: widget.delegate._queryTextController, + focusNode: focusNode, + style: theme.textTheme.headline6, + textInputAction: TextInputAction.search, + onSubmitted: (String _) => stateDelegate.onSubmit(), + decoration: InputDecoration( + border: InputBorder.none, + hintText: searchFieldLabel, + hintStyle: theme.inputDecorationTheme.hintStyle, + ), + ), + ); return RouteAwareWidget( onPushNext: _handlePushNext, child: Builder( @@ -437,44 +613,66 @@ class _SearchPageState extends State<_SearchPage> with TickerProviderState resizeToAvoidBottomInset: false, extendBodyBehindAppBar: true, appBar: PreferredSize( - preferredSize: Size.fromHeight(kNFAppBarPreferredSize + bottom.preferredSize.height), + preferredSize: Size.fromHeight(kToolbarHeight + bottom.preferredSize.height), child: SelectionAppBar( selectionController: stateDelegate.selectionController, onMenuPressed: null, - titleSelection: Padding( + showMenuButton: false, + titleSelection: selectionRoute ? title : Padding( padding: const EdgeInsets.only(top: 15.0), child: SelectionCounter(controller: stateDelegate.selectionController), ), - actionsSelection: [ - DeleteSongsAppBarAction( - controller: stateDelegate.selectionController, - ) - ], + actionsSelection: selectionRoute + ? [ + SelectAllSelectionAction( + controller: selectionController, + entryFactory: (content, index) => SelectionEntry.fromContent( + content: content, + index: index, + context: context, + ), + getAll: () => stateDelegate.results.map.getValue(stateDelegate.contentType), + ), + ] + : [ + DeleteSongsAppBarAction( + controller: stateDelegate.selectionController, + ), + ValueListenableBuilder( + valueListenable: stateDelegate.onContentTypeChange, + builder: (context, contentType, child) => AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => EmergeAnimation( + animation: animation, + child: child, + ), + child: stateDelegate.contentType == null + ? const SizedBox.shrink() + : SelectAllSelectionAction( + controller: selectionController, + entryFactory: (content, index) => SelectionEntry.fromContent( + content: content, + index: index, + context: context, + ), + getAll: () => stateDelegate.results.map.getValue(contentType), + ), + ), + ), + ], elevationSelection: 0.0, - elevation: theme.appBarTheme.elevation, + elevation: theme.appBarTheme.elevation!, + toolbarHeight: kToolbarHeight, backgroundColor: theme.primaryColor, iconTheme: theme.primaryIconTheme, textTheme: theme.primaryTextTheme, brightness: theme.primaryColorBrightness, leading: buildLeading(), - actions: buildActions(), + actions: selectionRoute ? const [] : buildActions(), bottom: bottom, - title: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: TextField( - selectionControls: NFTextSelectionControls(), - controller: widget.delegate._queryTextController, - focusNode: focusNode, - style: theme.textTheme.headline6, - textInputAction: TextInputAction.search, - onSubmitted: (String _) => stateDelegate.onSubmit(), - decoration: InputDecoration( - border: InputBorder.none, - hintText: searchFieldLabel, - hintStyle: theme.inputDecorationTheme.hintStyle, - ), - ), - ), + title: !selectionRoute ? title : const SizedBox.shrink(), ), ), body: SafeArea( @@ -496,26 +694,35 @@ class _SearchPageState extends State<_SearchPage> with TickerProviderState class _DelegateProvider extends InheritedWidget { const _DelegateProvider({ - Key key, - @required this.delegate, - Widget child, + Key? key, + required this.delegate, + required Widget child, }) : super(key: key, child: child); - final _SearchStateDelegate delegate; + final _SearchStateDelegate? delegate; static _DelegateProvider of(BuildContext context) { - return context.getElementForInheritedWidgetOfExactType<_DelegateProvider>().widget as _DelegateProvider; + return context.getElementForInheritedWidgetOfExactType<_DelegateProvider>()!.widget as _DelegateProvider; } @override bool updateShouldNotify(_DelegateProvider oldWidget) => false; } -class _DelegateBuilder extends StatelessWidget { - _DelegateBuilder({Key key}) : super(key: key); +class _DelegateBuilder extends StatefulWidget { + _DelegateBuilder({Key? key}) : super(key: key); + + @override + _DelegateBuilderState createState() => _DelegateBuilderState(); +} + +class _DelegateBuilderState extends State<_DelegateBuilder> { + // TODO: remove when https://github.com/flutter/flutter/issues/82046 is resolved + bool _onTop = true; + int _prevIndex = -1; Future _handlePop(_SearchStateDelegate delegate) async { - if (delegate.contentType != null) { + if (_onTop && delegate.contentType != null) { delegate.contentType = null; return true; } @@ -530,106 +737,116 @@ class _DelegateBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - final delegate = _SearchStateDelegate._of(context); + final delegate = _SearchStateDelegate._of(context)!; final results = delegate.results; final l10n = getl10n(context); - return NotificationListener( - onNotification: (notification) => _handleNotification(delegate, notification), - child: ValueListenableBuilder( - valueListenable: delegate.contentTypeNotifier, - builder: (context, contentType, child) { - if (delegate.trimmedQuery.isEmpty) { - return _Suggestions(); - } else if (results.empty) { - // Displays a message that there's nothing found - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - const Padding( - padding: EdgeInsets.only(bottom: 8.0), - child: Icon(Icons.error_outline_rounded), - ), - Padding( - padding: const EdgeInsets.only(bottom: 80.0), - child: Text( - l10n.searchNothingFound, - textAlign: TextAlign.center, + return RouteAwareWidget( + onPushNext: () => _onTop = false, + onPopNext: () => _onTop = true, + child: NotificationListener( + onNotification: (notification) => _handleNotification(delegate, notification), + child: ValueListenableBuilder( + valueListenable: delegate.onContentTypeChange, + builder: (context, contentType, child) { + if (delegate.trimmedQuery.isEmpty) { + return _Suggestions(); + } else if (results.empty) { + // Displays a message that there's nothing found + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 8.0), + child: Icon(Icons.error_outline_rounded), ), - ), - ], - ), - ); - } else { - final contentTypeEntries = results.map.entries - .where((el) => el.value.isNotEmpty) - .toList(); - final single = contentTypeEntries.length == 1; - final showSingleCategoryContentList = single || contentType != null; - final contentListContentType = single ? contentTypeEntries.single.key : contentType; - return NFBackButtonListener( - onBackButtonPressed: () => _handlePop(delegate), - child: - StreamBuilder( - stream: ContentControl.state.onSongChange, - builder: (context, snapshot) => - StreamBuilder(stream: ContentControl.state.onContentChange, - builder: (context, snapshot) => - PageTransitionSwitcher( - duration: const Duration(milliseconds: 300), - reverse: !single && contentType == null, - transitionBuilder: (child, animation, secondaryAnimation) => SharedAxisTransition( - transitionType: SharedAxisTransitionType.vertical, - animation: animation, - secondaryAnimation: secondaryAnimation, - fillColor: Colors.transparent, - child: child, + Padding( + padding: const EdgeInsets.only(bottom: 80.0), + child: Text( + l10n.searchNothingFound, + textAlign: TextAlign.center, + ), ), - child: Container( - key: ValueKey(contentType), - child: showSingleCategoryContentList - ? () { - final list = single - ? contentTypeEntries.single.value - : contentPick>( - contentType: contentType, - song: delegate.results.songs, - album: delegate.results.albums, - ); - return ContentListView( - contentType: contentListContentType, - controller: delegate.singleListScrollController, - selectionController: delegate.selectionController, - selectedTest: (index) =>delegate.selectionController.data - .firstWhereOrNull((el) => el.data == list[index]) != null, - onItemTap: delegate.getContentTileTapHandler(contentListContentType), - list: list, - ); - } () - : - // AppScrollbar( // TODO: enable this when i have more content on search screen - // controller: delegate.scrollController, - // child: - ListView( + ], + ), + ); + } else { + final contentTypeEntries = results.map.entries + .where((el) => el.value.isNotEmpty) + .toList(); + final single = contentTypeEntries.length == 1; + final showSingleCategoryContentList = single || contentType != null; + final contentListContentType = single ? contentTypeEntries.single.key : contentType; + final index = contentType == null ? -1 : Content.enumerate().indexOf(contentType); + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + _prevIndex = index; + }); + return NFBackButtonListener( + onBackButtonPressed: () => _handlePop(delegate), + child: + StreamBuilder( + stream: ContentControl.state.onSongChange, + builder: (context, snapshot) => + StreamBuilder(stream: ContentControl.state.onContentChange, + builder: (context, snapshot) => + PageTransitionSwitcher( + duration: const Duration(milliseconds: 300), + reverse: !single && (contentType == null || index < _prevIndex), + transitionBuilder: (child, animation, secondaryAnimation) => SharedAxisTransition( + transitionType: SharedAxisTransitionType.horizontal, + animation: animation, + secondaryAnimation: secondaryAnimation, + fillColor: Colors.transparent, + child: child, + ), + child: Container( + key: ValueKey(contentType), + child: showSingleCategoryContentList + ? () { + final list = single + ? contentTypeEntries.single.value + : contentPick>>( + contentType: contentType, + song: () => delegate.results.songs, + album: () => delegate.results.albums, + playlist: () => delegate.results.playlists, + artist: () => delegate.results.artists, + )(); + return ContentListView( + contentType: contentListContentType, + controller: delegate.singleListScrollController, + selectionController: delegate.selectionController, + onItemTap: (index) => delegate.handleContentTap(contentListContentType), + list: list, + ); + }() + : + // AppScrollbar( // TODO: enable this when i have more content on search screen + // controller: delegate.scrollController, + // child: + ListView( controller: delegate.scrollController, children: [ for (final entry in contentTypeEntries) if (entry.value.isNotEmpty) - _ContentSection( + ContentSection( contentType: entry.key, - items: results.map[entry.key], - onTap: () => delegate.contentType = entry.key, + list: results.map.getValue(entry.key), + onHeaderTap: () => delegate.contentType = entry.key, + selectionController: delegate.selectionController, + contentTileTapHandler: () => delegate.handleContentTap(entry.key), ), ], ), ), + ), ), ), - ), - ); - } - }, + ); + } + }, + ), ), ); } @@ -638,9 +855,9 @@ class _DelegateBuilder extends StatelessWidget { class _ContentChip extends StatefulWidget { const _ContentChip({ - Key key, - @required this.delegate, - @required this.contentType, + Key? key, + required this.delegate, + required this.contentType, }) : super(key: key); final _SearchStateDelegate delegate; @@ -653,7 +870,7 @@ class _ContentChip extends StatefulWidget { class _ContentChipState extends State<_ContentChip> with SingleTickerProviderStateMixin { static const borderRadius = BorderRadius.all(Radius.circular(50.0)); - AnimationController controller; + late AnimationController controller; bool get active => widget.delegate.contentType == widget.contentType; @@ -661,6 +878,7 @@ class _ContentChipState extends State<_ContentChip> with SingleTickerProviderSta void initState() { super.initState(); controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); + widget.delegate.registerChipContext(context, widget.contentType); if (active) { controller.forward(); } @@ -685,7 +903,13 @@ class _ContentChipState extends State<_ContentChip> with SingleTickerProviderSta @override Widget build(BuildContext context) { + if (active) { + controller.forward(); + } else { + controller.reverse(); + } final l10n = getl10n(context); + final count = widget.delegate.results.map.getValue(widget.contentType).length; final colorScheme = ThemeControl.theme.colorScheme; final colorTween = ColorTween( begin: colorScheme.secondary, @@ -705,11 +929,6 @@ class _ContentChipState extends State<_ContentChip> with SingleTickerProviderSta begin: Constants.Theme.glowSplashColor.auto, end: Constants.Theme.glowSplashColorOnContrast.auto, ).animate(baseAnimation); - if (active) { - controller.forward(); - } else { - controller.reverse(); - } return AnimatedBuilder( animation: controller, builder: (context, child) => Material( @@ -731,7 +950,7 @@ class _ContentChipState extends State<_ContentChip> with SingleTickerProviderSta ), backgroundColor: Colors.transparent, label: Text( - l10n.contents(widget.contentType), + l10n.contentsPluralWithCount(count, widget.contentType), style: TextStyle( color: textColorAnimation.value, fontWeight: FontWeight.w800, @@ -746,102 +965,15 @@ class _ContentChipState extends State<_ContentChip> with SingleTickerProviderSta } } -/// The search results are split into a few sections - songs, albums, etc. -/// -/// This widget renders a tappable header for such sections, and the sections -/// content. -class _ContentSection extends StatelessWidget { - const _ContentSection({ - Key key, - this.contentType, - @required this.items, - @required this.onTap, - }) : super(key: key); - - final Type contentType; - final List items; - final VoidCallback onTap; - - String getHeaderText(BuildContext context) { - final l10n = getl10n(context); - return contentPick( - contentType: contentType, - song: l10n.tracks, - album: l10n.albums, - ); - } - - @override - Widget build(BuildContext context) { - final delegate = _SearchStateDelegate._of(context); - final builder = contentPick( - contentType: contentType, - song: (index) { - final song = items[index] as Song; - return SongTile.selectable( - index: index, - selected: delegate.selectionController.data - .firstWhereOrNull((el) => el.data == song) != null, - song: song, - selectionController: delegate.selectionController, - horizontalPadding: 12.0, - onTap: delegate.getContentTileTapHandler(), - ); - }, - album: (index) { - final album = items[index] as Album; - return AlbumTile.selectable( - index: index, - selected: delegate.selectionController.data - .firstWhereOrNull((el) => el.data == album) != null, - album: items[index] as Album, - selectionController: delegate.selectionController, - small: false, - horizontalPadding: 12.0, - onTap: delegate.getContentTileTapHandler(), - ); - }, - ); - return Column( - children: [ - NFInkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 20.0), - child: Row( - children: [ - Text( - getHeaderText(context), - style: const TextStyle( - fontSize: 20.0, - fontWeight: FontWeight.w800, - ), - ), - const Icon(Icons.chevron_right_rounded), - ], - ), - ), - ), - Column( - children: [ - for (int index = 0; index < math.min(5, items.length); index ++) - builder(index), - ], - ) - ], - ); - } -} - class _Suggestions extends StatefulWidget { - _Suggestions({Key key}) : super(key: key); + _Suggestions({Key? key}) : super(key: key); @override _SuggestionsState createState() => _SuggestionsState(); } class _SuggestionsState extends State<_Suggestions> { - Future _loadFuture; + Future? _loadFuture; @override void initState() { @@ -862,7 +994,7 @@ class _SuggestionsState extends State<_Suggestions> { } return SizedBox( width: double.infinity, - child: SearchHistory.instance.history.isEmpty + child: SearchHistory.instance.history!.isEmpty ? Center( child: Padding( padding: const EdgeInsets.only( @@ -874,7 +1006,8 @@ class _SuggestionsState extends State<_Suggestions> { l10n.searchHistoryPlaceholder, textAlign: TextAlign.center, style: TextStyle( - color: ThemeControl.theme.colorScheme.onSurface.withOpacity(0.7), + fontWeight: FontWeight.w700, + color: ThemeControl.theme.hintColor, ), ), ), @@ -885,7 +1018,7 @@ class _SuggestionsState extends State<_Suggestions> { physics: const AlwaysScrollableScrollPhysics( parent: ClampingScrollPhysics(), ), - itemCount: SearchHistory.instance.history.length + 1, + itemCount: SearchHistory.instance.history!.length + 1, itemBuilder: (context, index) { if (index == 0) { return const _SuggestionsHeader(); @@ -903,11 +1036,11 @@ class _SuggestionsState extends State<_Suggestions> { class _SuggestionsHeader extends StatelessWidget { - const _SuggestionsHeader({Key key}) : super(key: key); + const _SuggestionsHeader({Key? key}) : super(key: key); void clearHistory(BuildContext context) { SearchHistory.instance.clear(); - _SearchStateDelegate._of(context).searchDelegate.setState(); + _SearchStateDelegate._of(context)!.searchDelegate.setState(); } @override @@ -943,21 +1076,21 @@ class _SuggestionsHeader extends StatelessWidget { class _SuggestionTile extends StatelessWidget { const _SuggestionTile({ - Key key, - @required this.index, + Key? key, + required this.index, }) : super(key: key); final int index; /// Deletes item from search history by its index. void _removeEntry(BuildContext context, int index) { - SearchHistory.instance.remove(index); - _SearchStateDelegate._of(context).searchDelegate.setState(); + SearchHistory.instance.removeAt(index); + _SearchStateDelegate._of(context)!.searchDelegate.setState(); } void _handleTap(context) { - final delegate = _SearchStateDelegate._of(context); - delegate.searchDelegate.query = SearchHistory.instance.history[index]; + final delegate = _SearchStateDelegate._of(context)!; + delegate.searchDelegate.query = SearchHistory.instance.history![index]; delegate.focusNode.unfocus(); } @@ -967,7 +1100,7 @@ class _SuggestionTile extends StatelessWidget { return NFListTile( onTap: () => _handleTap(context), title: Text( - SearchHistory.instance.history[index], + SearchHistory.instance.history![index], style: const TextStyle( fontSize: 15.5, fontWeight: FontWeight.w800, @@ -994,7 +1127,7 @@ class _SuggestionTile extends StatelessWidget { children: [ TextSpan(text: l10n.searchHistoryRemoveEntryDescriptionP1), TextSpan( - text: '"${SearchHistory.instance.history[index]}"', + text: '"${SearchHistory.instance.history![index]}"', style: const TextStyle(fontWeight: FontWeight.w700), ), TextSpan(text: l10n.searchHistoryRemoveEntryDescriptionP2), diff --git a/lib/routes/home_route/tabs_route.dart b/lib/routes/home_route/tabs_route.dart index 9006794c6..f5df8c38a 100644 --- a/lib/routes/home_route/tabs_route.dart +++ b/lib/routes/home_route/tabs_route.dart @@ -5,352 +5,612 @@ import 'dart:async'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; /// Returns app style used for app bar title. TextStyle get appBarTitleTextStyle => TextStyle( fontWeight: FontWeight.w700, - color: ThemeControl.theme.textTheme.headline6.color, + color: ThemeControl.theme.textTheme.headline6!.color, fontSize: 22.0, fontFamily: 'Roboto', ); /// Needed to change physics of the [TabBarView]. class _TabsScrollPhysics extends AlwaysScrollableScrollPhysics { - const _TabsScrollPhysics({ScrollPhysics parent}) : super(parent: parent); + const _TabsScrollPhysics({ScrollPhysics? parent}) : super(parent: parent); @override - _TabsScrollPhysics applyTo(ScrollPhysics ancestor) { + _TabsScrollPhysics applyTo(ScrollPhysics? ancestor) { return _TabsScrollPhysics(parent: buildParent(ancestor)); } @override SpringDescription get spring => const SpringDescription( - mass: 80, - stiffness: 100, - damping: 1, - ); + mass: 80, + stiffness: 100, + damping: 1, + ); } class TabsRoute extends StatefulWidget { - const TabsRoute({Key key}) : super(key: key); + const TabsRoute({Key? key}) : super(key: key); @override TabsRouteState createState() => TabsRouteState(); } -class TabsRouteState extends State with TickerProviderStateMixin, SelectionHandler { - ContentSelectionController selectionController; - TabController tabController; +class TabsRouteState extends State with TickerProviderStateMixin, SelectionHandlerMixin { + static const tabBarHeight = 44.0; + + late TabController tabController; + + /// Used in [HomeRouter.drawerCanBeOpened]. + bool tabBarDragged = false; + static late bool _mainTabsCreated = false; + + Type indexToContentType(int index) { + return Content.enumerate()[index]; + } @override void initState() { super.initState(); - selectionController = ContentSelectionController.forContent( - this, - ignoreWhen: () => playerRouteController.opened || HomeRouter.instance.routes.last != HomeRoutes.tabs, - ) - ..addListener(handleSelection) - ..addStatusListener(handleSelectionStatus); + + assert(() { + if (!selectionRoute) { + if (_mainTabsCreated) { + throw StateError( + "Several main tabs routes was created twice at the same time, " + "which is invalid", + ); + } + _mainTabsCreated = true; + } + return true; + }()); + + initSelectionController(() => ContentSelectionController.create( + vsync: this, + context: context, + ignoreWhen: () => playerRouteController.opened || + HomeRouter.instance.currentRoute.hasDifferentLocation(HomeRoutes.tabs), + )); + tabController = TabController( vsync: this, - length: 2, + length: 4, ); + _updatePrimaryContentType(); + tabController.addListener(() { + _updatePrimaryContentType(); + }); } @override void dispose() { - selectionController.dispose(); + assert(() { + if (!selectionRoute) + _mainTabsCreated = false; + return true; + }()); + disposeSelectionController(); tabController.dispose(); super.dispose(); } - List _buildTabs() { + void _updatePrimaryContentType() { + selectionController.primaryContentType = indexToContentType(tabController.index); + } + + List _buildTabs() { final l10n = getl10n(context); return [ - NFTab(text: l10n.tracks), - NFTab(text: l10n.albums), + _TabCollapse( + index: 0, + tabController: tabController, + icon: const Icon(Song.icon), + label: l10n.tracks, + ), + _TabCollapse( + index: 1, + tabController: tabController, + icon: const Icon(Album.icon), + label: l10n.albums, + ), + _TabCollapse( + index: 2, + tabController: tabController, + icon: const Icon(Playlist.icon, size: 28.0), + label: l10n.playlists, + ), + _TabCollapse( + index: 3, + tabController: tabController, + icon: const Icon(Artist.icon), + label: l10n.artists, + ), ]; } - DateTime _lastBackPressTime; + DateTime? _lastBackPressTime; Future _handlePop() async { final navigatorKey = AppRouter.instance.navigatorKey; - final homeNavigatorKey = HomeRouter.instance.navigatorKey; - if (navigatorKey.currentState != null && navigatorKey.currentState.canPop()) { - navigatorKey.currentState.pop(); + final homeNavigatorKey = homeRouter!.navigatorKey; + + // When in selection route, the home router should be popped first, + // opposed to the normal situation, where the main app navigator comes first + if (selectionRoute && homeNavigatorKey.currentState!.canPop()) { + homeNavigatorKey.currentState!.pop(); return true; - } else if (homeNavigatorKey.currentState != null && homeNavigatorKey.currentState.canPop()) { - homeNavigatorKey.currentState.pop(); + } + if (navigatorKey.currentState != null && navigatorKey.currentState!.canPop()) { + navigatorKey.currentState!.pop(); + return true; + } + if (homeNavigatorKey.currentState != null && homeNavigatorKey.currentState!.canPop()) { + homeNavigatorKey.currentState!.pop(); return true; - } else { + } + + if (!selectionRoute) { final now = DateTime.now(); // Show toast when user presses back button on main route, that // asks from user to press again to confirm that he wants to quit the app - if (_lastBackPressTime == null || now.difference(_lastBackPressTime) > const Duration(seconds: 2)) { + if (_lastBackPressTime == null || now.difference(_lastBackPressTime!) > const Duration(seconds: 2)) { _lastBackPressTime = now; ShowFunctions.instance.showToast(msg: getl10n(context).pressOnceAgainToExit); return true; } } + return false; } @override Widget build(BuildContext context) { - final appBar = PreferredSize( - preferredSize: const Size.fromHeight(kNFAppBarPreferredSize), - child: SelectionAppBar( - titleSpacing: 0.0, - elevation: 0.0, - elevationSelection: 0.0, - selectionController: selectionController, - onMenuPressed: () { - drawerController.open(); - }, - actions: [ - NFIconButton( - icon: const Icon(Icons.search_rounded), - onPressed: () { - ShowFunctions.instance.showSongsSearch(); - }, - ), - ], - actionsSelection: [ - DeleteSongsAppBarAction(controller: selectionController), - ], - title: Padding( - padding: const EdgeInsets.only(left: 15.0), - child: Text( - Constants.Config.APPLICATION_TITLE, - style: appBarTitleTextStyle, - ), + final theme = ThemeControl.theme; + final screenWidth = MediaQuery.of(context).size.width; + final searchButton = NFIconButton( + icon: const Icon(Icons.search_rounded), + onPressed: () { + ShowFunctions.instance.showSongsSearch(context); + }, + ); + + final appBar = SelectionAppBar( + titleSpacing: 0.0, + elevation: 2.0, + elevationSelection: 2.0, + selectionController: selectionController, + toolbarHeight: kToolbarHeight, + showMenuButton: !selectionRoute, + leading: selectionRoute + ? NFBackButton(onPressed: () => AppRouter.instance.navigatorKey.currentState!.pop()) + : const NFBackButton(), + onMenuPressed: () { + drawerController.open(); + }, + actions: selectionRoute ? const [] : [ + Visibility( + visible: false, + maintainState: true, + maintainAnimation: true, + maintainSize: true, + child: DeleteSongsAppBarAction(controller: selectionController), ), - titleSelection: Padding( - padding: const EdgeInsets.only(left: 10.0), - child: SelectionCounter(controller: selectionController), + searchButton, + ], + actionsSelection: selectionRoute + ? [ + SelectAllSelectionAction( + controller: selectionController, + entryFactory: (Content content, index) => SelectionEntry.fromContent( + content: content, + index: index, + context: context, + ), + getAll: () => ContentControl.getContent(selectionController.primaryContentType!), + ), + searchButton + ] + : [ + DeleteSongsAppBarAction(controller: selectionController), + SelectAllSelectionAction( + controller: selectionController, + entryFactory: (Content content, index) => SelectionEntry.fromContent( + content: content, + index: index, + context: context, + ), + getAll: () => ContentControl.getContent(selectionController.primaryContentType!), + ), + ], + title: Padding( + padding: const EdgeInsets.only(left: 15.0), + child: selectionRoute ? const SizedBox.shrink() : Text( + Constants.Config.APPLICATION_TITLE, + style: appBarTitleTextStyle, ), ), + titleSelection: selectionRoute + ? Text( + homeRouter!.selectionArguments!.title(context), + ) + : Padding( + padding: const EdgeInsets.only(left: 14.0), + child: SelectionCounter(controller: selectionController), + ), ); return NFBackButtonListener( onBackButtonPressed: _handlePop, - child: Stack( - children: [ - SafeArea( - child: Padding( - padding: const EdgeInsets.only( - top: kNFAppBarPreferredSize + 4.0, - ), - child: Stack( - children: [ - StreamBuilder( - stream: ContentControl.state.onSongChange, - builder: (context, snapshot) => - StreamBuilder( - stream: ContentControl.state.onContentChange, - builder: (context, snapshot) => - ScrollConfiguration( - behavior: const GlowlessScrollBehavior(), - child: Padding( - padding: EdgeInsets.only( - top: ContentControl.state.albums.isNotEmpty ? 44.0 : 0.0, + child: Material( + color: theme.colorScheme.background, + child: Stack( + children: [ + SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: kToolbarHeight), + child: Stack( + children: [ + StreamBuilder( + stream: ContentControl.state.onSongChange, + builder: (context, snapshot) => + StreamBuilder( + stream: ContentControl.state.onContentChange, + builder: (context, snapshot) => + ScrollConfiguration( + behavior: const GlowlessScrollBehavior(), + child: Padding( + padding: const EdgeInsets.only(bottom: tabBarHeight), + child: TabBarView( + controller: tabController, + physics: const _TabsScrollPhysics(), + children: [ + for (final contentType in Content.enumerate()) + _ContentTab( + contentType: contentType, + selectionController: selectionController, + ), + ], + ), ), - child: ContentControl.state.albums.isEmpty - ? _ContentTab(selectionController: selectionController) - : TabBarView( - controller: tabController, - physics: const _TabsScrollPhysics(), - children: [ - _ContentTab(selectionController: selectionController), - _ContentTab(selectionController: selectionController) - ], - ), - ), - ))), - if (ContentControl.state.albums.isNotEmpty) - IgnorePointer( - ignoring: selectionController.inSelection, + ))), + Positioned( + bottom: 0.0, + left: 0.0, + right: 0.0, child: Theme( - data: ThemeControl.theme.copyWith( + data: theme.copyWith( splashFactory: NFListTileInkRipple.splashFactory, + canvasColor:theme.colorScheme.secondary, ), - child: Material( - elevation: 2.0, - color: ThemeControl.theme.appBarTheme.color, - child: NFTabBar( - controller: tabController, - indicatorWeight: 5.0, - indicator: BoxDecoration( - color: ThemeControl.theme.colorScheme.primary, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(3.0), - topRight: Radius.circular(3.0), + child: ScrollConfiguration( + behavior: const GlowlessScrollBehavior(), + child: SizedBox( + height: tabBarHeight, + width: screenWidth, + child: Material( + elevation: 4.0, + color: theme.colorScheme.background, + child: GestureDetector( + onPanDown: (_) { + tabBarDragged = true; + }, + onPanCancel: () { + tabBarDragged = false; + }, + onPanEnd: (_) { + tabBarDragged = false; + }, + child: Center( + child: NFTabBar( + isScrollable: true, + controller: tabController, + indicatorWeight: 5.0, + indicator: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(3.0), + topRight: Radius.circular(3.0), + ), + ), + labelPadding: const EdgeInsets.symmetric(horizontal: 20.0), + labelColor: theme.textTheme.headline6!.color, + indicatorSize: TabBarIndicatorSize.label, + unselectedLabelColor: theme.colorScheme.onSurface.withOpacity(0.6), + labelStyle: theme.textTheme.headline6!.copyWith( + fontSize: 15.0, + fontWeight: FontWeight.w900, + ), + tabs: _buildTabs(), + ), + ), ), ), - labelColor: ThemeControl.theme.textTheme.headline6.color, - indicatorSize: TabBarIndicatorSize.label, - unselectedLabelColor: ThemeControl.theme.colorScheme.onSurface.withOpacity(0.6), - labelStyle: ThemeControl.theme.textTheme.headline6.copyWith( - fontSize: 15.0, - fontWeight: FontWeight.w900, - ), - tabs: _buildTabs(), ), ), ), ), - ], + ], + ), ), ), - ), - Positioned( - top: 0.0, - left: 0.0, - right: 0.0, - child: appBar, - ), - ], + Positioned( + top: 0.0, + left: 0.0, + right: 0.0, + child: appBar, + ), + ], + ), ), ); } } -class _ContentTab extends StatefulWidget { - _ContentTab({Key key, @required this.selectionController}) : super(key: key); +class _ContentTab extends StatefulWidget { + _ContentTab({ + Key? key, + required this.contentType, + required this.selectionController, + }) : super(key: key); - final ContentSelectionController selectionController; + final Type contentType; + final ContentSelectionController selectionController; @override - _ContentTabState createState() => _ContentTabState(); + _ContentTabState createState() => _ContentTabState(); } -class _ContentTabState extends State<_ContentTab> with AutomaticKeepAliveClientMixin<_ContentTab> { +class _ContentTabState extends State<_ContentTab> with AutomaticKeepAliveClientMixin<_ContentTab>, TickerProviderStateMixin { @override bool get wantKeepAlive => true; final key = GlobalKey(); bool get showLabel { - final SortFeature feature = ContentControl.state.sorts.getValue().feature; - return contentPick( + final contentType = widget.contentType; + final SortFeature feature = ContentControl.state.sorts.getValue(contentType).feature; + return contentPick( + contentType: contentType, song: feature == SongSortFeature.title, album: feature == AlbumSortFeature.title, - ); - } - - Future Function() get onRefresh { - return contentPick Function()>( - song: () => Future.wait([ - ContentControl.refetch(), - ContentControl.refetch(), - ]), - album: () => ContentControl.refetch(), + playlist: feature == PlaylistSortFeature.name, + artist: feature == ArtistSortFeature.name, ); } @override Widget build(BuildContext context) { super.build(context); - final list = ContentControl.getContent(); + final theme = ThemeControl.theme; + final contentType = widget.contentType; + final list = ContentControl.getContent(contentType); + final showDisabledActions = list.isNotEmpty && list.first is Playlist && (list as List).every((el) => el.songIds.isEmpty); final selectionController = widget.selectionController; + final selectionRoute = selectionRouteOf(context); return RefreshIndicator( key: key, strokeWidth: 2.5, - color: Colors.white, - backgroundColor: ThemeControl.theme.colorScheme.primary, - onRefresh: onRefresh, + color: theme.colorScheme.onPrimary, + backgroundColor: theme.colorScheme.primary, + onRefresh: ContentControl.refetchAll, notificationPredicate: (notification) { return selectionController.notInSelection && notification.depth == 0; }, - child: ContentListView( + child: ContentListView( + contentType: contentType, list: list, showScrollbarLabel: showLabel, selectionController: selectionController, - onItemTap: contentPick( - song: ContentControl.resetQueue, - // TODO: when i fully migrate to safety, make this null instead of empty closure - album: () {}, + onItemTap: contentPick>( + contentType: contentType, + song: (index) => ContentControl.resetQueue(), + album: (index) {}, + playlist: (index) {}, + artist: (index) {}, ), - leading: ContentListHeader( - count: list.length, - selectionController: selectionController, - trailing: Padding( - padding: const EdgeInsets.only(bottom: 1.0, right: 10.0), - child: Row( - children: [ - ContentListHeaderAction( - icon: const Icon(Icons.shuffle_rounded), - onPressed: () { - contentPick( - song: () { - ContentControl.setQueue( - type: QueueType.all, - modified: false, - shuffled: true, - shuffleFrom: ContentControl.state.allSongs.songs, - ); - }, - album: () { - final List songs = []; - for (final album in ContentControl.state.albums.values.toList()) { - for (final song in album.songs) { - song.origin = album; - songs.add(song); - } - } - ContentControl.setQueue( - type: QueueType.arbitrary, - shuffled: true, - shuffleFrom: songs, - arbitraryQueueOrigin: ArbitraryQueueOrigin.allAlbums, - ); - }, - )(); - MusicPlayer.instance.setSong(ContentControl.state.queues.current.songs[0]); - MusicPlayer.instance.play(); - playerRouteController.open(); - }, - ), - ContentListHeaderAction( - icon: const Icon(Icons.play_arrow_rounded), - onPressed: () { - contentPick( - song: () { - ContentControl.resetQueue(); - }, - album: () { - final List songs = []; - for (final album in ContentControl.state.albums.values.toList()) { - for (final song in album.songs) { - song.origin = album; - songs.add(song); - } - } - ContentControl.setQueue( - type: QueueType.arbitrary, - songs: songs, - arbitraryQueueOrigin: ArbitraryQueueOrigin.allAlbums, - ); - }, - )(); - MusicPlayer.instance.setSong(ContentControl.state.queues.current.songs[0]); - MusicPlayer.instance.play(); - playerRouteController.open(); - }, + leading: Column( + children: [ + if (selectionRoute) + ContentListHeader.onlyCount(contentType: contentType, count: list.length) + else + ContentListHeader( + contentType: contentType, + count: list.length, + selectionController: selectionController, + trailing: Padding( + padding: const EdgeInsets.only(bottom: 1.0), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 240), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + layoutBuilder:(Widget? currentChild, List previousChildren) { + return Stack( + alignment: Alignment.centerRight, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: list.isEmpty || selectionRoute + ? const SizedBox( + width: ContentListHeaderAction.size * 2, + height: ContentListHeaderAction.size, + ) + : Row( + children: [ + AnimatedContentListHeaderAction( + icon: const Icon(Icons.shuffle_rounded), + onPressed: showDisabledActions ? null : () { + contentPick( + contentType: contentType, + song: () { + ContentControl.setQueue( + type: QueueType.allSongs, + modified: false, + shuffled: true, + shuffleFrom: list as List, + ); + }, + album: () { + final shuffleResult = ContentUtils.shuffleSongOrigins(list as List); + ContentControl.setQueue( + type: QueueType.allAlbums, + shuffled: true, + songs: shuffleResult.shuffledSongs, + shuffleFrom: shuffleResult.songs, + ); + }, + playlist: () { + final shuffleResult = ContentUtils.shuffleSongOrigins(list as List); + ContentControl.setQueue( + type: QueueType.allPlaylists, + shuffled: true, + songs: shuffleResult.shuffledSongs, + shuffleFrom: shuffleResult.songs, + ); + }, + artist: () { + final shuffleResult = ContentUtils.shuffleSongOrigins(list as List); + ContentControl.setQueue( + type: QueueType.allArtists, + shuffled: true, + songs: shuffleResult.shuffledSongs, + shuffleFrom: shuffleResult.songs, + ); + }, + )(); + MusicPlayer.instance.setSong(ContentControl.state.queues.current.songs[0]); + MusicPlayer.instance.play(); + playerRouteController.open(); + }, + ), + AnimatedContentListHeaderAction( + icon: const Icon(Icons.play_arrow_rounded), + onPressed: showDisabledActions ? null : () { + contentPick( + contentType: contentType, + song: () => ContentControl.resetQueue(), + album: () => ContentControl.setQueue( + type: QueueType.allAlbums, + songs: ContentUtils.joinSongOrigins(list as List), + ), + playlist: () => ContentControl.setQueue( + type: QueueType.allPlaylists, + songs: ContentUtils.joinSongOrigins(list as List), + ), + artist: () => ContentControl.setQueue( + type: QueueType.allArtists, + songs: ContentUtils.joinSongOrigins(list as List), + ), + )(); + MusicPlayer.instance.setSong(ContentControl.state.queues.current.songs[0]); + MusicPlayer.instance.play(); + playerRouteController.open(); + }, + ), + ], + ), + ), ), - ], - ), - ), + ), + if (contentType == Playlist && !selectionRoute) + const CreatePlaylistInListAction(), + ], ), ) ); } } + +class _TabCollapse extends StatelessWidget { + const _TabCollapse({ + Key? key, + required this.index, + required this.tabController, + required this.label, + required this.icon, + }) : super(key: key); + + final int index; + final TabController tabController; + final String label; + final Icon icon; + + @override + Widget build(BuildContext context) { + // TODO: some wierd stuff happening if value is 1.6090780263766646e-7, this suggests some issue in the rendering that would needed to be investigated, reproduced and filed to flutter as issue + return NFTab( + child: AnimatedBuilder( + animation: tabController.animation!, + child: Text(label), + builder: (context, child) { + final tabValue = tabController.animation!.value; + final indexIsChanging = tabController.indexIsChanging; + double value = 0.0; + if (tabValue > index - 1 && tabValue <= index) { + if (!indexIsChanging || indexIsChanging && (tabController.index == index || tabController.previousIndex == index)) { + // Animation for next tab. + value = 1 + (tabController.animation!.value - index); + } + } else if (tabValue <= index + 1 && tabValue > index) { + if (!indexIsChanging || indexIsChanging && (tabController.index == index || tabController.previousIndex == index)) { + // Animation for previous tab. + value = 1 - (tabController.animation!.value - index); + } + } + value = value.clamp(0.0, 1.0); + return Row( + children: [ + ClipRect( + child: Align( + alignment: Alignment.centerLeft, + heightFactor: 1.0 - value, + widthFactor: 1.0 - value, + child: icon, + ), + ), + // Create a space while animating in-between icon and label, but don't keep it, + // otherwise symmetry is ruined. + SizedBox( + width: 4 * TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 0.0, end: 1.0), + weight: 1, + ), + TweenSequenceItem( + tween: ConstantTween(1.0), + weight: 2, + ), + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 0.0), + weight: 1, + ), + ]).transform(value), + ), + ClipRect( + child: Align( + alignment: Alignment.centerLeft, + widthFactor: value, + child: child, + ), + ), + ], + ); + } + ), + ); + } +} diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index a036cb63b..b45a30f80 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -7,52 +7,87 @@ export 'home_route/home_route.dart'; export 'settings_route/settings_route.dart'; export 'dev_route.dart'; +export 'selection_route.dart'; import 'dart:async'; -import 'package:flutter/material.dart' hide LicensePage, SearchDelegate; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide LicensePage; import 'package:equatable/equatable.dart'; import 'package:sweyer/routes/settings_route/theme_settings.dart'; import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; import 'home_route/home_route.dart'; import 'settings_route/settings_route.dart'; import 'settings_route/licenses_route.dart'; import 'dev_route.dart'; +import 'selection_route.dart'; final RouteObserver routeObserver = RouteObserver(); final RouteObserver homeRouteObserver = RouteObserver(); -abstract class _Routes extends Equatable { +abstract class _Routes extends Equatable { const _Routes(this.location, [this.arguments]); final String location; - final T arguments; + final T? arguments; - /// Value key to pass in to the [Page]. - ValueKey get key => ValueKey(location); + _Routes withArguments(T arguments); + + /// Returns a unique key to pass in to the [Page]. + /// This will disallow adding this route to stack multiple times. + /// + /// See also: + /// * [uniqueKey], which allows for multiple [Page]s for this route + ValueKey get uniqueKey => ValueKey(location); @override - List get props => [location]; + List get props => [location, arguments]; + + /// Checks whether the [other] route has the same location. + /// + /// Routes compared with [==] will be the same only when both content and + /// arguments are equal. + bool hasSameLocation(_Routes other) { + return location == other.location; + } + + /// The oppsoite of [hasSameLocation]. + bool hasDifferentLocation(_Routes other) { + return location != other.location; + } } -class AppRoutes extends _Routes { - const AppRoutes._(String location, [T arguments]) : super(location, arguments); +class AppRoutes extends _Routes { + const AppRoutes._(String location, [T? arguments]) : super(location, arguments); + + @override + AppRoutes withArguments(T arguments) { + return AppRoutes._(location, arguments); + } static const initial = AppRoutes._('/'); static const settings = AppRoutes._('/settings'); static const themeSettings = AppRoutes._('/settings/theme'); static const licenses = AppRoutes._('/settings/licenses'); static const dev = AppRoutes._('/dev'); + static const selection = AppRoutes._('/selection'); } -class HomeRoutes extends _Routes { - const HomeRoutes._(String location, [T arguments]) : super(location, arguments); +class HomeRoutes extends _Routes { + const HomeRoutes._(String location, [T? arguments]) : super(location, arguments); + + @override + HomeRoutes withArguments(T arguments) { + return HomeRoutes._(location, arguments); + } static const tabs = HomeRoutes._('/tabs'); - static const album = HomeRoutes._('/album'); + static const album = HomeRoutes>._('/album'); + static const playlist = HomeRoutes>._('/playlist'); + static const artist = HomeRoutes._('/artist'); + static const artistContent = HomeRoutes._('/artist/content'); static const search = HomeRoutes._('/search'); /// Returns a factory to create routes with arguments. @@ -62,16 +97,104 @@ class HomeRoutes extends _Routes { class _HomeRoutesFactory { const _HomeRoutesFactory(); - HomeRoutes album(Album album) => HomeRoutes._('/album', album); - HomeRoutes search(SearchArguments arguments) => HomeRoutes._('/search', arguments); + HomeRoutes content(T content) { + return contentPick>( + song: () => throw ArgumentError(), + album: () => HomeRoutes._(HomeRoutes.album.location, PersistentQueueArguments(queue: content as Album)), + playlist: () => HomeRoutes._(HomeRoutes.playlist.location, PersistentQueueArguments(queue: content as Playlist)), + artist: () => HomeRoutes._(HomeRoutes.artist.location, content as Artist), + )(); + } + + HomeRoutes> artistContent(Artist artist, List list) { + assert(T == Song || T == Album); + return HomeRoutes._(HomeRoutes.artistContent.location, ArtistContentArguments( + artist: artist, + list: list, + )); + } + + HomeRoutes persistentQueue(T persistentQueue) { + if (persistentQueue is Album || persistentQueue is Playlist) { + return content(persistentQueue); + } else { + throw ArgumentError(); + } + } +} + +class SelectionArguments { + SelectionArguments({ + required this.title, + required this.onSubmit, + this.settingsPageBuilder, + }); + + /// Builder for title to display in the app bar. + final String Function(BuildContext) title; + + /// Fired when user pressed a done button. + final ValueSetter> onSubmit; + + /// If non-null, near submit button there will be shown a settings button, + /// and if it's pressed, the new route is opened which shows the result of + /// this function invocation. + final WidgetBuilder? settingsPageBuilder; + + /// Created and set in [SelectionRoute]. + late ContentSelectionController selectionController; +} + +class PersistentQueueArguments extends Equatable { + PersistentQueueArguments({ + required this.queue, + this.editing = false, + }) : assert( + !editing || queue is Playlist, + "The `editing` is only valid with playlists" + ); + + /// The queue to be opened. + final T queue; + + // Whether to open the playlist route in edtining mode. + final bool editing; + + @override + List get props => [queue, editing]; +} + +/// The artist `T` content. +/// +/// Only [Song] and [Album] are valid. +class ArtistContentArguments { + ArtistContentArguments({ + required this.artist, + required this.list, + }) : assert(T == Song || T == Album); + + /// Artist the contents of which to view. + final Artist artist; + + /// The list of artist content for initial render. + /// + /// Further this will be updated automatically on [ContentState.onContentChange]. + /// + /// Because this is created in [ArtistRoute], there we already have a list + /// of artist content. + final List list; } class SearchArguments { - const SearchArguments({ + SearchArguments({ this.query = '', - this.openKeyboard = true - }); + this.openKeyboard = true, + }) { + _delegate.query = query; + _delegate.autoKeyboard = openKeyboard; + } + final ContentSearchDelegate _delegate = ContentSearchDelegate(); final String query; final bool openKeyboard; } @@ -80,7 +203,7 @@ class SearchArguments { class AppRouteInformationParser extends RouteInformationParser { @override Future parseRouteInformation(RouteInformation routeInformation) async { - return AppRoutes._(routeInformation.location); + return AppRoutes._(routeInformation.location!); } @override @@ -92,7 +215,7 @@ class AppRouteInformationParser extends RouteInformationParser { class HomeRouteInformationParser extends RouteInformationParser { @override Future parseRouteInformation(RouteInformation routeInformation) async { - return HomeRoutes._(routeInformation.location); + return HomeRoutes._(routeInformation.location!); } @override @@ -102,32 +225,38 @@ class HomeRouteInformationParser extends RouteInformationParser { } mixin _DelegateMixin on RouterDelegate, ChangeNotifier { - List get _routes; + RouteObserver get observer; + + /// Route stack. List get routes => List.unmodifiable(_routes); + List get _routes; + + /// Returns the route laying on top of the stack. + T get currentRoute => _routes.last; + + // For web application + @override + T get currentConfiguration => currentRoute; /// Goes to some route. /// - /// By default, if route already in the stack, removes all routes on top of it. + /// If route already in the stack and lies just below the current route, + /// removes current route to reveal it. /// - /// However, if [allowStackSimilar] is `true`, then if similar route is on top, - /// for example [HomeRoutes.album], and other one is pushed, it will be stacked on top. - void goto(T route, [bool allowStackSimilar = false]) { - final index = !allowStackSimilar - ? _routes.indexOf(route) - : _routes.lastIndexOf(route); - if (!allowStackSimilar - ? index > 0 - : index > 0 && index != _routes.length - 1) { - for (int i = index + 1; i < _routes.length; i++) { - _routes.remove(_routes[i]); + /// Otherwise just adds the new route. + void goto(T route) { + final index = _routes.indexOf(route); + if (index != _routes.length - 1) { + if (index > 0 && index == _routes.length - 2) { + _routes.remove(_routes.last); + } else { + _routes.add(route); } - } else { - _routes.add(route); } notifyListeners(); } - bool _handlePopPage(Route route, dynamic result) { + bool _handlePopPage(Route route, Object? result) { final bool success = route.didPop(result); if (success) { if (_routes.length <= 1) { @@ -143,11 +272,11 @@ mixin _DelegateMixin on RouterDelegate, ChangeNotifier { class _TransitionSettings { _TransitionSettings({ - @required this.grey, - @required this.greyDismissible, - @required this.dismissible, - @required this.initial, - @required this.theme, + required this.grey, + required this.greyDismissible, + required this.dismissible, + required this.initial, + required this.theme, }); /// Used on [HomeRouter] routes that cannot be dismissed. @@ -162,7 +291,7 @@ class _TransitionSettings { final StackFadeRouteTransitionSettings theme; } -class AppRouter extends RouterDelegate +class AppRouter extends RouterDelegate> with ChangeNotifier, _DelegateMixin, PopNavigatorRouterDelegateMixin { @@ -170,13 +299,12 @@ class AppRouter extends RouterDelegate AppRouter._(); static final instance = AppRouter._(); - final List __routes = [AppRoutes.initial]; @override - List get _routes => __routes; + RouteObserver get observer => routeObserver; - // for web applicatiom @override - AppRoutes get currentConfiguration => _routes.last; + List> get _routes => __routes; + final List> __routes = [AppRoutes.initial as AppRoutes]; @override Future setNewRoutePath(AppRoutes configuration) async { } @@ -216,7 +344,7 @@ class AppRouter extends RouterDelegate updateTransitionSettings(); } - VoidCallback setState; + VoidCallback? setState; void updateTransitionSettings({bool themeChanged = false}) { final dismissBarrier = _dismissBarrier; transitionSettings.grey.uiStyle = Constants.UiTheme.grey.auto; @@ -242,53 +370,68 @@ class AppRouter extends RouterDelegate return StatefulBuilder( builder: (BuildContext context, setState) { this.setState = () => setState(() { }); + + final pages = >[ + StackFadePage( + key: AppRoutes.initial.uniqueKey, + child: const InitialRoute(), + transitionSettings: transitionSettings.initial, + ), + ]; + + for (int i = 0; i < _routes.length; i++) { + final route = _routes[i]; + if (route.hasSameLocation(AppRoutes.settings)) { + pages.add(StackFadePage( + key: AppRoutes.settings.uniqueKey, + child: const SettingsRoute(), + transitionSettings: transitionSettings.dismissible, + )); + } else if (route.hasSameLocation(AppRoutes.themeSettings)) { + pages.add(StackFadePage( + key: AppRoutes.themeSettings.uniqueKey, + child: const ThemeSettingsRoute(), + transitionSettings: transitionSettings.theme, + )); + } else if (route.hasSameLocation(AppRoutes.licenses)) { + pages.add(StackFadePage( + key: AppRoutes.licenses.uniqueKey, + child: const LicensePage(), + transitionSettings: transitionSettings.dismissible, + )); + } else if (route.hasSameLocation(AppRoutes.dev)) { + pages.add(StackFadePage( + key: AppRoutes.dev.uniqueKey, + child: const DevRoute(), + transitionSettings: transitionSettings.dismissible, + )); + } else if (route.hasSameLocation(AppRoutes.selection)) { + pages.add(StackFadePage( + key: AppRoutes.selection.uniqueKey, + child: SelectionRoute( + selectionArguments: (route as AppRoutes).arguments!, + ), + transitionSettings: transitionSettings.greyDismissible, + )); + } + } return Navigator( key: navigatorKey, observers: [routeObserver], onPopPage: _handlePopPage, - pages: >[ - StackFadePage( - key: AppRoutes.initial.key, - child: const InitialRoute(), - transitionSettings: transitionSettings.initial, - ), - if (_routes.length > 1 && _routes[1] == AppRoutes.settings) - StackFadePage( - key: AppRoutes.settings.key, - child: const SettingsRoute(), - transitionSettings: transitionSettings.dismissible, - ), - if (_routes.length > 2 && _routes[2] == AppRoutes.themeSettings) - StackFadePage( - key: AppRoutes.themeSettings.key, - child: const ThemeSettingsRoute(), - transitionSettings: transitionSettings.theme, - ), - if (_routes.length > 2 && _routes[2] == AppRoutes.licenses) - StackFadePage( - key: AppRoutes.licenses.key, - child: const LicensePage(), - transitionSettings: transitionSettings.dismissible, - ), - if (_routes.length > 1 && _routes[1] == AppRoutes.dev) - StackFadePage( - key: AppRoutes.dev.key, - child: const DevRoute(), - transitionSettings: transitionSettings.dismissible, - ), - ], + pages: pages, ); }, ); } } -class HomeRouter extends RouterDelegate +class HomeRouter extends RouterDelegate> with ChangeNotifier, _DelegateMixin, PopNavigatorRouterDelegateMixin { - HomeRouter() { + HomeRouter.main() : selectionArguments = null { AppRouter.instance.mainScreenShown = true; _instance = this; // _quickActionsSub = ContentControl.quickAction.listen((action) { @@ -298,50 +441,68 @@ class HomeRouter extends RouterDelegate // }); } - static HomeRouter _instance; - static HomeRouter get instance => _instance; + HomeRouter.selection(SelectionArguments this.selectionArguments); + + final SelectionArguments? selectionArguments; + bool get selectionRoute => selectionArguments != null; + + static HomeRouter? _instance; + static HomeRouter get instance => _instance!; + + static HomeRouter of(BuildContext context) { + return _RouterDelegateProvider.maybeOf(context)!; + } + + static HomeRouter? maybeOf(BuildContext context) { + return _RouterDelegateProvider.maybeOf(context); + } @override void dispose() { _instance = null; AppRouter.instance.mainScreenShown = false; - _quickActionsSub.cancel(); + // _quickActionsSub.cancel(); super.dispose(); } - StreamSubscription _quickActionsSub; + // late StreamSubscription _quickActionsSub; - final List __routes = [HomeRoutes.tabs]; @override - List get _routes => __routes; + RouteObserver get observer => selectionRoute ? _observer : homeRouteObserver; + late final RouteObserver _observer = RouteObserver(); @override - final GlobalKey navigatorKey = GlobalKey(); + List> get _routes => __routes; + final List> __routes = [HomeRoutes.tabs as HomeRoutes]; - // for web applicatiom @override - HomeRoutes get currentConfiguration => _routes.last; + final GlobalKey navigatorKey = GlobalKey(); @override Future setNewRoutePath(HomeRoutes configuration) async { } final tabsRouteKey = GlobalKey(); - SearchDelegate _searchDelegate; + + ContentSearchDelegate? get _currentSearchDelegate => _routes.last.hasSameLocation(HomeRoutes.search) + ? (_routes.last as HomeRoutes).arguments!._delegate + : null; /// Whether the drawer can be opened. bool get drawerCanBeOpened { final selectionController = ContentControl.state.selectionNotifier.value; return playerRouteController.closed && (selectionController?.notInSelection ?? true) && - routes.last != HomeRoutes.album && - ((tabsRouteKey.currentState?.tabController?.animation?.value ?? -1) == 0.0 || routes.length > 1); + (routes.last.hasSameLocation(HomeRoutes.tabs) || routes.last.hasSameLocation(HomeRoutes.search)) && + ((tabsRouteKey.currentState?.tabController.animation?.value ?? -1) == 0.0 || routes.length > 1) && + !(tabsRouteKey.currentState?.tabBarDragged ?? false) && + !(_currentSearchDelegate?.chipsBarDragged ?? false); } /// Callback that must be called before any pop. /// /// For example we want that player route would be closed first. bool handleNecessaryPop() { - final selectionController = ContentControl.state.selectionNotifier?.value; + final selectionController = ContentControl.state.selectionNotifier.value; if (playerRouteController.opened) { if (selectionController != null) { selectionController.close(); @@ -352,7 +513,9 @@ class HomeRouter extends RouterDelegate } else if (drawerController.opened) { drawerController.close(); return true; - } else if (selectionController != null) { + // Don't try to close the alwaysInSelection controller, since it is not possible + } else if (selectionController != null && + !selectionController.alwaysInSelection) { selectionController.close(); return true; } @@ -361,24 +524,45 @@ class HomeRouter extends RouterDelegate /// The [allowStackSimilar] parameter in this override is ignored and set automatically. @override - void goto(HomeRoutes route, [bool allowStackSimilar = false]) { - super.goto(route, false); - if (route == HomeRoutes.album) { - playerRouteController.close(); - } else if (route == HomeRoutes.search) { - _searchDelegate ??= SearchDelegate(); - final SearchArguments arguments = route.arguments; - _searchDelegate.query = arguments.query; - _searchDelegate.autoKeyboard = arguments.openKeyboard; + void goto(HomeRoutes route) { + playerRouteController.close(); + if (route.hasSameLocation(HomeRoutes.search) && + _routes.last.hasSameLocation(HomeRoutes.search)) { + final lastRoute = _routes.last as HomeRoutes; + final newArguments = (route as HomeRoutes).arguments!; + lastRoute.arguments!._delegate.query = newArguments.query; + lastRoute.arguments!._delegate.autoKeyboard = newArguments.openKeyboard; + } else { + super.goto(route); } } - @override - bool _handlePopPage(Route route, dynamic result) { - if (_searchDelegate != null && _routes.contains(HomeRoutes.search)) { - _searchDelegate = null; + Page _buildPage( + LocalKey key, + StackFadeRouteTransitionSettings transitionSettings, + Widget child, + ) { + if (selectionRoute) { + child = Padding( + padding: const EdgeInsets.only(bottom: kSongTileHeight), + child: child, + ); + } + return StackFadePage( + key: key, + transitionSettings: transitionSettings, + child: child, + ); + } + + Widget _buildChild(Widget child) { + if (selectionRoute) { + child = Padding( + padding: const EdgeInsets.only(bottom: kSongTileHeight), + child: child, + ); } - return super._handlePopPage(route, result); + return child; } @override @@ -387,46 +571,77 @@ class HomeRouter extends RouterDelegate final pages = >[]; for (int i = 0; i < _routes.length; i++) { - /// TODO: when i'll be adding artists and other contents, i should stack them together (including albums) - /// - /// I can use this - /// - /// ```dart - /// ValueKey('${HomeRoutes.album.location}/${(route.arguments as Album).id}_$i') - /// ``` - /// - /// Currently i don't enable it, since there's on reason for that, as the only possible way - /// to stack album routes is through selection action to go to album - but this is disabled - /// in albums + LocalKey _buildContentKey(_Routes route, Content content) { + return ValueKey('${route.location}/${content.id}_$i'); + } final route = _routes[i]; - if (route == HomeRoutes.tabs) { - pages.add(StackFadePage( - key: HomeRoutes.tabs.key, - child: TabsRoute(key: tabsRouteKey), - transitionSettings: transitionSettings.grey, + if (route.hasSameLocation(HomeRoutes.tabs)) { + pages.add(_buildPage( + HomeRoutes.tabs.uniqueKey, + transitionSettings.grey, + TabsRoute(key: tabsRouteKey), + )); + + } else if (route.hasSameLocation(HomeRoutes.album)) { + final arguments = route.arguments! as PersistentQueueArguments; + pages.add(_buildPage( + _buildContentKey(HomeRoutes.album, arguments.queue), + transitionSettings.greyDismissible, + PersistentQueueRoute(arguments: arguments), + )); + + } else if (route.hasSameLocation(HomeRoutes.playlist)) { + final arguments = route.arguments! as PersistentQueueArguments; + pages.add(_buildPage( + _buildContentKey(route, arguments.queue), + transitionSettings.greyDismissible, + PersistentQueueRoute(arguments: arguments), )); - } else if (route == HomeRoutes.album) { - pages.add(StackFadePage( - key: HomeRoutes.album.key, - transitionSettings: transitionSettings.greyDismissible, - child: AlbumRoute(album: route.arguments), + + } else if (route.hasSameLocation(HomeRoutes.artist)) { + final arguments = route.arguments! as Artist; + pages.add(_buildPage( + _buildContentKey(route, arguments), + transitionSettings.greyDismissible, + ArtistRoute(artist: arguments), + )); + + } else if (route.hasSameLocation(HomeRoutes.artistContent)) { + final arguments = route.arguments! as ArtistContentArguments; + final ArtistContentRoute _route; + if (arguments is ArtistContentArguments) + _route = ArtistContentRoute(arguments: arguments); + else if (arguments is ArtistContentArguments) + _route = ArtistContentRoute(arguments: arguments); + else + throw ArgumentError(); + pages.add(_buildPage( + ValueKey('${HomeRoutes.artistContent.location}/${arguments.artist.id}_$i'), + transitionSettings.greyDismissible, + _route, )); - } else if (route == HomeRoutes.search) { + + } else if (route.hasSameLocation(HomeRoutes.search)) { + final arguments = route.arguments! as SearchArguments; pages.add(SearchPage( - key: HomeRoutes.search.key, - delegate: _searchDelegate, + key: ValueKey('${HomeRoutes.search.location}/$i'), + child: _buildChild(SearchRoute(delegate: arguments._delegate)), transitionSettings: transitionSettings.grey, )); + } else { throw UnimplementedError(); } } - return Navigator( - key: navigatorKey, - observers: [homeRouteObserver], - onPopPage: _handlePopPage, - pages: pages, + return _RouterDelegateProvider( + delegate: this, + child: Navigator( + key: navigatorKey, + observers: [observer], + onPopPage: _handlePopPage, + pages: pages, + ), ); } } @@ -448,3 +663,21 @@ class HomeRouteBackButtonDispatcher extends ChildBackButtonDispatcher { return super.invokeCallback(defaultValue); } } + +class _RouterDelegateProvider extends InheritedWidget { + _RouterDelegateProvider({ + Key? key, + required this.delegate, + required Widget child, + }) : super(key: key, child: child); + + final T delegate; + + static T? maybeOf(BuildContext context) { + return (context.getElementForInheritedWidgetOfExactType<_RouterDelegateProvider>()?.widget + as _RouterDelegateProvider?)?.delegate; + } + + @override + bool updateShouldNotify(covariant InheritedWidget oldWidget) => false; +} \ No newline at end of file diff --git a/lib/routes/selection_route.dart b/lib/routes/selection_route.dart new file mode 100644 index 000000000..2bb41e991 --- /dev/null +++ b/lib/routes/selection_route.dart @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:flutter/material.dart'; +import 'package:sweyer/sweyer.dart'; + +class SelectionRoute extends StatefulWidget { + const SelectionRoute({ + Key? key, + required this.selectionArguments, + }) : super(key: key); + + final SelectionArguments selectionArguments; + + @override + _SelectionRouteState createState() => _SelectionRouteState(); +} + +class _SelectionRouteState extends State { + late final HomeRouter nestedHomeRouter = HomeRouter.selection(widget.selectionArguments); + late final ContentSelectionController controller; + + @override + void initState() { + super.initState(); + var settingsOpened = false; + controller = ContentSelectionController.createAlwaysInSelection( + context: context, + actionsBuilder: (context) { + final l10n = getl10n(context); + final settingsPageBuilder = widget.selectionArguments.settingsPageBuilder; + return [ + if (settingsPageBuilder != null) + NFIconButton( + icon: const Icon(Icons.settings_rounded), + onPressed: () async { + if (!settingsOpened) { + settingsOpened = true; + await nestedHomeRouter.navigatorKey.currentState!.push(StackFadeRouteTransition( + child: Builder(builder: (context) => settingsPageBuilder(context)), + transitionSettings: AppRouter.instance.transitionSettings.greyDismissible, + )); + settingsOpened = false; + } + }, + ), + const SizedBox(width: 6.0), + AnimatedBuilder( + animation: controller, + builder: (context, child) => AppButton( + text: l10n.done, + onPressed: controller.data.isEmpty ? null : () { + widget.selectionArguments.onSubmit(controller.data); + Navigator.of(this.context).pop(); + }, + ), + ), + ]; + }, + ); + widget.selectionArguments.selectionController = controller; + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + if (mounted) { + controller.overlay = nestedHomeRouter.navigatorKey.currentState!.overlay; + controller.activate(); + } + }); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Router( + routerDelegate: nestedHomeRouter, + routeInformationParser: HomeRouteInformationParser(), + routeInformationProvider: HomeRouteInformationProvider(), + backButtonDispatcher: ChildBackButtonDispatcher( + Router.of(context).backButtonDispatcher!, + ), + ); + } +} diff --git a/lib/routes/settings_route/general_settings.dart b/lib/routes/settings_route/general_settings.dart index a4d312923..a96ce0634 100644 --- a/lib/routes/settings_route/general_settings.dart +++ b/lib/routes/settings_route/general_settings.dart @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import 'package:flutter/foundation.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; +// import 'package:sweyer/sweyer.dart'; import 'package:flutter/material.dart'; class GeneralSettingsRoute extends StatefulWidget { - const GeneralSettingsRoute({Key key}) : super(key: key); + const GeneralSettingsRoute({Key? key}) : super(key: key); @override _GeneralSettingsRouteState createState() => _GeneralSettingsRouteState(); } @@ -22,9 +22,11 @@ class _GeneralSettingsRouteState extends State { @override Widget build(BuildContext context) { final l10n = getl10n(context); - return NFPageBase( - name: l10n.general, - child: ListView( + return Scaffold( + appBar: AppBar( + title: Text(l10n.general), + ), + body: ListView( physics: const NeverScrollableScrollPhysics(), children: const [ // _MinFileDurationSlider( diff --git a/lib/routes/settings_route/licenses_route.dart b/lib/routes/settings_route/licenses_route.dart index a97531436..b11129542 100644 --- a/lib/routes/settings_route/licenses_route.dart +++ b/lib/routes/settings_route/licenses_route.dart @@ -6,8 +6,6 @@ * See ThirdPartyNotices.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @dart = 2.12 - /// ########################################################################################### /// copied this from flutter https://github.com/flutter/flutter/commit/183f0e797a3bf8aa1b35b650150f7522d5d10377 /// ########################################################################################### @@ -15,14 +13,11 @@ import 'dart:developer' show Timeline, Flow; import 'dart:io' show Platform; -// TODO: remove all ignores when migrate to nnbd -// ignore: import_of_legacy_library_into_null_safe import 'package:animations/animations.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide Flow; import 'package:flutter/scheduler.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; -// ignore: import_of_legacy_library_into_null_safe + import 'package:sweyer/sweyer.dart'; /// A page that shows licenses for software used by the application. @@ -123,9 +118,7 @@ class _PackagesView extends StatefulWidget { Key? key, required this.isLateral, required this.selectedId, - // ignore: unnecessary_null_comparison - }) : assert(isLateral != null), - super(key: key); + }) : super(key: key); final bool isLateral; final ValueNotifier selectedId; @@ -456,19 +449,15 @@ class _PackageLicensePageState extends State<_PackageLicensePage> { final Widget page; if (widget.scrollController == null) { page = Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kNFAppBarPreferredSize), - child: AppBar( - elevation: 2.0, - leading: NFBackButton( - onPressed: () => Navigator.of(context).pop(), - ), - titleSpacing: 0.0, - title: _PackageLicensePageTitle( - title, - subtitle, - theme.appBarTheme.textTheme ?? theme.primaryTextTheme, - ), + appBar: AppBar( + leading: NFBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: _PackageLicensePageTitle( + title: title, + subtitle: subtitle, + titleTextStyle: theme.appBarTheme.titleTextStyle?.copyWith(fontSize: 20.0), + subtitleTextStyle: theme.appBarTheme.titleTextStyle?.copyWith(fontSize: 15.0), ), ), body: Center( @@ -484,25 +473,23 @@ class _PackageLicensePageState extends State<_PackageLicensePage> { page = CustomScrollView( controller: widget.scrollController, slivers: [ - PreferredSize( - preferredSize: const Size.fromHeight(kNFAppBarPreferredSize), - child: SliverAppBar( - automaticallyImplyLeading: false, - titleSpacing: 0.0, - pinned: true, - backgroundColor: theme.colorScheme.secondary, - title: _PackageLicensePageTitle(title, subtitle, theme.textTheme), + SliverAppBar( + automaticallyImplyLeading: false, + titleSpacing: 0.0, + pinned: true, + backgroundColor: theme.colorScheme.secondary, + title: _PackageLicensePageTitle( + title: title, + subtitle: subtitle, + titleTextStyle: theme.textTheme.headline6, + subtitleTextStyle: theme.textTheme.subtitle2, ), ), SliverPadding( padding: padding, sliver: SliverList( delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) => Localizations.override( - locale: const Locale('en', 'US'), - context: context, - child: listWidgets[index], - ), + (BuildContext context, int index) => listWidgets[index], childCount: listWidgets.length, ), ), @@ -518,16 +505,18 @@ class _PackageLicensePageState extends State<_PackageLicensePage> { } class _PackageLicensePageTitle extends StatelessWidget { - const _PackageLicensePageTitle( - this.title, - this.subtitle, - this.theme, { + const _PackageLicensePageTitle({ Key? key, + required this.title, + required this.subtitle, + required this.titleTextStyle, + required this.subtitleTextStyle, }) : super(key: key); final String title; final String subtitle; - final TextTheme theme; + final TextStyle? titleTextStyle; + final TextStyle? subtitleTextStyle; @override Widget build(BuildContext context) { @@ -537,11 +526,8 @@ class _PackageLicensePageTitle extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: theme.headline6), - Text( - subtitle, - style: theme.subtitle2 ?? const TextStyle(fontSize: 15.0), - ), + Text(title, style: titleTextStyle), + Text(subtitle, style: subtitleTextStyle), ], ), ); @@ -635,15 +621,7 @@ class _MasterDetailFlow extends StatefulWidget { this.masterPageBuilder, this.masterViewWidth, this.title, - // ignore: unnecessary_null_comparison - }) : assert(masterViewBuilder != null), - // ignore: unnecessary_null_comparison - assert(automaticallyImplyLeading != null), - // ignore: unnecessary_null_comparison - assert(detailPageBuilder != null), - // ignore: unnecessary_null_comparison - assert(displayMode != null), - super(key: key); + }) : super(key: key); /// Builder for the master view for lateral navigation. /// @@ -852,8 +830,7 @@ class _MasterDetailFlowState extends State<_MasterDetailFlow> child: Navigator( key: _navigatorKey, initialRoute: 'initial', - onGenerateInitialRoutes: - (NavigatorState navigator, String initialRoute) { + onGenerateInitialRoutes: (NavigatorState navigator, String initialRoute) { switch (focus) { case _Focus.master: return >[masterPageRoute]; @@ -903,8 +880,7 @@ class _MasterDetailFlowState extends State<_MasterDetailFlow> flexibleSpace: widget.flexibleSpace, automaticallyImplyLeading: widget.automaticallyImplyLeading, floatingActionButton: widget.floatingActionButton, - floatingActionButtonLocation: - widget.floatingActionButtonMasterPageLocation, + floatingActionButtonLocation: widget.floatingActionButtonMasterPageLocation, masterViewBuilder: widget.masterViewBuilder, actionBuilder: widget.actionBuilder, ), @@ -922,11 +898,11 @@ class _MasterDetailFlowState extends State<_MasterDetailFlow> onBackButtonPressed: () async { // No need for setState() as rebuild happens on navigation pop. focus = _Focus.master; - Navigator.of(context).pop(); - return true; + return Navigator.of(context).maybePop(); }, child: BlockSemantics( - child: widget.detailPageBuilder(context, arguments, null)), + child: widget.detailPageBuilder(context, arguments, null), + ), ); }, ), @@ -984,20 +960,15 @@ class _MasterPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kNFAppBarPreferredSize), - child: AppBar( - elevation: 2.0, - titleSpacing: 0.0, - title: title, - leading: leading, - actions: actionBuilder == null - ? const [] - : actionBuilder!(context, _ActionLevel.composite), - centerTitle: centerTitle, - flexibleSpace: flexibleSpace, - automaticallyImplyLeading: automaticallyImplyLeading, - ), + appBar: AppBar( + title: title, + leading: leading, + actions: actionBuilder == null + ? const [] + : actionBuilder!(context, _ActionLevel.composite), + centerTitle: centerTitle, + flexibleSpace: flexibleSpace, + automaticallyImplyLeading: automaticallyImplyLeading, ), body: masterViewBuilder!(context, false), floatingActionButton: floatingActionButton, @@ -1027,11 +998,7 @@ class _MasterDetailScaffold extends StatefulWidget { this.detailPageFABlessGutterWidth, this.detailPageFABGutterWidth, this.masterViewWidth, - // ignore: unnecessary_null_comparison - }) : assert(detailPageBuilder != null), - // ignore: unnecessary_null_comparison - assert(masterViewBuilder != null), - super(key: key); + }) : super(key: key); final _MasterViewBuilder masterViewBuilder; @@ -1080,15 +1047,13 @@ class _MasterDetailScaffoldState extends State<_MasterDetailScaffold> @override void openDetailPage(Object arguments) { - SchedulerBinding.instance! - .addPostFrameCallback((_) => _detailArguments.value = arguments); + SchedulerBinding.instance!.addPostFrameCallback((_) => _detailArguments.value = arguments); _MasterDetailFlow.of(context)!.openDetailPage(arguments); } @override void setInitialDetailPage(Object arguments) { - SchedulerBinding.instance! - .addPostFrameCallback((_) => _detailArguments.value = arguments); + SchedulerBinding.instance!.addPostFrameCallback((_) => _detailArguments.value = arguments); _MasterDetailFlow.of(context)!.setInitialDetailPage(arguments); } @@ -1102,36 +1067,33 @@ class _MasterDetailScaffoldState extends State<_MasterDetailScaffold> floatingActionButtonLocation: floatingActionButtonLocation, body: _masterPanel(context), floatingActionButton: widget.floatingActionButton, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kNFAppBarPreferredSize), - child: AppBar( - titleSpacing: 0.0, - elevation: 2.0, - title: widget.title, - actions: widget.actionBuilder!(context, _ActionLevel.top), - leading: widget.leading, - automaticallyImplyLeading: widget.automaticallyImplyLeading, - centerTitle: widget.centerTitle, - // bottom: PreferredSize( - // preferredSize: const Size.fromHeight(kToolbarHeight), - // child: Row( - // mainAxisAlignment: MainAxisAlignment.start, - // children: [ - // ConstrainedBox( - // constraints: - // BoxConstraints.tightFor(width: masterViewWidth), - // child: IconTheme( - // data: Theme.of(context).primaryIconTheme, - // child: ButtonBar( - // children: - // widget.actionBuilder!(context, _ActionLevel.view), - // ), - // ), - // ) - // ], - // ), - // ), - ), + appBar: AppBar( + titleSpacing: 0.0, + elevation: 2.0, + title: widget.title, + actions: widget.actionBuilder!(context, _ActionLevel.top), + leading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + centerTitle: widget.centerTitle, + // bottom: PreferredSize( + // preferredSize: const Size.fromHeight(kToolbarHeight), + // child: Row( + // mainAxisAlignment: MainAxisAlignment.start, + // children: [ + // ConstrainedBox( + // constraints: + // BoxConstraints.tightFor(width: masterViewWidth), + // child: IconTheme( + // data: Theme.of(context).primaryIconTheme, + // child: ButtonBar( + // children: + // widget.actionBuilder!(context, _ActionLevel.view), + // ), + // ), + // ) + // ], + // ), + // ), ), ), // Detail view stacked above main scaffold and master view. @@ -1192,17 +1154,14 @@ class _MasterDetailScaffoldState extends State<_MasterDetailScaffold> ? Scaffold( backgroundColor: Colors.red, body: widget.masterViewBuilder(context, true), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(kNFAppBarPreferredSize), - child: AppBar( - titleSpacing: 0.0, - elevation: 2.0, - title: widget.title, - actions: widget.actionBuilder!(context, _ActionLevel.top), - leading: widget.leading, - automaticallyImplyLeading: widget.automaticallyImplyLeading, - centerTitle: widget.centerTitle, - ), + appBar: AppBar( + titleSpacing: 0.0, + elevation: 2.0, + title: widget.title, + actions: widget.actionBuilder!(context, _ActionLevel.top), + leading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + centerTitle: widget.centerTitle, ), ) : widget.masterViewBuilder(context, true), @@ -1215,11 +1174,9 @@ class _DetailView extends StatelessWidget { Key? key, required _DetailPageBuilder builder, Object? arguments, - // ignore: unnecessary_null_comparison - }) : assert(builder != null), - _builder = builder, - _arguments = arguments, - super(key: key); + }) : _builder = builder, + _arguments = arguments, + super(key: key); final _DetailPageBuilder _builder; final Object? _arguments; diff --git a/lib/routes/settings_route/settings_route.dart b/lib/routes/settings_route/settings_route.dart index aa4b0bb48..e248a185f 100644 --- a/lib/routes/settings_route/settings_route.dart +++ b/lib/routes/settings_route/settings_route.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'dart:ui'; import 'package:package_info/package_info.dart'; import 'package:sweyer/sweyer.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/constants.dart' as Constants; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -16,7 +16,7 @@ import 'package:url_launcher/url_launcher.dart'; // import 'licenses_route.dart'; class SettingsRoute extends StatefulWidget { - const SettingsRoute({Key key}) : super(key: key); + const SettingsRoute({Key? key}) : super(key: key); @override _SettingsRouteState createState() => _SettingsRouteState(); } @@ -33,9 +33,12 @@ class _SettingsRouteState extends State { @override Widget build(BuildContext context) { final l10n = getl10n(context); - return NFPageBase( - name: l10n.settings, - child: Column( + return Scaffold( + appBar: AppBar( + title: Text(l10n.settings), + leading: const NFBackButton(), + ), + body: Column( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -69,7 +72,7 @@ class _SettingsRouteState extends State { } class _Footer extends StatefulWidget { - _Footer({Key key}) : super(key: key); + _Footer({Key? key}) : super(key: key); @override _FooterState createState() => _FooterState(); @@ -83,20 +86,16 @@ class _FooterState extends State<_Footer> { String appVersion = ''; String get appName { - var postFix = ''; - if (appVersion != null) { - postFix = '@$appVersion'; - } - return Constants.Config.APPLICATION_TITLE + postFix; + return Constants.Config.APPLICATION_TITLE + '@$appVersion'; } @override void initState() { super.initState(); - _fetch(); + _init(); } - Future _fetch() async { + Future _init() async { final info = await PackageInfo.fromPlatform(); if (mounted) { setState(() { @@ -115,7 +114,7 @@ class _FooterState extends State<_Footer> { } void _handleSecretLogoClick() { - if (ContentControl.devMode.value) + if (Prefs.devMode.get()) return; final int remainingClicks = clicksForDevMode - 1 - _clickCount; final textScaleFactor = MediaQuery.of(context).textScaleFactor; @@ -128,7 +127,7 @@ class _FooterState extends State<_Footer> { if (remainingClicks < 0) { return; } else if (remainingClicks == 0) { - ContentControl.setDevMode(true); + Prefs.devMode.set(true); NFSnackbarController.showSnackbar( NFSnackbarEntry( important: true, @@ -137,7 +136,7 @@ class _FooterState extends State<_Footer> { leading: Icon( Icons.adb_rounded, color: Colors.white, - size: Constants.iconSize * textScaleFactor, + size: NFConstants.iconSize * textScaleFactor, ), title: Text(l10n.devModeGreet, style: textStyle), color: Constants.AppColors.androidGreen, @@ -202,14 +201,14 @@ class _FooterState extends State<_Footer> { appName, style: TextStyle( fontWeight: FontWeight.w800, - color: ThemeControl.theme.textTheme.headline6.color, + color: ThemeControl.theme.textTheme.headline6!.color, ), ), Text( 'Copyright (c) 2019, nt4f04uNd', style: Theme.of(context) .textTheme - .caption + .caption! .copyWith(height: 1.0), ), ], diff --git a/lib/routes/settings_route/theme_settings.dart b/lib/routes/settings_route/theme_settings.dart index 9ccfe2e9a..990cd6e26 100644 --- a/lib/routes/settings_route/theme_settings.dart +++ b/lib/routes/settings_route/theme_settings.dart @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; @@ -12,7 +12,7 @@ const double _colorItemSize = 36.0; const double _colorItemActiveBorderWidth = 2.5; class ThemeSettingsRoute extends StatefulWidget { - const ThemeSettingsRoute({Key key}) : super(key: key); + const ThemeSettingsRoute({Key? key}) : super(key: key); @override _ThemeSettingsRouteState createState() => _ThemeSettingsRouteState(); } @@ -22,8 +22,8 @@ class _ThemeSettingsRouteState extends State Color prevPrimaryColor = ThemeControl.theme.colorScheme.primary; Color primaryColor = ThemeControl.theme.colorScheme.primary; bool get switched => ThemeControl.isLight; - bool get canPop => !ThemeControl.themeChaning; - AnimationController controller; + bool get canPop => !ThemeControl.themeChaning.valueWrapper!.value; + late AnimationController controller; static const List colors = [ Constants.AppColors.deepPurpleAccent, @@ -60,7 +60,7 @@ class _ThemeSettingsRouteState extends State prevPrimaryColor = ColorTween( begin: prevPrimaryColor, end: primaryColor, - ).evaluate(controller); + ).evaluate(controller)!; }); primaryColor = color; ThemeControl.changePrimaryColor(color); @@ -82,16 +82,18 @@ class _ThemeSettingsRouteState extends State ).animate(controller); return WillPopScope( onWillPop: _handlePop, - child: NFPageBase( - name: l10n.theme, - backButton: IgnorePointer( - ignoring: !canPop, - child: const NFBackButton(), + child: Scaffold( + appBar: AppBar( + title: Text(l10n.theme), + leading: IgnorePointer( + ignoring: !canPop, + child: const NFBackButton(), + ), ), - child: ScrollConfiguration( + body: ScrollConfiguration( behavior: const GlowlessScrollBehavior(), child: AnimatedBuilder( - animation: controller, + animation: animation, child: Container( margin: const EdgeInsets.symmetric(vertical: 6.0), height: 53.0, @@ -111,7 +113,7 @@ class _ThemeSettingsRouteState extends State ), ), builder: (context, child) => ListView( - children: [ + children: [ Theme( data: ThemeControl.theme.copyWith( splashFactory: NFListTileInkRipple.splashFactory, @@ -123,10 +125,10 @@ class _ThemeSettingsRouteState extends State onChanged: _handleThemeSwitch, ), ), - child, + child!, Image.asset( Constants.Assets.ASSET_LOGO_MASK, - color: getColorForBlend(animation.value), + color: ContentArt.getColorToBlendInDefaultArt(animation.value!), colorBlendMode: BlendMode.plus, fit: BoxFit.cover, ), @@ -141,21 +143,23 @@ class _ThemeSettingsRouteState extends State class _ColorItem extends StatefulWidget { const _ColorItem({ - Key key, - this.color, + Key? key, + required this.color, + required this.onTap, this.active = false, - this.onTap, }) : super(key: key); + final Color color; final bool active; - final VoidCallback onTap; + final VoidCallback? onTap; @override _ColorItemState createState() => _ColorItemState(); } class _ColorItemState extends State<_ColorItem> with SingleTickerProviderStateMixin { - AnimationController controller; + late AnimationController controller; + @override void initState() { super.initState(); @@ -202,7 +206,7 @@ class _ColorItemState extends State<_ColorItem> with SingleTickerProviderStateMi child: SizedBox( width: _colorItemSize, child: AnimatedBuilder( - animation: controller, + animation: animation, child: Container( width: _colorItemSize, height: _colorItemSize, @@ -215,12 +219,11 @@ class _ColorItemState extends State<_ColorItem> with SingleTickerProviderStateMi ), builder: (context, child) { final margin = 12.0 * animation.value; - final borderContainerSize = - _colorItemSize + _colorItemActiveBorderWidth * 2 - margin; + final borderContainerSize = _colorItemSize + _colorItemActiveBorderWidth * 2 - margin; return Stack( alignment: Alignment.center, children: [ - child, + child!, FadeTransition( opacity: animation, child: Container( diff --git a/lib/routes/unknown_route.dart b/lib/routes/unknown_route.dart deleted file mode 100644 index a12f0d1c8..000000000 --- a/lib/routes/unknown_route.dart +++ /dev/null @@ -1,21 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'package:flutter/material.dart'; -import 'package:sweyer/sweyer.dart'; - -class UnknownRoute extends StatelessWidget { - const UnknownRoute({Key key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final l10n = getl10n(context); - return Scaffold( - body: Center( - child: Text(l10n.unknownRoute), - ), - ); - } -} diff --git a/lib/sweyer.dart b/lib/sweyer.dart index 7540edc06..30a833f4f 100644 --- a/lib/sweyer.dart +++ b/lib/sweyer.dart @@ -3,14 +3,16 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ +// @dart = 2.7 + /// This is general module of a whole app. /// /// It exports all except of constants, see `constants.dart`. -/// API is also not exported as you should use `as API`, see `api.dart`. -export 'package:sweyer/core/core.dart'; export 'package:sweyer/localization/localization.dart'; export 'package:sweyer/logic/logic.dart'; export 'package:sweyer/routes/routes.dart'; export 'package:sweyer/widgets/widgets.dart'; -export 'package:sweyer/main.dart'; +export 'package:sweyer/real_main.dart'; + +export 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; \ No newline at end of file diff --git a/lib/widgets/album_art.dart b/lib/widgets/album_art.dart deleted file mode 100644 index 7ee9ab394..000000000 --- a/lib/widgets/album_art.dart +++ /dev/null @@ -1,500 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:sweyer/constants.dart' as Constants; -import 'package:sweyer/sweyer.dart'; - -const double kSongTileArtSize = 48.0; -const double kAlbumTileArtSize = 64.0; -const double kArtBorderRadius = 10.0; - -/// `3` is the [CircularPercentIndicator.lineWidth] doubled and additional 3 spacing -/// -/// `2` is border width -const double kRotatingArtSize = kSongTileArtSize - 6 - 3 - 2; - -const Duration _kLoadAnimationDuration = Duration(milliseconds: 340); - -// TODO: comments -class AlbumArtSource { - const AlbumArtSource({ - @required this.path, - @required this.contentUri, - @required this.albumId, - }) : _none = false; - - const AlbumArtSource.none() - : _none = true, - path = null, - contentUri = null, - albumId = null; - - final bool _none; - final String path; - final String contentUri; - final int albumId; -} - -class AlbumArt extends StatefulWidget { - const AlbumArt({ - Key key, - @required this.source, - this.color, - this.size, - this.assetScale = 1.0, - this.borderRadius = kArtBorderRadius, - this.current = false, - this.highRes = false, - this.currentIndicatorScale, - this.loadAnimationDuration = _kLoadAnimationDuration, - }) : super(key: key); - - /// Creates an art for the [SongTile] or [SelectableSongTile]. - const AlbumArt.songTile({ - Key key, - @required this.source, - this.color, - this.assetScale = 1.0, - this.borderRadius = kArtBorderRadius, - this.current = false, - this.loadAnimationDuration = _kLoadAnimationDuration, - }) : size = kSongTileArtSize, - highRes = false, - currentIndicatorScale = null, - super(key: key); - - /// Creates an art for the [ALbumTile]. - /// It has the same image contents scale as [AlbumArt.songTile]. - const AlbumArt.albumTile({ - Key key, - @required this.source, - this.color, - this.assetScale = 1.0, - this.borderRadius = kArtBorderRadius, - this.current = false, - this.loadAnimationDuration = _kLoadAnimationDuration, - }) : size = kAlbumTileArtSize, - highRes = false, - currentIndicatorScale = 1.17, - super(key: key); - - /// Creates an art for the [PlayerRoute]. - /// Its image contents scale differs from the [AlbumArt.songTile] and [AlbumArt.albumTile]. - const AlbumArt.playerRoute({ - Key key, - @required this.source, - @required this.size, - this.color, - this.assetScale = 1.0, - this.borderRadius = kArtBorderRadius, - this.loadAnimationDuration = _kLoadAnimationDuration, - }) : current = false, - highRes = true, - currentIndicatorScale = null, - super(key: key); - - final AlbumArtSource source; - - /// Background color for the album art. - /// By default will use [ThemeControl.colorForBlend]. - final Color color; - - /// Album art size. - final double size; - - /// Scale that will be applied to the asset image contents. - final double assetScale; - - /// Album art border radius. - /// Defaults to [kArtBorderRadius]. - final double borderRadius; - - /// Will show current indicator if true. - /// When album art does exist, will dim it a bit and overlay the indicator. - /// Otherwise, will replace the logo placeholder image without dimming the background. - final bool current; - - /// Whether the album art is should be rendered with hight resolution (like it does in [AlbumArtPlayerRoute]). - /// Defaults to `false`. - /// - /// This changes image placeholder contents, so size of it might be different and you probably - /// want to change [assetScale]. - final bool highRes; - - // TODO: doc - final double currentIndicatorScale; - - /// Above Android Q and above album art loads from bytes, and performns an animation on load. - /// This defines the duration of this animation. - final Duration loadAnimationDuration; - - @override - _AlbumArtState createState() => _AlbumArtState(); -} - -class _AlbumArtState extends State { - // TODO: dedup this code to a separate base class - - bool get useBytes => ContentControl.sdkInt >= 29; - CancellationSignal signal; - Uint8List bytes; - bool loaded = false; - - @override - void initState() { - super.initState(); - _load(); - } - - void _load() { - if (useBytes) { - final uri = widget.source.contentUri; - assert(uri != null); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - signal = CancellationSignal(); - bytes = await ContentChannel.loadAlbumArt( - uri: uri, - size: Size.square(widget.size) * MediaQuery.of(context).devicePixelRatio, - signal: signal, - ); - if (mounted) { - setState(() { - loaded = true; - }); - } - }); - } - } - - @override - void didUpdateWidget(covariant AlbumArt oldWidget) { - if (oldWidget.source?.contentUri != widget.source?.contentUri) { - _load(); - } - super.didUpdateWidget(oldWidget); - } - - @override - void dispose() { - signal?.cancel(); - super.dispose(); - } - - Widget _buildCurrentIndicator() { - return widget.currentIndicatorScale == null - ? const CurrentIndicator() - : Transform.scale( - scale: widget.currentIndicatorScale, - child: const CurrentIndicator(), - ); - } - - bool recreated = false; - Future _recreateArt() async { - recreated = true; - await ContentChannel.fixAlbumArt(widget.source.albumId); - if (mounted) { - setState(() { }); - } - } - - @override - Widget build(BuildContext context) { - Widget child; - File file; - bool showDefault = widget.source == null || - widget.source._none || - !useBytes && widget.source.path == null || - useBytes && loaded && bytes == null; - if (!showDefault && !useBytes) { - file = File(widget.source.path); - final exists = file.existsSync(); - showDefault = !exists; - if (!exists && !recreated) { - _recreateArt(); - } - } - if (useBytes && !loaded) { - if (widget.current) { - child = Container( - alignment: Alignment.center, - width: widget.size, - height: widget.size, - child: _buildCurrentIndicator(), - ); - } else { - child = SizedBox( - width: widget.size, - height: widget.size, - ); - } - } else if (showDefault) { - if (widget.current) { - child = Container( - alignment: Alignment.center, - color: ThemeControl.theme.colorScheme.primary, - width: widget.size, - height: widget.size, - child: _buildCurrentIndicator(), - ); - } else { - child = Image.asset( - widget.highRes - ? Constants.Assets.ASSET_LOGO_MASK - : Constants.Assets.ASSET_LOGO_THUMB_INAPP, - width: widget.size, - height: widget.size, - color: widget.color != null - ? getColorForBlend(widget.color) - : ThemeControl.colorForBlend, - colorBlendMode: BlendMode.plus, - fit: BoxFit.cover, - ); - if (widget.assetScale != 1.0) { - child = Transform.scale(scale: widget.assetScale, child: child); - } - } - } else { - Image image; - if (useBytes) { - image = Image.memory( - bytes, - width: widget.size, - height: widget.size, - fit: BoxFit.cover, - ); - } else { - image = Image.file( - file, - width: widget.size, - height: widget.size, - fit: BoxFit.cover, - ); - } - if (widget.current) { - child = Stack( - children: [ - image, - Container( - alignment: Alignment.center, - color: Colors.black.withOpacity(0.5), - width: widget.size, - height: widget.size, - child: _buildCurrentIndicator(), - ), - ], - ); - } else { - child = image; - } - } - - child = ClipRRect( - borderRadius: BorderRadius.all( - Radius.circular(widget.borderRadius), - ), - child: child, - ); - - if (!useBytes) - return child; - return AnimatedSwitcher( - duration: widget.loadAnimationDuration, - switchInCurve: Curves.easeOut, - child: Container( - key: ValueKey("${widget.source?.contentUri}_$loaded"), - child: child - ), - ); - } -} - -/// Widget that shows rotating album art. -/// Used in bottom track panel and starts rotating when track starts playing. -class AlbumArtRotating extends StatefulWidget { - const AlbumArtRotating({ - Key key, - @required this.source, - @required this.initRotating, - this.color, - this.initRotation = 0.0, - }) : assert(initRotating != null), - assert(initRotation >= 0 && initRotation <= 1.0), - super(key: key); - - final AlbumArtSource source; - - /// Background color for the album art. - /// By default will use [ThemeControl.colorForBlend]. - final Color color; - - /// Should widget start rotate on mount or not - final bool initRotating; - - /// From 0.0 to 1.0 - /// Will be set as animation controller initial value - final double initRotation; - - @override - AlbumArtRotatingState createState() => AlbumArtRotatingState(); -} - -class AlbumArtRotatingState extends State with SingleTickerProviderStateMixin { - AnimationController controller; - - bool get useBytes => ContentControl.sdkInt >= 29; - CancellationSignal signal; - Uint8List bytes; - bool loaded = false; - - @override - void initState() { - super.initState(); - controller = AnimationController( - duration: const Duration(seconds: 15), - vsync: this, - ); - controller.value = widget.initRotation ?? 0; - if (widget.initRotating) { - rotate(); - } - _load(); - } - - void _load() { - if (useBytes) { - final uri = widget.source.contentUri; - assert(uri != null); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - signal = CancellationSignal(); - bytes = await ContentChannel.loadAlbumArt( - uri: uri, - size: const Size.square(kRotatingArtSize) * MediaQuery.of(context).devicePixelRatio, - signal: signal, - ); - if (mounted) { - setState(() { - loaded = true; - }); - } - }); - } - } - - @override - void didUpdateWidget(covariant AlbumArtRotating oldWidget) { - if (oldWidget.source?.contentUri != widget.source?.contentUri) { - _load(); - } - super.didUpdateWidget(oldWidget); - } - - @override - void dispose() { - controller.dispose(); - signal?.cancel(); - super.dispose(); - } - - /// Starts rotating, for use with global keys - void rotate() { - controller.repeat(); - } - - /// Stops rotating, for use with global keys - void stopRotating() { - controller.stop(); - } - - bool recreated = false; - Future _recreateArt() async { - recreated = true; - await ContentChannel.fixAlbumArt(widget.source.albumId); - if (mounted) { - setState(() { }); - } - } - - @override - Widget build(BuildContext context) { - Widget image; - File file; - bool showDefault = widget.source == null || - widget.source._none || - !useBytes && widget.source.path == null || - useBytes && loaded && bytes == null; - if (!showDefault && !useBytes) { - file = File(widget.source.path); - final exists = file.existsSync(); - showDefault = !exists; - if (!exists && !recreated) { - _recreateArt(); - } - } - if (useBytes && !loaded) { - image = const SizedBox( - width: kRotatingArtSize, - height: kRotatingArtSize, - ); - } else if (showDefault) { - image = SizedBox( - width: kRotatingArtSize, - height: kRotatingArtSize, - child: Image.asset( - Constants.Assets.ASSET_LOGO_THUMB_INAPP, - color: widget.color != null - ? getColorForBlend(widget.color) - : ThemeControl.colorForBlend, - colorBlendMode: BlendMode.plus, - fit: BoxFit.cover, - ), - ); - } else { - if (useBytes) { - image = Image.memory( - bytes, - width: kRotatingArtSize, - height: kRotatingArtSize, - fit: BoxFit.cover, - ); - } else { - image = Image.file( - file, - width: kRotatingArtSize, - height: kRotatingArtSize, - fit: BoxFit.cover, - ); - } - } - - image = AnimatedBuilder( - child: ClipRRect( - borderRadius: const BorderRadius.all( - Radius.circular(kRotatingArtSize), - ), - child: image, - ), - animation: controller, - builder: (context, child) => RotationTransition( - turns: controller, - child: child, - ), - ); - if (!useBytes) - return image; - return AnimatedSwitcher( - duration: const Duration(milliseconds: 340), - switchInCurve: Curves.easeOut, - child: Container( - key: ValueKey("${widget.source?.contentUri}_$loaded"), - child: image, - ), - ); - } -} diff --git a/lib/widgets/album_tile.dart b/lib/widgets/album_tile.dart deleted file mode 100644 index 9ac81c66e..000000000 --- a/lib/widgets/album_tile.dart +++ /dev/null @@ -1,184 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; -import 'package:sweyer/sweyer.dart'; - -/// Needed for scrollbar computations. -const double kAlbumTileHeight = kAlbumTileArtSize + _tileVerticalPadding * 2; -const double _tileVerticalPadding = 8.0; -const double _horizontalPadding = 16.0; - -class AlbumTile extends SelectableWidget { - const AlbumTile({ - Key key, - @required this.album, - this.trailing, - this.current, - this.onTap, - this.small = false, - double horizontalPadding, - }) : assert(album != null), - horizontalPadding = horizontalPadding ?? (small ? kSongTileHorizontalPadding : _horizontalPadding), - index = null, - super(key: key); - - const AlbumTile.selectable({ - Key key, - @required this.album, - @required this.index, - @required SelectionController selectionController, - bool selected = false, - this.trailing, - this.current, - this.onTap, - this.small = false, - double horizontalPadding, - }) : assert(album != null), - assert(index != null), - assert(selectionController != null), - assert(selectionController is SelectionController> || - selectionController is SelectionController>), - horizontalPadding = horizontalPadding ?? (small ? kSongTileHorizontalPadding : _horizontalPadding), - super.selectable( - key: key, - selected: selected, - selectionController: selectionController, - ); - - final Album album; - final int index; - - /// Widget to be rendered at the end of the tile. - final Widget trailing; - - /// Whether this album is currently playing, if yes, enables animated - /// [CurrentIndicator] over the ablum art. - /// - /// If not specified, by default true if album is `currentSongOrigin` or - /// if it's currently playing persistent playlist: - /// - /// ```dart - /// return album == ContentControl.state.currentSongOrigin || - /// album == ContentControl.state.queues.persistent; - /// ``` - final bool current; - final VoidCallback onTap; - - /// Creates a small variant of the tile with the sizes of [SelectableTile]. - final bool small; - final double horizontalPadding; - - @override - SelectionEntry toSelectionEntry() => SelectionEntry( - index: index, - data: album, - ); - - @override - _AlbumTileState createState() => _AlbumTileState(); -} - -class _AlbumTileState extends SelectableState { - void _handleTap() { - super.handleTap(() { - if (widget.onTap != null) { - widget.onTap(); - } - HomeRouter.instance.goto(HomeRoutes.factory.album(widget.album)); - }); - } - - bool get current { - if (widget.current != null) - return widget.current; - final album = widget.album; - return album == ContentControl.state.currentSongOrigin || - album == ContentControl.state.queues.persistent; - } - - Widget _buildTile() { - return InkWell( - onTap: _handleTap, - onLongPress: toggleSelection, - splashFactory: NFListTileInkRipple.splashFactory, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: widget.horizontalPadding, - vertical: _tileVerticalPadding, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: widget.small - ? AlbumArt.songTile( - source: AlbumArtSource( - path: widget.album.albumArt, - contentUri: widget.album.contentUri, - albumId: widget.album.id, - ), - current: current, - ) - : AlbumArt.albumTile( - source: AlbumArtSource( - path: widget.album.albumArt, - contentUri: widget.album.contentUri, - albumId: widget.album.id, - ), - current: current, - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.album.album, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ThemeControl.theme.textTheme.headline6, - ), - ArtistWidget( - artist: widget.album.artist, - textStyle: const TextStyle(fontSize: 14.0, height: 1.0), - ) - ], - ), - ), - ), - if (widget.trailing != null) - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: widget.trailing, - ), - ], - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - if (!selectable) - return _buildTile(); - return Stack( - children: [ - _buildTile(), - Positioned( - left: kAlbumTileArtSize + 2.0, - bottom: 2.0, - child: SelectionCheckmark(animation: animation), - ), - ], - ); - } -} diff --git a/lib/widgets/app_bar_border.dart b/lib/widgets/app_bar_border.dart index e1ceec45b..558a0774e 100644 --- a/lib/widgets/app_bar_border.dart +++ b/lib/widgets/app_bar_border.dart @@ -11,7 +11,7 @@ import 'package:sweyer/constants.dart' as Constants; /// Used in list views when they are scrolled and displayed below the [AppBar], /// instead of elevation. class AppBarBorder extends StatelessWidget { - const AppBarBorder({Key key, this.shown = true}) : super(key: key); + const AppBarBorder({Key? key, this.shown = true}) : super(key: key); final bool shown; diff --git a/lib/widgets/artist.dart b/lib/widgets/artist.dart index 4435d151b..cf0aef628 100644 --- a/lib/widgets/artist.dart +++ b/lib/widgets/artist.dart @@ -6,29 +6,33 @@ import 'package:sweyer/sweyer.dart'; import 'package:flutter/material.dart'; -String formatArtist(String artist, AppLocalizations l10n) => - artist != '' ? artist : l10n.artistUnknown; - /// Component to show artist, or automatically show 'Unknown artist' instead of '' class ArtistWidget extends StatelessWidget { const ArtistWidget({ - Key key, - @required this.artist, + Key? key, + required this.artist, + this.trailingText, this.overflow = TextOverflow.ellipsis, this.textStyle, }) : super(key: key); final String artist; + /// If not null, this text will be shown after appended dot. + final String? trailingText; final TextOverflow overflow; - final TextStyle textStyle; + final TextStyle? textStyle; @override Widget build(BuildContext context) { final l10n = getl10n(context); + final localizedArtist = ContentUtils.localizedArtist(artist, l10n); return Text( - formatArtist(artist, l10n), + trailingText == null ? localizedArtist : ContentUtils.joinDot([ + localizedArtist, + trailingText, + ]), overflow: overflow, - style: ThemeControl.theme.textTheme.subtitle2.merge(textStyle), + style: Theme.of(context).textTheme.subtitle2!.merge(textStyle), ); } } diff --git a/lib/widgets/bottom_track_panel.dart b/lib/widgets/bottom_track_panel.dart index b8c01ae2f..f1eb08ba8 100644 --- a/lib/widgets/bottom_track_panel.dart +++ b/lib/widgets/bottom_track_panel.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:percent_indicator/circular_percent_indicator.dart'; import 'package:sweyer/sweyer.dart'; @@ -16,11 +16,11 @@ const double progressLineHeight = 3.0; /// Renders current playing track class TrackPanel extends StatelessWidget { TrackPanel({ - Key key, + Key? key, this.onTap, }) : super(key: key); - final VoidCallback onTap; + final VoidCallback? onTap; @override Widget build(BuildContext context) { @@ -40,75 +40,78 @@ class TrackPanel extends StatelessWidget { builder: (context, snapshot) { return FadeTransition( opacity: fadeAnimation, - child: AnimatedBuilder( - animation: playerRouteController, - builder: (context, child) => IgnorePointer( - ignoring: const IgnoringStrategy( + child: RepaintBoundary( + child: AnimationStrategyBuilder( + strategy: const IgnoringStrategy( forward: true, completed: true, - ).evaluate(playerRouteController), - child: child, - ), - child: GestureDetector( - onTap: onTap, - child: Material( - color: Colors.transparent, - child: Container( - height: kSongTileHeight * math.max(0.95, textScaleFactor), - padding: const EdgeInsets.only( - left: 16.0, - right: 16.0, - top: 4.0, - bottom: 4.0, - ), - child: Row( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Transform.scale( - scale: math.min(1.1, textScaleFactor), - child: const RotatingAlbumArtWithProgress(), + ), + animation: playerRouteController, + builder: (context, value, child) => IgnorePointer( + ignoring: value, + child: child, + ), + child: GestureDetector( + onTap: onTap, + child: Material( + color: Colors.transparent, + child: Container( + height: kSongTileHeight * math.max(0.95, textScaleFactor), + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + top: 4.0, + bottom: 4.0, + ), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Transform.scale( + scale: math.min(1.1, textScaleFactor), + child: const RotatingAlbumArtWithProgress(), + ), ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 5.0, right: 5.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - NFMarquee( - key: ValueKey(ContentControl.state.currentSong.id), - fontWeight: FontWeight.w700, - text: ContentControl.state.currentSong.title, - fontSize: 16, - velocity: 26.0, - blankSpace: 40.0, - ), - Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: ArtistWidget( - artist: ContentControl.state.currentSong.artist, + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 5.0, right: 5.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NFMarquee( + key: ValueKey(ContentControl.state.currentSong.id), + fontWeight: FontWeight.w700, + text: ContentControl.state.currentSong.title, + fontSize: 16, + velocity: 26.0, + blankSpace: 40.0, + ), + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: ArtistWidget( + artist: ContentControl.state.currentSong.artist, + ), ), - ), - ], + ], + ), ), ), - ), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: textScaleFactor * 50.0), - child: const AnimatedPlayPauseButton( - size: 40.0, - iconSize: 19.0, + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: textScaleFactor * 50.0), + child: const AnimatedPlayPauseButton( + size: 40.0, + iconSize: 19.0, + ), ), - ), - ) - ], + ) + ], + ), ), ), ), @@ -121,51 +124,45 @@ class TrackPanel extends StatelessWidget { } class RotatingAlbumArtWithProgress extends StatefulWidget { - const RotatingAlbumArtWithProgress({Key key}) : super(key: key); + const RotatingAlbumArtWithProgress({Key? key}) : super(key: key); @override _RotatingAlbumArtWithProgressState createState() => _RotatingAlbumArtWithProgressState(); } -class _RotatingAlbumArtWithProgressState - extends State { +class _RotatingAlbumArtWithProgressState extends State { + static const min = 0.001; + + double initRotation = math.Random(DateTime.now().second).nextDouble(); + /// Actual track position value - Duration _value = const Duration(seconds: 0); + Duration _value = Duration.zero; // Duration of playing track - Duration _duration = const Duration(seconds: 0); + Duration _duration = Duration.zero; - StreamSubscription _positionSubscription; - StreamSubscription _songChangeSubscription; - StreamSubscription _playingSubscription; + late StreamSubscription _positionSubscription; + late StreamSubscription _songChangeSubscription; + late StreamSubscription _playingSubscription; final _rotatingArtGlobalKey = GlobalKey(); @override void initState() { super.initState(); - _value = MusicPlayer.instance.position; _duration = MusicPlayer.instance.duration; - _playingSubscription = MusicPlayer.instance.playingStream.listen((playing) { if (playing) { - _rotatingArtGlobalKey.currentState.rotate(); + _rotatingArtGlobalKey.currentState!.rotate(); } else { - _rotatingArtGlobalKey.currentState.stopRotating(); + _rotatingArtGlobalKey.currentState!.stopRotating(); } }); - - // Handle track position movement _positionSubscription = MusicPlayer.instance.positionStream.listen((position) { - if (position.inSeconds != _value.inSeconds) { - // Prevent waste updates - setState(() { - _value = position; - }); - } + setState(() { + _value = position; + }); }); - - // Handle song change _songChangeSubscription = ContentControl.state.onSongChange.listen((event) async { _value = MusicPlayer.instance.position; setState(() { @@ -182,25 +179,15 @@ class _RotatingAlbumArtWithProgressState super.dispose(); } - double _calcProgress() { - if (_value.inMilliseconds == 0.0 || _duration.inMilliseconds == 0.0) { - return 0.001; - } - // Additional safety checks - var result = _value.inMilliseconds / _duration.inMilliseconds; - if (result < 0) { - result = 0; - } else if (result > 1) { - result = 0; - } - return result; + double get _progress { + return (_value.inMilliseconds / _duration.inMilliseconds).clamp(min, 1.0); } @override Widget build(BuildContext context) { final song = ContentControl.state.currentSong; return CircularPercentIndicator( - percent: _calcProgress(), + percent: _progress, animation: true, animationDuration: 200, curve: Curves.easeOutCubic, @@ -212,12 +199,8 @@ class _RotatingAlbumArtWithProgressState backgroundColor: Colors.transparent, center: AlbumArtRotating( key: _rotatingArtGlobalKey, - source: AlbumArtSource( - path: song.albumArt, - contentUri: song.contentUri, - albumId: song.albumId, - ), - initRotation: math.Random(DateTime.now().second).nextDouble(), + source: ContentArtSource.song(song), + initRotation: initRotation, initRotating: MusicPlayer.instance.playing, ), ); diff --git a/lib/widgets/buttons.dart b/lib/widgets/buttons.dart index c451f3c53..8bcec3c7f 100644 --- a/lib/widgets/buttons.dart +++ b/lib/widgets/buttons.dart @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; /// Button to switch loop mode class LoopButton extends StatelessWidget { - const LoopButton({Key key}) : super(key: key); + const LoopButton({Key? key}) : super(key: key); @override Widget build(BuildContext context) { @@ -23,8 +23,8 @@ class LoopButton extends StatelessWidget { return AnimatedIconButton( icon: const Icon(Icons.loop_rounded), size: 40.0, - iconSize: textScaleFactor * Constants.iconSize, - active: snapshot.data, + iconSize: textScaleFactor * NFConstants.iconSize, + active: snapshot.data!, onPressed: player.switchLooping, ); }, @@ -33,7 +33,7 @@ class LoopButton extends StatelessWidget { } class ShuffleButton extends StatelessWidget { - const ShuffleButton({Key key}) : super(key: key); + const ShuffleButton({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final textScaleFactor = MediaQuery.of(context).textScaleFactor; @@ -43,7 +43,7 @@ class ShuffleButton extends StatelessWidget { icon: const Icon(Icons.shuffle_rounded), color: ThemeControl.theme.colorScheme.onSurface, size: 40.0, - iconSize: textScaleFactor * Constants.iconSize, + iconSize: textScaleFactor * NFConstants.iconSize, active: ContentControl.state.queues.shuffled, onPressed: () { ContentControl.setQueue( @@ -57,7 +57,7 @@ class ShuffleButton extends StatelessWidget { /// Icon button that opens settings page. class SettingsButton extends StatelessWidget { - const SettingsButton({Key key}) : super(key: key); + const SettingsButton({Key? key}) : super(key: key); @override Widget build(BuildContext context) { @@ -67,3 +67,165 @@ class SettingsButton extends StatelessWidget { ); } } + + +/// A button with text and icon, used to start queue playback. +/// +/// Also used in: +/// * [PlayQueueButton] +/// * [ShuffleQueueButton] +class AppButton extends StatefulWidget { + const AppButton({ + Key? key, + required this.text, + required this.onPressed, + this.icon, + this.color, + this.textColor, + this.splashColor, + this.borderRadius = 15.0, + this.fontSize, + this.fontWeight = FontWeight.w800, + }) : super(key: key); + + final String text; + final Icon? icon; + final VoidCallback? onPressed; + final Color? color; + final Color? textColor; + final Color? splashColor; + final double borderRadius; + final double? fontSize; + final FontWeight fontWeight; + + @override + State createState() => _AppButtonState(); +} + +class _AppButtonState extends State with SingleTickerProviderStateMixin { + late final controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240) + ) + ..value = disabled ? 0.0 : 1.0; + late final colorAnimation = ColorTween( + begin: ThemeControl.theme.colorScheme.onSurface.withOpacity(0.12), + end: widget.color ?? ThemeControl.theme.colorScheme.primary, + ).animate(CurvedAnimation( + parent: controller, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + )); + late final textColorAnimation = ColorTween( + begin: ThemeControl.theme.colorScheme.onSurface.withOpacity(0.38), + end: widget.textColor ?? ThemeControl.theme.colorScheme.onPrimary + ).animate(CurvedAnimation( + parent: controller, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + )); + + bool get disabled => widget.onPressed == null; + + @override + void didUpdateWidget(covariant AppButton oldWidget) { + if (disabled) { + controller.reverse(); + } else { + controller.forward(); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + Widget _buildText() { + return Padding( + padding: const EdgeInsets.only(bottom: 1.0), + child: Text(widget.text), + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, child) => ElevatedButton( + onPressed: widget.onPressed, + style: const ElevatedButton(child: null, onPressed: null).defaultStyleOf(context).copyWith( + backgroundColor: MaterialStateProperty.all(colorAnimation.value), + padding: MaterialStateProperty.all(const EdgeInsets.symmetric(horizontal: 20.0)), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(widget.borderRadius), + ) + ), + foregroundColor: MaterialStateProperty.all(textColorAnimation.value), + overlayColor: MaterialStateProperty.all(widget.splashColor ?? Constants.Theme.glowSplashColorOnContrast.auto), + splashFactory: NFListTileInkRipple.splashFactory, + shadowColor: MaterialStateProperty.all(Colors.transparent), + textStyle: MaterialStateProperty.all(TextStyle( + fontFamily: ThemeControl.theme.textTheme.headline1!.fontFamily, + fontWeight: widget.fontWeight, + fontSize: widget.fontSize, + )), + ), + child: widget.icon == null ? Text(widget.text): Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + widget.icon!, + const SizedBox(width: 6.0), + _buildText(), + const SizedBox(width: 8.0), + ], + ), + ), + ); + } +} + +/// Used to start queue playback. +class PlayQueueButton extends StatelessWidget { + const PlayQueueButton({Key? key, required this.onPressed}) : super(key: key); + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + return AppButton( + text: l10n.playContentList, + icon: const Icon(Icons.play_arrow_rounded, size: 28.0), + borderRadius: 4.0, + fontSize: 15.0, + fontWeight: FontWeight.w700, + onPressed: onPressed, + ); + } +} + +/// Used to start shuffled queue playback. +class ShuffleQueueButton extends StatelessWidget { + const ShuffleQueueButton({Key? key, required this.onPressed}) : super(key: key); + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + return AppButton( + text: l10n.shuffleContentList, + icon: const Icon(Icons.shuffle_rounded, size: 22.0), + color: Constants.Theme.contrast.auto, + textColor: ThemeControl.theme.colorScheme.background, + borderRadius: 4.0, + fontSize: 15.0, + fontWeight: FontWeight.w700, + onPressed: onPressed, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/content_art.dart b/lib/widgets/content_art.dart new file mode 100644 index 000000000..b1211b18a --- /dev/null +++ b/lib/widgets/content_art.dart @@ -0,0 +1,1097 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'dart:io'; +import 'dart:ui' as ui; +import 'dart:typed_data'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:sweyer/constants.dart' as Constants; +import 'package:sweyer/sweyer.dart'; + +const double kSongTileArtSize = 48.0; +const double kPersistentQueueTileArtSize = 64.0; +const double kArtistTileArtSize = 64.0; +// const double kArtBorderRadius = 10.0; +const double kArtBorderRadius = 10.0; + +/// `3` is the [CircularPercentIndicator.lineWidth] doubled and additional 3 spacing +/// +/// `2` is border width +const double kRotatingArtSize = kSongTileArtSize - 6 - 3 - 2; + +/// Used for loading some large arts which should be emphasized. +/// For example main content art in album or artist route. +/// +/// Used by default [ContentArt] constructor. +const Duration kArtLoadAnimationDuration = Duration(milliseconds: 240); + +/// Used for loading arts in lists. +const Duration kArtListLoadAnimationDuration = Duration(milliseconds: 200); + +/// Whether running on scoped storage, and should use bytes to load album +/// arts from `MediaStore`. +bool get _useScopedStorage => ContentControl.sdkInt >= 29; + +class ContentArtSource { + const ContentArtSource(Content content) : _content = content; + + const ContentArtSource.song(Song song) + : _content = song; + + const ContentArtSource.album(Album album) + : _content = album; + + const ContentArtSource.playlist(Playlist playlist) + : _content = playlist; + + /// Checks the kind of [PersistentQueue], and respectively either picks [ContentArtSource.album], or [ContentArtSource.playlist]. + const ContentArtSource.persistentQueue(PersistentQueue persistentQueue) + : assert(persistentQueue is Album || persistentQueue is Playlist), + _content = persistentQueue; + + const ContentArtSource.artist(Artist artist) + : _content = artist; + + const ContentArtSource.origin(SongOrigin origin) + : _content = origin; + + final Content _content; +} + +/// Image that represents the content art. +/// It can be an album art, placeholder, or some other image. +/// +/// How arts are displayed: +/// * [ContentArtSource.song] - just the song art +/// * [ContentArtSource.album] - the art of the first song in album +/// * [ContentArtSource.playlist] - grid of 4 arts, when playlist length is: +/// * 1 - 4 identical arts +/// * 2 - two arts in the first row, and same two arts on the second, though reversed +/// * 3 - arts of 3 songs, and the last one is of the first song +/// * 4 - just 4 arts of 4 songs +/// +class ContentArt extends StatefulWidget { + const ContentArt({ + Key? key, + required this.source, + this.color, + this.size, + this.defaultArtIcon, + this.defaultArtIconScale = 1.0, + this.assetScale = 1.0, + this.assetHighRes = false, + this.borderRadius = kArtBorderRadius, + this.current = false, + this.currentIndicatorScale, + this.onLoad, + this.loadAnimationDuration = kArtLoadAnimationDuration, + }) : super(key: key); + + /// Creates an art for the [SongTile] or [SelectableSongTile]. + const ContentArt.songTile({ + Key? key, + required this.source, + this.color, + this.defaultArtIcon, + this.defaultArtIconScale = 1.0, + this.assetScale = 1.0, + this.borderRadius = kArtBorderRadius, + this.current = false, + this.onLoad, + this.loadAnimationDuration = kArtListLoadAnimationDuration, + }) : size = kSongTileArtSize, + assetHighRes = false, + currentIndicatorScale = null, + super(key: key); + + /// Creates an art for the [PersistentQueueTile]. + /// It has the same image contents scale as [AlbumArt.songTile]. + const ContentArt.persistentQueueTile({ + Key? key, + required this.source, + this.color, + this.defaultArtIcon, + this.defaultArtIconScale = 1.0, + this.assetScale = 1.0, + this.borderRadius = kArtBorderRadius, + this.current = false, + this.onLoad, + this.loadAnimationDuration = kArtListLoadAnimationDuration, + }) : size = kPersistentQueueTileArtSize, + assetHighRes = false, + currentIndicatorScale = 1.17, + super(key: key); + + /// Creates an art for the [ArtistTile]. + /// It has the same image contents scale as [AlbumArt.songTile]. + const ContentArt.artistTile({ + Key? key, + required this.source, + this.color, + this.defaultArtIcon, + this.defaultArtIconScale = 1.0, + this.assetScale = 1.0, + this.borderRadius = kArtistTileArtSize, + this.current = false, + this.onLoad, + this.loadAnimationDuration = kArtListLoadAnimationDuration, + }) : size = kPersistentQueueTileArtSize, + assetHighRes = false, + currentIndicatorScale = 1.1, + super(key: key); + + /// Creates an art for the [PlayerRoute]. + /// Its image contents scale differs from the [AlbumArt.songTile] and [AlbumArt.PersistentQueueTile]. + const ContentArt.playerRoute({ + Key? key, + required this.source, + this.size, + this.color, + this.defaultArtIcon, + this.defaultArtIconScale = 1.0, + this.assetScale = 1.0, + this.borderRadius = kArtBorderRadius, + this.onLoad, + this.loadAnimationDuration = const Duration(milliseconds: 500), + }) : assetHighRes = true, + current = false, + currentIndicatorScale = null, + super(key: key); + + final ContentArtSource? source; + + /// Background color for the album art. + /// By default will use [ThemeControl.colorForBlend]. + final Color? color; + + /// Album art size. + final double? size; + + /// Icon to show as default image instead of the app logo. + /// + /// Will be ignored if [source] is created from [Song], since the song default art + /// are inteded to use an app logo. + final IconData? defaultArtIcon; + + /// Scale that will be applied to the [defaultArtIcon]. + final double defaultArtIconScale; + + /// Scale that will be applied to the asset image contents. + final double assetScale; + + /// Whether the default album art is should be rendered with hight resolution. + /// Defaults to `false`. + /// + /// This changes image contents, so size of it might be different and you probably + /// want to change [assetScale]. + final bool assetHighRes; + + /// Album art border radius. + /// Defaults to [kArtBorderRadius]. + final double borderRadius; + + /// Will show current indicator if true. + /// When album art does exist, will dim it a bit and overlay the indicator. + /// Otherwise, will replace the logo placeholder image without dimming the background. + final bool current; + + /// SCale for the [CurrentIndicator]. + final double? currentIndicatorScale; + + /// Called when art is loaded. + final Function(ui.Image)? onLoad; + + /// Above Android Q and above album art loads from bytes, and performns an animation on load. + /// This defines the duration of this animation. + final Duration loadAnimationDuration; + + /// This is the color of the mask background (by RGBs, full color would be `0x1a1a1a`). + /// It's twice lighter than the shadow color on the mask, + /// which is `0x001d0d0d`.Used in [getColorToBlendInDefaultArt]. + static const int _defaultArtMask = 0x1a; + + /// Returns the color to be blended in default art. + /// + /// The default art asset is a grey-toned mask, so we subtract that mask + /// to get the color we need to blend to get that original [color]. + static Color getColorToBlendInDefaultArt(Color color) { + final int r = (((color.value >> 16) & 0xff) - _defaultArtMask).clamp(0, 0xff); + final int g = (((color.value >> 8) & 0xff) - _defaultArtMask).clamp(0, 0xff); + final int b = ((color.value & 0xff) - _defaultArtMask).clamp(0, 0xff); + return Color((0xff << 24) + (r << 16) + (g << 8) + b); + } + + @override + _ContentArtState createState() => _ContentArtState(); +} + +/// Loading state for [_ArtSourceLoader]. +enum _SourceLoading { + /// There's not source to load. + notLoading, + + /// Source loading is in process. + loading, + + /// Source has been loaded and ready to be used. + loaded, +} + +/// Signature for function that notifies about updates of [_SourceLoading] state. +typedef OnLoadingChangeCallback = void Function(_SourceLoading); + +/// Base class for loading arts for a content. +/// It loads a source of the art and provides it to the [_ContentArtState]. + +abstract class _ArtSourceLoader { + _ArtSourceLoader({ + required this.state, + this.onLoadingChange, + }); + + /// Art state this loader is bound to. + final _ContentArtState state; + + /// Function that notifies about updates of [_SourceLoading] state. + /// If none specified [Art._onSourceLoad] will be used. + final OnLoadingChangeCallback? onLoadingChange; + + /// Loading state. + _SourceLoading get loading => _loading; + _SourceLoading _loading = _SourceLoading.notLoading; + void setLoading(_SourceLoading value) { + assert( + _loading != _SourceLoading.loaded, + "Image has loaded and loader is locked", + ); + _loading = value; + final onLoad = onLoadingChange ?? state._onSourceLoad; + onLoad(value); + } + + /// Whether art should show some default art, after the loader loads. + /// It should be invalid to call this method when not [_SourceLoading.loaded]. + bool get showDefault; + + /// Loads the source. + /// + /// Commonly, at the start of this method, state should be set to + /// [_SourceLoading.loading], and then at the end, set to [_SourceLoading.loaded]. + void load(); + + /// Cancels the source loading. + void cancel(); + + /// Returns the image built from the loaded source. + /// May return `null`, when [showDefault] is `true`. + Widget? getImage(int? cacheSize); +} + +/// Always loads the default art. +class _NoSourceLoader extends _ArtSourceLoader { + _NoSourceLoader(_ContentArtState state) : super(state: state); + + @override + bool get showDefault => true; + + @override + void load() { + if (loading != _SourceLoading.notLoading) + return; + if (state.loadAnimationDuration == Duration.zero) { + setLoading(_SourceLoading.notLoading); + } else { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + setLoading(_SourceLoading.notLoading); + }); + } + } + + @override + void cancel() { } + + @override + Widget? getImage(int? cacheSize) { + return null; + } +} + +/// Loads song art for [Song]s. +/// +/// This class automatically chooses between [_SongScopedStorageArtSourceLoader] and [_SongFileArtSourceLoader], +/// depended on Android version. +class _SongArtSourceLoader extends _ArtSourceLoader { + _SongArtSourceLoader({ + required this.song, + required this.size, + required _ContentArtState state, + }) : super(state: state) { + if (song == null) { + loader = _NoSourceLoader(state); + } else if (_useScopedStorage) { + loader = _SongScopedStorageArtSourceLoader( + song: song!, + size: size!, + onLoadingChange: (value) => setLoading(value), + state: state, + ); + } else { + loader = _SongFileArtSourceLoader( + song: song!, + size: size, + onLoadingChange: (value) => setLoading(value), + state: state, + ); + } + } + + final Song? song; + final double? size; + late final _ArtSourceLoader loader; + + @override + bool get showDefault { + assert(loading != _SourceLoading.loading); + return loader.showDefault; + } + + @override + void load() { + loader.load(); + } + + @override + void cancel() { + loader.cancel(); + } + + @override + Widget? getImage(int? cacheSize) { + return loader.getImage(cacheSize); + } +} + +/// Loads local song art with `MediaStore` API, used above Android Q. +/// +/// Lower Android Q album arts ared displayed directly from the file path +/// of album art from [Song.albumArt]. +/// +/// Above Q though, this path was deprecated due to scoped storage, and now +/// album arts should be fetched with special method in `MediaStore.loadThumbnail`. +/// +/// The [_useScopedStorage] indicates that we are on scoped storage and should use this +/// new method. +/// +/// See also: +/// * [_SongFileArtSourceLoader], which loads arts from files +/// * [_SongArtSourceLoader], which automatically chooses between this loader and [_SongFileArtSourceLoader], +/// dependent on Android version +class _SongScopedStorageArtSourceLoader extends _ArtSourceLoader { + _SongScopedStorageArtSourceLoader({ + required this.song, + required this.size, + required _ContentArtState state, + required OnLoadingChangeCallback onLoadingChange, + }) : super(state: state, onLoadingChange: onLoadingChange); + + final Song song; + final double size; + + CancellationSignal? _signal; + Uint8List? _bytes; + + @override + bool get showDefault { + assert(loading != _SourceLoading.loading); + return _bytes == null; + } + + @override + void load() { + if (loading != _SourceLoading.notLoading) + return; + setLoading(_SourceLoading.loading); + final uri = song.contentUri; + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) async { + if (!state.mounted) + return; + _signal = CancellationSignal(); + _bytes = await ContentChannel.loadAlbumArt( + uri: uri, + size: Size.square(size) * MediaQuery.of(state.context).devicePixelRatio, + signal: _signal!, + ); + setLoading(_SourceLoading.loaded); + }); + } + + @override + void cancel() { + _signal?.cancel(); + _signal = null; + } + + @override + Widget? getImage(int? cacheSize) { + if (showDefault) + return null; + return Image.memory( + _bytes!, + width: size, + height: size, + cacheHeight: cacheSize, + cacheWidth: cacheSize, + fit: BoxFit.cover, + frameBuilder: state.frameBuilder, + ); + } +} + +/// Loads local song art from the file, used below Android Q. +/// +/// Also, sometimes below Android Q, album arts files sometimes become unaccessible, +/// even though they should not. This loader will try to restore them with [Song.albumId] +/// and [ContentChannel.fixAlbumArt]. +/// +/// See also: +/// * [_SongScopedStorageArtSourceLoader], which loads arts in scoped storage +/// * [_SongArtSourceLoader], which automatically chooses between this loader and [_SongScopedStorageArtSourceLoader], +/// dependent on Android version +class _SongFileArtSourceLoader extends _ArtSourceLoader { + _SongFileArtSourceLoader({ + required this.song, + required this.size, + required _ContentArtState state, + required OnLoadingChangeCallback onLoadingChange, + }) : super(state: state, onLoadingChange: onLoadingChange); + + final Song song; + final double? size; + + late File _file; + + @override + bool get showDefault { + assert(loading != _SourceLoading.loading); + return song.albumArt == null; + } + + @override + void load() { + if (loading != _SourceLoading.notLoading) + return; + setLoading(_SourceLoading.loading); + if (state.loadAnimationDuration == Duration.zero) { + _loadFile(); + } else { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + _loadFile(); + }); + } + } + + @override + void cancel() { } + + void _loadFile() { + if (!state.mounted) + return; + final art = song.albumArt; + bool broken = false; + if (art != null) { + _file = File(art); + final exists = _file.existsSync(); + broken = !exists; + if (broken) + _recreateArt(); + } + if (!broken) + setLoading(_SourceLoading.loaded); + } + + Future _recreateArt() async { + final ablumId = song.albumId; + if (ablumId != null) + await ContentChannel.fixAlbumArt(song.albumId!); + setLoading(_SourceLoading.loaded); + } + + @override + Widget? getImage(int? cacheSize) { + if (showDefault) + return null; + return Image.file( + _file, + width: size, + height: size, + cacheHeight: cacheSize, + cacheWidth: cacheSize, + fit: BoxFit.cover, + frameBuilder: state.frameBuilder, + ); + } +} + + +/// Makes a call to the backend which searches an for artist art +/// on Genius. +/// +/// Unknown artist will be ignored and set as not loading immediately. +class _ArtistGeniusArtSourceLoader extends _ArtSourceLoader { + _ArtistGeniusArtSourceLoader({ + required this.artist, + required this.size, + required _ContentArtState state, + }) : super(state: state); + + final Artist artist; + final double? size; + + String? _url; + + @override + bool get showDefault { + assert(loading != _SourceLoading.loading); + return _url == null; + } + + @override + void load() { + if (loading != _SourceLoading.notLoading) + return; + if (artist.isUnknown) { + setLoading(_SourceLoading.notLoading); + return; + } + setLoading(_SourceLoading.loading); + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) async { + if (!state.mounted) + return; + try { + final info = await artist.fetchInfo(); + _url = info.imageUrl; + } catch (ex, stack) { + FirebaseCrashlytics.instance.recordError(ex, stack); + } finally { + setLoading(_SourceLoading.loaded); + } + }); + } + + @override + void cancel() { } + + @override + Widget? getImage(int? cacheSize) { + if (showDefault) + return null; + return Image( + image: ResizeImage.resizeIfNeeded(cacheSize, cacheSize, CachedNetworkImageProvider(_url!)), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (context, error, stacktrace) { + FirebaseCrashlytics.instance.recordError(error, stacktrace); + return state._buildDefault(); + }, + frameBuilder: state.frameBuilder, + ); + } +} + +// TODO: end the abstraction of Art and ContentArt +// I started it, but it's consumed a lot of time and I left it unended +// +// What needs to be done is that there should be a clean widget Art, +// all specifics regarding source and how widget is exactly rendered should be either: +// * factored out to delegates (like this class) +// * or implemented as derived widgets from [Art], like [ContentArt] +// +/// Loading the art consists of three stages: +/// +/// 1. Load the source - this part is delegated to [_ArtSourceLoader]. +/// +/// During this stage, art displays either: +/// * current indicator, if it's current +/// * an empty box otherwise +/// +/// 2. Load the image from source. +/// +/// When the traversal happens from the first stage to this, the art is +/// revealed with animation. +/// +/// 3. Optionally, if [Art.onLoad] was provided, send the loaded art widget +/// to it. +/// +class _ContentArtState extends State { + late List<_ArtSourceLoader> _loaders; + GlobalKey? globalKey; + + bool get loaded => _loaders.isEmpty || _loaders.every((el) => el.loading != _SourceLoading.loading); + bool get showDefault => _loaders.isEmpty || _loaders.every((el) => el.showDefault); + + /// Min duration for [loadAnimationDuration]. + static Duration get _minDuration => _useScopedStorage + ? const Duration(milliseconds: 100) + : Duration.zero; + + Duration get loadAnimationDuration => widget.loadAnimationDuration < _minDuration + ? _minDuration + : widget.loadAnimationDuration; + + @override + void initState() { + super.initState(); + if (widget.onLoad != null) + globalKey = GlobalKey(); + _init(); + } + + void _init() { + _delivered = false; + final content = widget.source?._content; + if (content == null) { + _loaders = [_NoSourceLoader(this)]; + } else if (content is Song) { + _loaders = [_SongArtSourceLoader( + state: this, + song: content, + size: widget.size, + )]; + } else if (content is Album) { + _loaders = [_SongArtSourceLoader( + state: this, + song: content.firstSong, + size: widget.size, + )]; + } else if (content is Playlist) { + final songs = content.songs; + final size = _getSize(true); + switch (songs.length) { + case 0: + final loader = _SongArtSourceLoader( + state: this, + song: null, + size: size, + ); + _loaders = List.generate(4, (index) => loader); + break; + case 1: + final loader = _SongArtSourceLoader( + state: this, + song: songs.first, + size: size, + ); + _loaders = List.generate(4, (index) => loader); + break; + case 2: + _loaders = List.generate(2, (index) => _SongArtSourceLoader( + state: this, + song: songs[index], + size: size, + )); + _loaders.addAll(_loaders.reversed.toList()); + break; + case 3: + _loaders = List.generate(3, (index) => _SongArtSourceLoader( + state: this, + song: songs[index], + size: size, + )); + _loaders.add(_loaders[0]); + break; + case 4: + default: + _loaders = List.generate(4, (index) => _SongArtSourceLoader( + state: this, + song: songs[index], + size: size, + )); + break; + } + } else if (content is Artist) { + _loaders = [_ArtistGeniusArtSourceLoader( + state: this, + artist: content, + size: widget.size, + )]; + } else { + throw UnimplementedError(); + } + for (final loader in _loaders) { + loader.load(); + } + } + + bool _delivered = false; + void _onSourceLoad(_SourceLoading loading) { + if (mounted) { + if (loading == _SourceLoading.notLoading) + _deliverLoad(); + setState(() { }); + } + } + + Widget frameBuilder(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { + if (frame == 0 && loaded) + _deliverLoad(); + return child; + } + + /// Finally reveals the art and delivers the [ContentArt.onLoad] event, if listener was + /// provided. + /// + /// For each widget that can be return from build method: + /// * if that widget is an image - the [frameBuilder] should be provided to it, so when it's ready, + /// this method would be called to reveal it + /// * otherwise it should be called manually (see _deliverLoad call in [build]) + /// + /// It's an error to call this method, when [loaded] is not true. + Future _deliverLoad() async { + assert(loaded); + if (!_delivered) { + _delivered = true; + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + if (mounted) { + setState(() { + /* rebuild to change animated switcher child */ + }); + } + }); + if (widget.onLoad != null) { + /// Schedule two build phazes, don't know exactly why, but one throws with + /// `debugNeedsPaint` assertion fail. + /// + /// And the third is for the called above `setState`, because calling it + /// will cause image to be rebuilt to trigger [AnimatiedSwitcher] animation. + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) async { + if (!mounted) + return; + final object = globalKey!.currentContext!.findRenderObject()!; + final RenderRepaintBoundary boundary; + if (object is RenderStack) { + boundary = (object.lastChild! as RenderAnimatedOpacity).child! as RenderRepaintBoundary; + } else if (object is RenderPositionedBox) { + boundary = object.child! as RenderRepaintBoundary; + } else { + throw StateError(''); + } + final image = await boundary.toImage(); + widget.onLoad!(image); + }); + }); + }); + } + } + } + + void _update() { + for (final loader in _loaders) + loader.cancel(); + _init(); + } + + double? _devicePixelRatio; + + @override + void didChangeDependencies() { + final newPixelRatio = MediaQuery.of(context).devicePixelRatio; + // Reload arts when on scoped storage, as they are using the pixel ratio size at the stage + // of loading. + if (_useScopedStorage && _devicePixelRatio != null && _devicePixelRatio != newPixelRatio) + _update(); + _devicePixelRatio = newPixelRatio; + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(covariant ContentArt oldWidget) { + final oldContent = oldWidget.source?._content; + final content = widget.source?._content; + if (oldContent != content || + oldContent is Album && content is Album && oldContent.firstSong != content.firstSong || + oldContent is Playlist && content is Playlist && !listEquals(oldContent.songIds, content.songIds)) + _update(); + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + for (final loader in _loaders) + loader.cancel(); + super.dispose(); + } + + /// Returns a size for image. + double? _getSize([bool forPlaylist = false]) { + return forPlaylist && widget.size != null + ? widget.size! / 2 + : widget.size; + } + + /// Returns a cache size for image. + int? _getCacheSize([bool forPlaylist = false, double? size]) { + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio.round(); + size ??= _getSize(forPlaylist); + return (size == null ? size : size * devicePixelRatio)?.round(); + } + + Widget _buildCurrentIndicator() { + return widget.currentIndicatorScale == null + ? const CurrentIndicator() + : Transform.scale( + scale: widget.currentIndicatorScale!, + child: const CurrentIndicator(), + ); + } + + Widget _buildDefault([bool forPlaylist = false, int? cacheSize]) { + final size = _getSize(forPlaylist); + cacheSize ??= _getCacheSize(forPlaylist, size); + final int? _cacheSize = ( + cacheSize == null + ? cacheSize + : cacheSize * widget.assetScale + )?.round(); + Widget child; + if (widget.defaultArtIcon != null && widget.source?._content is! Song?) { + // We should show the art now. + _deliverLoad(); + final theme = ThemeControl.theme; + child = Container( + alignment: Alignment.center, + color: theme.colorScheme.primary, + width: widget.size, + height: widget.size, + child: Icon( + widget.defaultArtIcon, + color: theme.colorScheme.onPrimary, + size: 32.0, + ), + ); + if (widget.defaultArtIconScale != 1.0) { + child = Transform.scale(scale: widget.defaultArtIconScale, child: child); + } + } else { + child = Image.asset( + widget.assetHighRes + ? Constants.Assets.ASSET_LOGO_MASK + : Constants.Assets.ASSET_LOGO_THUMB_INAPP, + width: size, + height: size, + cacheWidth: _cacheSize, + cacheHeight: _cacheSize, + color: widget.color != null + ? ContentArt.getColorToBlendInDefaultArt(widget.color!) + : ThemeControl.colorForBlend, + colorBlendMode: BlendMode.plus, + frameBuilder: frameBuilder, + fit: BoxFit.cover, + ); + if (widget.assetScale != 1.0) { + child = Transform.scale(scale: widget.assetScale, child: child); + if (forPlaylist) { + child = ClipRRect(child: child); + } + } + } + return child; + } + + @override + Widget build(BuildContext context) { + assert(_loaders.isEmpty || _loaders.length == 1 || _loaders.length == 4); + + Widget child; + Widget? currentIndicator; + if (!loaded) { + child = SizedBox( + width: widget.size, + height: widget.size, + ); + if (widget.current) { + currentIndicator = Container( + alignment: Alignment.center, + width: widget.size, + height: widget.size, + child: _buildCurrentIndicator(), + ); + } + } else if (showDefault) { + if (widget.current) { + child = Container( + alignment: Alignment.center, + color: ThemeControl.theme.colorScheme.primary, + width: widget.size, + height: widget.size, + ); + currentIndicator = _buildCurrentIndicator(); + // We should show the art now. + _deliverLoad(); + } else { + child = _buildDefault(); + } + } else { + Widget arts; + if (_loaders.length == 1) { + arts = _loaders.first.getImage(_getCacheSize()) ?? _buildDefault(); + } else { + Widget? defaultArt; + final cacheSize = _getCacheSize(true); + if (_loaders.any((el) => el.showDefault)) { + defaultArt = _buildDefault(true, cacheSize); + } + arts = Column( + children: [ + Row( + children: [ + // TODO: there's unwanted 1 pixel gap, remove the transform that is used as workaround when this is resolved https://github.com/flutter/flutter/issues/14288 + Transform.scale(scale: 1.01, child: _loaders[0].getImage(cacheSize) ?? defaultArt!), + Transform.scale(scale: 1.01, child: _loaders[1].getImage(cacheSize) ?? defaultArt!), + ], + ), + Row( + children: [ + Transform.scale(scale: 1.01, child: _loaders[2].getImage(cacheSize) ?? defaultArt!), + Transform.scale(scale: 1.01, child: _loaders[3].getImage(cacheSize) ?? defaultArt!), + ], + ), + ], + ); + } + if (widget.current) { + child = Stack( + children: [ + arts, + Container( + alignment: Alignment.center, + color: Colors.black.withOpacity(0.5), + width: widget.size, + height: widget.size, + ), + ], + ); + currentIndicator = _buildCurrentIndicator(); + } else { + child = arts; + } + } + + child = RepaintBoundary( + child: ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(widget.borderRadius), + ), + child: child, + ), + ); + + return SizedBox( + width: widget.size, + height: widget.size, + child: Stack( + children: [ + if (loadAnimationDuration == Duration.zero) + Center( + key: globalKey, + child: child, + ) + else + Center( + child: AnimatedSwitcher( + key: globalKey, + duration: loadAnimationDuration, + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _delivered ? child : Opacity(opacity: 0, child: child), + ), + ), + if (currentIndicator != null) + Center(child: currentIndicator), + ], + ), + ); + } +} + +/// Widget that shows rotating album art. +/// Used in bottom track panel and starts rotating when track starts playing. +class AlbumArtRotating extends StatefulWidget { + const AlbumArtRotating({ + Key? key, + required this.source, + required this.initRotating, + this.initRotation = 0.0, + }) : assert(initRotation >= 0 && initRotation <= 1.0), + super(key: key); + + final ContentArtSource source; + + /// Should widget start rotate on mount or not + final bool initRotating; + + /// From 0.0 to 1.0 + /// Will be set as animation controller initial value + final double initRotation; + + @override + AlbumArtRotatingState createState() => AlbumArtRotatingState(); +} + +class AlbumArtRotatingState extends State with SingleTickerProviderStateMixin { + late AnimationController controller; + + @override + void initState() { + super.initState(); + controller = AnimationController( + duration: const Duration(seconds: 15), + vsync: this, + ); + controller.value = widget.initRotation; + if (widget.initRotating) { + rotate(); + } + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + /// Starts rotating, for use with global keys + void rotate() { + controller.repeat(); + } + + /// Stops rotating, for use with global keys + void stopRotating() { + controller.stop(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + child: ContentArt( + source: widget.source, + size: kRotatingArtSize, + borderRadius: kRotatingArtSize, + ), + animation: controller, + builder: (context, child) => RotationTransition( + turns: controller, + child: child, + ), + ); + } +} diff --git a/lib/widgets/content_list_view.dart b/lib/widgets/content_list_view.dart deleted file mode 100644 index 0a8fab62b..000000000 --- a/lib/widgets/content_list_view.dart +++ /dev/null @@ -1,240 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'package:flutter/material.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:sweyer/sweyer.dart'; - -/// Signature used for [ContentListView.currentTest] and [ContentListView.selected]. -/// -/// The argument [index] is index of the item. -typedef _ItemTest = bool Function(int index); - -/// Signature used for [ContentListView.itemBuilder]. -/// -/// The [item] is the prebuilt item tile widget. -typedef _ItemBuilder = Widget Function(BuildContext context, int index, Widget item); - -/// Renders a list of content. -/// -/// Picks some value based on the provided `T` type of [Content]. -/// -/// Instead of `T`, you can explicitly specify [contentType]. -class ContentListView extends StatelessWidget { - /// Creates a content list with automatically applied draggable scrollbar. - const ContentListView({ - Key key, - this.contentType, - @required this.list, - this.itemBuilder, - this.controller, - this.selectionController, - this.leading, - this.currentTest, - this.selectedTest, - this.songTileVariant = SongTileVariant.albumArt, - this.songClickBehavior = SongClickBehavior.play, - this.onItemTap, - this.padding = EdgeInsets.zero, - this.physics = const AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()), - this.interactiveScrollbar = true, - this.alwaysShowScrollbar = false, - this.showScrollbarLabel = false, - }) : super(key: key); - - /// An explicit content type. - final Type contentType; - - /// Content list. - final List list; - - /// Builder that allows to wrap the prebuilt item tile tile. - /// For example can be used to add [Dismissible]. - final _ItemBuilder itemBuilder; - - /// Viewport scroll controller. - final ScrollController controller; - - /// If specified, list will be built as [SongTile.selectable], - /// otherwise [SongTile] is used (in case if content is [Song]). - final ContentSelectionController selectionController; - - /// A widget to build before all items. - final Widget leading; - - /// Returned value is passed to [SongTile.current] (in case if content is [Song]). - /// - /// The argument [index] is index of the song. - final _ItemTest currentTest; - - /// Returned values is passed to [SongTile.selected] (in case if content is [Song]). - /// - /// The argument [index] is index of the song. - final _ItemTest selectedTest; - - /// Passed to [SongTile.variant]. - final SongTileVariant songTileVariant; - - /// Passed to [SongTile.clickBehavior]. - final SongClickBehavior songClickBehavior; - - /// Callback to be called on item tap. - final VoidCallback onItemTap; - - /// The amount of space by which to inset the children. - final EdgeInsetsGeometry padding; - - /// How the scroll view should respond to user input. - /// - /// For example, determines how the scroll view continues to animate after the - /// user stops dragging the scroll view. - /// - /// See [ScrollView.physics]. - final ScrollPhysics physics; - - /// Whether the scrollbar is interactive. - final bool interactiveScrollbar; - - /// Whether to always show the scrollbar. - final bool alwaysShowScrollbar; - - /// Whether to draw a label when scrollbar is dragged. - final bool showScrollbarLabel; - - @override - Widget build(BuildContext context) { - final localController = controller ?? ScrollController(); - return AppScrollbar.forContent( - list: list, - controller: localController, - showLabel: showScrollbarLabel, - interactive: interactiveScrollbar, - isAlwaysShown: alwaysShowScrollbar, - child: CustomScrollView( - controller: localController, - physics: physics, - slivers: [ - SliverPadding( - padding: padding, - sliver: sliver( - contentType: contentType, - list: list, - itemBuilder: itemBuilder, - selectionController: selectionController, - leading: leading, - currentTest: currentTest, - selectedTest: selectedTest, - songTileVariant: songTileVariant, - songClickBehavior: songClickBehavior, - onItemTap: onItemTap, - ), - ), - ], - ), - ); - } - - /// Returns a sliver list of content. - /// - /// There will be no scrollbar, since scrollbar is applied to [ScrollView], - /// not to slivers. - /// - /// Padding is also removed, since it's possible to just wrap it with [SliverPadding]. - @factory - static MultiSliver sliver({ - Key key, - Type contentType, - @required List list, - _ItemBuilder itemBuilder, - ContentSelectionController selectionController, - Widget leading, - _ItemTest currentTest, - _ItemTest selectedTest, - SongTileVariant songTileVariant = SongTileVariant.albumArt, - SongClickBehavior songClickBehavior = SongClickBehavior.play, - VoidCallback onItemTap, - }) { - final selectable = selectionController != null; - return MultiSliver( - children: [ - if (leading != null) - leading, - contentPick( - contentType: contentType, - song: () => SliverFixedExtentList( - itemExtent: kSongTileHeight, - delegate: SliverChildBuilderDelegate( - (context, index) { - final item = list[index] as Song; - final localSelected = selectedTest != null - ? selectedTest(index) - : selectionController.data.contains(SelectionEntry( - data: item, - index: index, - )); - Widget child; - if (selectable) { - child = SongTile.selectable( - index: index, - song: item, - selectionController: selectionController, - clickBehavior: songClickBehavior, - variant: songTileVariant, - current: currentTest?.call(index), - selected: localSelected, - onTap: onItemTap, - ); - } else { - child = SongTile( - song: item, - current: currentTest?.call(index), - clickBehavior: songClickBehavior, - variant: songTileVariant, - onTap: onItemTap, - ); - } - return itemBuilder?.call(context, index, child) ?? child; - }, - childCount: list.length, - ), - ), - album: () => SliverFixedExtentList( - itemExtent: kAlbumTileHeight, - delegate: SliverChildBuilderDelegate( - (context, index) { - final item = list[index] as Album; - final localSelected = selectedTest != null - ? selectedTest(index) - : selectionController.data.contains(SelectionEntry( - data: item, - index: index, - )); - Widget child; - if (selectable) { - child = AlbumTile.selectable( - index: index, - album: item, - current: currentTest?.call(index), - onTap: onItemTap, - selected: localSelected, - selectionController: selectionController, - ); - } else { - child = AlbumTile( - album: item, - onTap: onItemTap, - current: currentTest?.call(index), - ); - } - return itemBuilder?.call(context, index, child) ?? child; - }, - childCount: list.length, - ), - ), - )(), - ], - ); - } -} diff --git a/lib/widgets/content_list_view/add_to_selection_button.dart b/lib/widgets/content_list_view/add_to_selection_button.dart new file mode 100644 index 000000000..f138c3141 --- /dev/null +++ b/lib/widgets/content_list_view/add_to_selection_button.dart @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:flutter/material.dart'; +import 'package:sweyer/sweyer.dart'; + +/// Creates an icon button that adds a content entry to selection. +class AddToSelectionButton extends StatelessWidget { + const AddToSelectionButton({ + Key? key, + required this.onPressed, + }) : super(key: key); + + final VoidCallback onPressed; + + static const size = kSongTileArtSize; + /// The padding that is preferred between this action and other UI elements. + static const preferredPadding = 4.0; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: NFIconButton( + icon: const Icon(Icons.add_rounded), + size: size, + onPressed: onPressed, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/content_list_view/artist_tile.dart b/lib/widgets/content_list_view/artist_tile.dart new file mode 100644 index 000000000..e73d30b91 --- /dev/null +++ b/lib/widgets/content_list_view/artist_tile.dart @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + + +import 'package:flutter/material.dart'; + +import 'package:sweyer/sweyer.dart'; + +/// Needed for scrollbar computations. +const double kArtistTileHeight = kArtistTileArtSize + _tileVerticalPadding * 2; +const double _tileVerticalPadding = 8.0; +const double _horizontalPadding = 16.0; + +class ArtistTile extends SelectableWidget { + const ArtistTile({ + Key? key, + required this.artist, + this.trailing, + this.current, + this.onTap, + this.enableDefaultOnTap = true, + double? horizontalPadding, + this.backgroundColor = Colors.transparent, + }) : horizontalPadding = horizontalPadding ?? _horizontalPadding, + super(key: key); + + const ArtistTile.selectable({ + Key? key, + required this.artist, + required int selectionIndex, + required SelectionController? selectionController, + bool selected = false, + bool longPressSelectionGestureEnabled = true, + bool handleTapInSelection = true, + this.trailing, + this.current, + this.onTap, + this.enableDefaultOnTap = true, + double? horizontalPadding, + this.backgroundColor = Colors.transparent, + }) : assert(selectionController is SelectionController> || + selectionController is SelectionController>), + horizontalPadding = horizontalPadding ?? _horizontalPadding, + super.selectable( + key: key, + selectionIndex: selectionIndex, + selected: selected, + longPressSelectionGestureEnabled: longPressSelectionGestureEnabled, + handleTapInSelection: handleTapInSelection, + selectionController: selectionController, + ); + + final Artist artist; + + /// Widget to be rendered at the end of the tile. + final Widget? trailing; + + /// Whether this queue is currently playing, if yes, enables animated + /// [CurrentIndicator] over the ablum art. + /// + /// If not specified, by default uses [ContentUtils.originIsCurrent]. + final bool? current; + final VoidCallback? onTap; + + /// Whether to handle taps by default. + /// By default plays song on tap. + final bool enableDefaultOnTap; + + final double horizontalPadding; + + /// Background tile color. + /// By default tile background is transparent. + final Color backgroundColor; + + @override + _ArtistTileState createState() => _ArtistTileState(); +} + +class _ArtistTileState extends SelectableState, ArtistTile> with ContentTileComponentsMixin { + @override + SelectionEntry toSelectionEntry() => SelectionEntry.fromContent( + content: widget.artist, + index: widget.selectionIndex!, + context: context, + ); + + void _handleTap() { + super.handleTap(() { + widget.onTap?.call(); + HomeRouter.of(context).goto(HomeRoutes.factory.content(widget.artist)); + }); + } + + bool get current { + if (widget.current != null) + return widget.current!; + return ContentUtils.originIsCurrent(widget.artist); + } + + Widget _buildTile() { + final source = ContentArtSource.artist(widget.artist); + final l10n = getl10n(context); + return Material( + color: widget.backgroundColor, + child: InkWell( + onTap: widget.enableDefaultOnTap || selectable && widget.selectionController!.inSelection + ? _handleTap + : widget.onTap, + onLongPress: handleLongPress, + splashFactory: NFListTileInkRipple.splashFactory, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: widget.horizontalPadding, + vertical: _tileVerticalPadding, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ContentArt.artistTile( + source: source, + defaultArtIcon: Artist.icon, + current: current, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + ContentUtils.localizedArtist(widget.artist.artist, l10n), + overflow: TextOverflow.ellipsis, + style: ThemeControl.theme.textTheme.headline6, + ), + ], + ), + ), + ), + if (selectionRoute) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.trailing != null) + widget.trailing!, + buildAddToSelection(), + ], + ) + else if (widget.trailing != null) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: widget.trailing, + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!selectable) + return _buildTile(); + return Stack( + children: [ + _buildTile(), + if (!selectionRoute && animation.status != AnimationStatus.dismissed) + Positioned( + left: kArtistTileArtSize - 4.0, + bottom: 6.0, + child: SelectionCheckmark(animation: animation), + ), + ], + ); + } +} + diff --git a/lib/widgets/content_list_view/content_list_view.dart b/lib/widgets/content_list_view/content_list_view.dart new file mode 100644 index 000000000..ec612650a --- /dev/null +++ b/lib/widgets/content_list_view/content_list_view.dart @@ -0,0 +1,347 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +export 'add_to_selection_button.dart'; +export 'artist_tile.dart'; +export 'content_tile.dart'; +export 'in_list_action.dart'; +export 'list_header.dart'; +export 'persistent_queue_tile.dart'; +export 'song_tile.dart'; + +import 'package:flutter/material.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:sweyer/sweyer.dart'; + +/// Signature used for [ContentListView.currentTest], [ContentListView.selected] and +/// other similar callbacks. +/// +/// The argument [index] is index of the item. +typedef _ItemTest = bool Function(int index); + +/// Signature used for [ContentListView.backgroundColorBuilder]. +/// +/// The argument [index] is index of the item. +typedef _ColorBuilder = Color Function(int index); + +/// Signature used for [ContentListView.itemBuilder]. +/// +/// The [item] is the prebuilt item tile widget. +typedef _ItemBuilder = Widget Function(BuildContext context, int index, Widget item); + +/// Renders a list of content. +/// +/// Picks some value based on the provided `T` type of [Content]. +/// +/// Instead of `T`, you can explicitly specify [contentType]. +class ContentListView extends StatelessWidget { + /// Creates a content list with automatically applied draggable scrollbar. + const ContentListView({ + Key? key, + this.contentType, + required this.list, + this.itemBuilder, + this.itemTrailingBuilder, + this.controller, + this.selectionController, + this.leading, + this.currentTest, + this.selectedTest, + this.longPressSelectionGestureEnabledTest, + this.handleTapInSelectionTest, + this.onItemTap, + this.backgroundColorBuilder, + this.enableDefaultOnTap = true, + this.songTileVariant = kSongTileVariant, + this.songTileClickBehavior = kSongTileClickBehavior, + this.padding = EdgeInsets.zero, + this.physics = const AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()), + this.interactiveScrollbar = true, + this.alwaysShowScrollbar = false, + this.showScrollbarLabel = false, + }) : super(key: key); + + /// An explicit content type. + final Type? contentType; + + /// Content list. + final List list; + + /// Builder that allows to wrap the prebuilt item tile tile. + /// For example can be used to add [Dismissible]. + final _ItemBuilder? itemBuilder; + + /// Builds an item trailing. + final IndexedWidgetBuilder? itemTrailingBuilder; + + /// Viewport scroll controller. + final ScrollController? controller; + + /// If specified, list will be built as [ContentTile.selectable], + /// otherwise [ContentTile] is used. + final ContentSelectionController? selectionController; + + /// A widget to build before all items. + final Widget? leading; + + /// Returned value is passed to [ContentTile.current]. + final _ItemTest? currentTest; + + /// Returned value is passed to [ContentTile.selected]. + final _ItemTest? selectedTest; + + /// Returned value is passed to [ContentTile.longPressSelectionGestureEnabled]. + final _ItemTest? longPressSelectionGestureEnabledTest; + + /// Returned value is passed to [ContentTile.handleTapInSelection]. + final _ItemTest? handleTapInSelectionTest; + + /// Callback to be called on item tap. + final ValueSetter? onItemTap; + + /// Builds a background color for an item. + final _ColorBuilder? backgroundColorBuilder; + + /// Passed to [Song.enableDefaultOnTap]. + final bool enableDefaultOnTap; + + /// Passed to [SongTile.variant]. + final SongTileVariant songTileVariant; + + /// Passed to [SongTile.clickBehavior]. + final SongTileClickBehavior songTileClickBehavior; + + /// The amount of space by which to inset the children. + final EdgeInsetsGeometry padding; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// See [ScrollView.physics]. + final ScrollPhysics physics; + + /// Whether the scrollbar is interactive. + final bool interactiveScrollbar; + + /// Whether to always show the scrollbar. + final bool alwaysShowScrollbar; + + /// Whether to draw a label when scrollbar is dragged. + final bool showScrollbarLabel; + + @override + Widget build(BuildContext context) { + final localController = controller ?? ScrollController(); + return AppScrollbar.forContent( + list: list, + controller: localController, + showLabel: showScrollbarLabel, + interactive: interactiveScrollbar, + isAlwaysShown: alwaysShowScrollbar, + child: CustomScrollView( + controller: localController, + physics: physics, + slivers: [ + SliverPadding( + padding: padding, + sliver: sliver( + contentType: contentType, + list: list, + itemBuilder: itemBuilder, + itemTrailingBuilder: itemTrailingBuilder, + selectionController: selectionController, + leading: leading, + currentTest: currentTest, + selectedTest: selectedTest, + longPressSelectionGestureEnabledTest: longPressSelectionGestureEnabledTest, + handleTapInSelectionTest: handleTapInSelectionTest, + songTileVariant: songTileVariant, + songTileClickBehavior: songTileClickBehavior, + onItemTap: onItemTap, + backgroundColorBuilder: backgroundColorBuilder, + enableDefaultOnTap: enableDefaultOnTap, + ), + ), + ], + ), + ); + } + + /// Returns a sliver list of content. + /// + /// There will be no scrollbar, since scrollbar is applied to [ScrollView], + /// not to slivers. + /// + /// Padding is also removed, since it's possible to just wrap it with [SliverPadding]. + /// + /// See also: + /// * [reorderableSliver] which creates a reorderable sliver + @factory + static MultiSliver sliver({ + Key? key, + Type? contentType, + required List list, + _ItemBuilder? itemBuilder, + IndexedWidgetBuilder? itemTrailingBuilder, + ContentSelectionController? selectionController, + Widget? leading, + _ItemTest? currentTest, + _ItemTest? selectedTest, + _ItemTest? longPressSelectionGestureEnabledTest, + _ItemTest? handleTapInSelectionTest, + SongTileVariant songTileVariant = kSongTileVariant, + SongTileClickBehavior songTileClickBehavior = kSongTileClickBehavior, + ValueSetter? onItemTap, + _ColorBuilder? backgroundColorBuilder, + bool enableDefaultOnTap = true, + }) { + return MultiSliver( + children: [ + if (leading != null) + leading, + SliverFixedExtentList( + itemExtent: ContentTile.getHeight(contentType), + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = list[index]; + final child = ContentTile( + contentType: contentType, + content: item, + selectionIndex: index, + selected: selectedTest != null + ? selectedTest(index) + : selectionController?.data.contains(SelectionEntry.fromContent( + content: item, + index: index, + context: context, + )) + ?? false, + longPressSelectionGestureEnabled: longPressSelectionGestureEnabledTest?.call(index) ?? true, + handleTapInSelection: handleTapInSelectionTest?.call(index) ?? true, + selectionController: selectionController, + trailing: itemTrailingBuilder?.call(context, index), + current: currentTest?.call(index), + onTap: onItemTap == null ? null : () => onItemTap(index), + backgroundColor: backgroundColorBuilder == null ? Colors.transparent : backgroundColorBuilder(index), + enableDefaultOnTap: enableDefaultOnTap, + songTileVariant: songTileVariant, + songTileClickBehavior: songTileClickBehavior, + ); + return itemBuilder?.call(context, index, child) ?? child; + }, + childCount: list.length, + ), + ), + ], + ); + } + + /// Returns a sliver reorderable list of content. + /// + /// There will be no scrollbar, since scrollbar is applied to [ScrollView], + /// not to slivers. + /// + /// Padding is also removed, since it's possible to just wrap it with [SliverPadding]. + /// + /// See also: + /// * [sliver] which creates a not reorderable sliver + @factory + static MultiSliver reorderableSliver({ + Key? key, + Type? contentType, + required List list, + required ReorderCallback onReorder, + bool reorderingEnabled = true, + _ItemBuilder? itemBuilder, + IndexedWidgetBuilder? itemTrailingBuilder, + ContentSelectionController? selectionController, + Widget? leading, + _ItemTest? currentTest, + _ItemTest? selectedTest, + _ItemTest? longPressSelectionGestureEnabledTest, + _ItemTest? handleTapInSelectionTest, + SongTileVariant songTileVariant = kSongTileVariant, + SongTileClickBehavior songTileClickBehavior = kSongTileClickBehavior, + ValueSetter? onItemTap, + _ColorBuilder? backgroundColorBuilder, + bool enableDefaultOnTap = true, + }) { + return MultiSliver( + children: [ + if (leading != null) + leading, + SliverReorderableList( + // TODO: itemExtent is broken https://github.com/flutter/flutter/issues/84901 + // itemExtent: ContentTile.getHeight(contentType), + itemCount: list.length, + onReorder: onReorder, + itemBuilder: (context, index) { + final item = list[index]; + final child = ReorderableDelayedDragStartListener( + key: ValueKey(item.id), + enabled: reorderingEnabled, + index: index, + child: ContentTile( + contentType: contentType, + content: item, + selectionIndex: index, + selected: selectedTest != null + ? selectedTest(index) + : selectionController?.data.contains(SelectionEntry.fromContent( + content: item, + index: index, + context: context, + )) + ?? false, + longPressSelectionGestureEnabled: longPressSelectionGestureEnabledTest?.call(index) + ?? !reorderingEnabled, + handleTapInSelection: handleTapInSelectionTest?.call(index) + ?? !reorderingEnabled, + selectionController: selectionController, + current: currentTest?.call(index), + onTap: onItemTap == null ? null : () => onItemTap(index), + enableDefaultOnTap: enableDefaultOnTap, + backgroundColor: backgroundColorBuilder == null + ? ThemeControl.theme.colorScheme.background + : backgroundColorBuilder(index), + songTileVariant: songTileVariant, + songTileClickBehavior: songTileClickBehavior, + trailing: AnimatedSwitcher( + duration: const Duration(milliseconds: 240), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (Widget child, Animation animation) { + return EmergeAnimation( + animation: animation, + child: FadeTransition( + opacity: CurveTween( + curve: const Interval(0.4, 1.0), + ).animate(animation), + child: child, + ), + ); + }, + child: !reorderingEnabled + ? itemTrailingBuilder?.call(context, index) ?? const SizedBox.shrink() + : ReorderableDragStartListener( + enabled: reorderingEnabled, + index: index, + child: const Icon( + Icons.drag_handle, + size: 30.0, + ), + ), + ), + ), + ); + return itemBuilder?.call(context, index, child) ?? child; + }, + ), + ], + ); + } +} diff --git a/lib/widgets/content_list_view/content_tile.dart b/lib/widgets/content_list_view/content_tile.dart new file mode 100644 index 000000000..6fe9e931e --- /dev/null +++ b/lib/widgets/content_list_view/content_tile.dart @@ -0,0 +1,208 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:flutter/material.dart'; +import 'package:sweyer/logic/logic.dart'; +import 'package:sweyer/sweyer.dart'; + +/// Generalizes all content tiles into one widget and exposes +/// common propertlies of all tiles. +class ContentTile extends StatelessWidget { + const ContentTile({ + Key? key, + this.contentType, + required this.content, + this.trailing, + this.current, + this.onTap, + this.enableDefaultOnTap = true, + this.horizontalPadding, + this.backgroundColor = Colors.transparent, + this.songTileVariant = kSongTileVariant, + this.songTileClickBehavior = kSongTileClickBehavior, + this.selectionIndex, + this.selected = false, + this.longPressSelectionGestureEnabled = true, + this.handleTapInSelection = true, + this.selectionController, + }) : super(key: key); + + final Type? contentType; + final T content; + final Widget? trailing; + final bool? current; + final VoidCallback? onTap; + final bool enableDefaultOnTap; + final double? horizontalPadding; + final Color backgroundColor; + final SongTileVariant songTileVariant; + final SongTileClickBehavior songTileClickBehavior; + + final int? selectionIndex; + final bool selected; + final bool longPressSelectionGestureEnabled; + final bool handleTapInSelection; + final ContentSelectionController? selectionController; + + static double getHeight(Type? contentType) { + return contentPick( + contentType: contentType, + song: kSongTileHeight, + album: kPersistentQueueTileHeight, + playlist: kPersistentQueueTileHeight, + artist: kArtistTileHeight, + ); + } + + bool get selectable => selectionController != null; + + ValueGetter forPersistentQueue() { + return () => !selectable + ? PersistentQueueTile( + queue: content as Q, + onTap: onTap, + enableDefaultOnTap: enableDefaultOnTap, + trailing: trailing, + current: current, + horizontalPadding: horizontalPadding, + backgroundColor: backgroundColor, + ) + : PersistentQueueTile.selectable( + queue: content as Q, + selectionIndex: selectionIndex!, + selectionController: selectionController!, + selected: selected, + longPressSelectionGestureEnabled: longPressSelectionGestureEnabled, + handleTapInSelection: handleTapInSelection, + trailing: trailing, + current: current, + onTap: onTap, + enableDefaultOnTap: enableDefaultOnTap, + horizontalPadding: horizontalPadding, + backgroundColor: backgroundColor, + ); + } + + @override + Widget build(BuildContext context) { + assert( + !selectable || selectable && selectionIndex != null, + 'If tile is selectable, an `index` must be provided' + ); + + return contentPick>( + contentType: contentType, + song: () => !selectable + ? SongTile( + song: content as Song, + trailing: trailing, + current: current, + onTap: onTap, + enableDefaultOnTap: enableDefaultOnTap, + horizontalPadding: horizontalPadding ?? kSongTileHorizontalPadding, + backgroundColor: backgroundColor, + variant: songTileVariant, + clickBehavior: songTileClickBehavior, + ) + : SongTile.selectable( + song: content as Song, + selectionIndex: selectionIndex!, + selectionController: selectionController, + selected: selected, + longPressSelectionGestureEnabled: longPressSelectionGestureEnabled, + handleTapInSelection: handleTapInSelection, + trailing: trailing, + current: current, + onTap: onTap, + enableDefaultOnTap: enableDefaultOnTap, + horizontalPadding: horizontalPadding ?? kSongTileHorizontalPadding, + backgroundColor: backgroundColor, + variant: songTileVariant, + clickBehavior: songTileClickBehavior, + ), + album: forPersistentQueue(), + playlist: forPersistentQueue(), + artist: () => !selectable + ? ArtistTile( + artist: content as Artist, + trailing: trailing, + current: current, + onTap: onTap, + enableDefaultOnTap: enableDefaultOnTap, + horizontalPadding: horizontalPadding, + backgroundColor: backgroundColor, + ) + : ArtistTile.selectable( + artist: content as Artist, + selectionIndex: selectionIndex!, + selectionController: selectionController, + selected: selected, + longPressSelectionGestureEnabled: longPressSelectionGestureEnabled, + handleTapInSelection: handleTapInSelection, + trailing: trailing, + current: current, + onTap: onTap, + enableDefaultOnTap: enableDefaultOnTap, + horizontalPadding: horizontalPadding, + backgroundColor: backgroundColor, + ), + )(); + } +} + +/// Common parts of the UI in content tile implementations. +/// +/// TODO: comments +mixin ContentTileComponentsMixin on SelectableState { + final checmarkLargeSize = 28.0; + + Widget buildSelectionCheckmark({bool forceLarge = false, bool forSelectionRoute = false}) { + if (animation.status == AnimationStatus.dismissed) + return const SizedBox.shrink(); + return SelectionCheckmark( + ignorePointer: !forSelectionRoute, + scaleAnimation: !forSelectionRoute, + size: forceLarge || forSelectionRoute ? checmarkLargeSize : 21.0, + animation: animation, + ); + } + + Widget buildAddToSelection() { + Widget builder(Widget child, Animation animation) { + return ScaleTransition( + scale: animation, + child: child, + ); + } + return Padding( + padding: const EdgeInsets.only(left: 4.0, right: 8.0), + child: GestureDetector( + onTap: () { + toggleSelection(); + }, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: AddToSelectionButton.size, + width: AddToSelectionButton.size, + child: AnimationSwitcher( + animation: animation, + builder1: builder, + builder2: builder, + child1: Material( + color: Colors.transparent, + child: AddToSelectionButton( + onPressed: () { + toggleSelection(); + }, + ), + ), + child2: buildSelectionCheckmark(forSelectionRoute: true), + alignment: Alignment.center, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/content_list_view/in_list_action.dart b/lib/widgets/content_list_view/in_list_action.dart new file mode 100644 index 000000000..11e97b975 --- /dev/null +++ b/lib/widgets/content_list_view/in_list_action.dart @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:flutter/material.dart'; +import 'package:sweyer/sweyer.dart'; +import 'package:sweyer/constants.dart' as Constants; + +/// Action to be displayed directly in the content list. +class InListContentAction extends StatefulWidget { + /// Creats action with paddings for song list. + const InListContentAction.song({ + Key? key, + required this.icon, + required this.text, + required this.onTap, + this.color, + this.iconColor, + this.textColor, + this.splashColor, + }) : horizontalPadding = kSongTileHorizontalPadding, + super(key: key); + + /// Creats action with paddings for persistent queue list. + const InListContentAction.persistentQueue({ + Key? key, + required this.icon, + required this.text, + required this.onTap, + this.color, + this.iconColor, + this.textColor, + this.splashColor, + }) : horizontalPadding = kPersistentQueueTileHorizontalPadding, + super(key: key); + + final IconData icon; + final String text; + final VoidCallback? onTap; + final Color? color; + final Color? textColor; + final Color? iconColor; + final Color? splashColor; + final double horizontalPadding; + + @override + State createState() => _InListContentActionState(); +} + +class _InListContentActionState extends State with SingleTickerProviderStateMixin { + late final controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + ); + late final fadeAnimation = Tween( + begin: 0.2, + end: 1.0, + ).animate(CurvedAnimation( + parent: controller, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + )); + + Color? previousColor; + + bool get enabled => widget.onTap != null; + + @override + void initState() { + super.initState(); + if (enabled) { + controller.forward(); + } + } + + @override + void didUpdateWidget(covariant InListContentAction oldWidget) { + previousColor = oldWidget.color; + if (oldWidget.onTap != widget.onTap) { + if (enabled) { + controller.forward(); + } else { + controller.reverse(); + } + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: fadeAnimation, + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + tween: ColorTween(begin: previousColor, end: widget.color ?? Colors.transparent), + builder: (context, value, child) => Material( + color: value, + child: child, + ), + child: NFInkWell( + splashColor: widget.splashColor, + onTap: widget.onTap, + child: Container( + padding: EdgeInsets.symmetric(horizontal: widget.horizontalPadding), + height: kSongTileHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + height: kSongTileArtSize, + width: kSongTileArtSize, + decoration: BoxDecoration( + color: Constants.Theme.glowSplashColor.auto, + borderRadius: const BorderRadius.all(Radius.circular(kArtBorderRadius)), + ), + alignment: Alignment.center, + child: Icon( + widget.icon, + size: 36.0, + color: widget.iconColor, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + widget.text, + overflow: TextOverflow.ellipsis, + style: ThemeControl.theme.textTheme.headline6?.copyWith(color: widget.textColor), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class CreatePlaylistInListAction extends StatefulWidget { + const CreatePlaylistInListAction({Key? key}) : super(key: key); + + @override + State createState() => _CreatePlaylistInListActionState(); +} + +class _CreatePlaylistInListActionState extends State with TickerProviderStateMixin { + void _handleTap() { + ShowFunctions.instance.showCreatePlaylist(this, context); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + return InListContentAction.persistentQueue( + onTap: _handleTap, + icon: Icons.add_rounded, + text: l10n.newPlaylist, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/list_header.dart b/lib/widgets/content_list_view/list_header.dart similarity index 58% rename from lib/widgets/list_header.dart rename to lib/widgets/content_list_view/list_header.dart index eabac39bb..591871d91 100644 --- a/lib/widgets/list_header.dart +++ b/lib/widgets/content_list_view/list_header.dart @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; class ListHeader extends StatelessWidget { const ListHeader({ - Key key, + Key? key, this.leading, this.trailing, this.color, @@ -22,9 +22,9 @@ class ListHeader extends StatelessWidget { ), }) : super(key: key); - final Widget leading; - final Widget trailing; - final Color color; + final Widget? leading; + final Widget? trailing; + final Color? color; final EdgeInsetsGeometry margin; @override @@ -39,11 +39,9 @@ class ListHeader extends StatelessWidget { color: color, padding: margin, child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.max, - children: [ - if (leading != null) leading, - if (trailing != null) trailing, + children: [ + if (leading != null) Expanded(child: leading!), + if (trailing != null) trailing!, ], ), ), @@ -53,40 +51,48 @@ class ListHeader extends StatelessWidget { /// Displays content controls to sort content and content [count] at the trailing. class ContentListHeader extends StatelessWidget { + /// Creats a default header with sort controls, count and slots widgets + /// for other widgets. const ContentListHeader({ - Key key, - @required this.count, - @required this.selectionController, + Key? key, + this.contentType, + required this.count, + this.selectionController, this.leading, this.trailing, - }) : assert(count != null), - super(key: key); + }) : _onlyCount = false, super(key: key); + + /// Creates a header that shows only count. + const ContentListHeader.onlyCount({ + Key? key, + this.contentType, + required this.count, + }) : _onlyCount = true, + selectionController = null, + leading = null, + trailing = null, + super(key: key); + + final Type? contentType; + + final bool _onlyCount; /// Content count, will be displayed at the trailing. final int count; /// This needed to ignore the header sort buttons when the controller is in selection. /// This parameter can be `null`. - final ContentSelectionController selectionController; + final ContentSelectionController? selectionController; /// Additional widget to place after sorting controls. - final Widget leading; + final Widget? leading; /// Additional widget to place before [count]. - final Widget trailing; + final Widget? trailing; - Sort getSort() => ContentControl.state.sorts.getValue(); + Sort getSort() => ContentControl.state.sorts.getValue(contentType) as Sort; - String getContentCountText(AppLocalizations l10n) { - final plural = contentPick( - song: l10n.tracksPlural, - album: l10n.albumsPlural, - )(count).toLowerCase(); - return '$count $plural'; - } - - void _handleTap() { - final context = HomeRouter.instance.navigatorKey.currentContext; + void _handleTap(BuildContext context) { final l10n = getl10n(context); final sort = getSort(); Widget buildItem(SortFeature feature) { @@ -97,13 +103,14 @@ class ContentListHeader extends StatelessWidget { child: Builder( // i need the proper context to pop the dialog builder: (context) => _RadioListTile( title: Text( - l10n.sortFeature(feature).toLowerCase(), + l10n.sortFeature(feature as SortFeature, contentType).toLowerCase(), style: ThemeControl.theme.textTheme.subtitle1, ), value: feature, groupValue: sort.feature, onChanged: (_) { ContentControl.sort( + contentType: contentType, sort: sort.copyWith(feature: feature).withDefaultOrder, ); Navigator.pop(context); @@ -121,10 +128,23 @@ class ContentListHeader extends StatelessWidget { contentPadding: const EdgeInsets.only(top: 5.0, bottom: 10.0), acceptButton: const SizedBox.shrink(), content: Column( - children: contentPick Function()>( - song: () => SongSortFeature.values, - album: () => AlbumSortFeature.values, - )().map((el) => buildItem(el)).toList(), + mainAxisSize: MainAxisSize.min, + children: SortFeature + .getValuesForContent(contentType) + .map((el) => buildItem(el)) + .toList(), + ), + ); + } + + Widget _buildCount(AppLocalizations l10n, TextStyle textStyle) { + return Padding( + padding: const EdgeInsets.only(left: 8.0, right: 10.0), + child: Text( + l10n.contentsPluralWithCount(count, contentType), + softWrap: false, + overflow: TextOverflow.fade, + style: textStyle, ), ); } @@ -145,24 +165,25 @@ class ContentListHeader extends StatelessWidget { left: 10.0, right: 7.0, ), - trailing: Row( - children: [ - if (trailing != null) - trailing, - Padding( - padding: const EdgeInsets.only(right: 10.0), - child: Text( - getContentCountText(l10n), - style: textStyle, + trailing: _onlyCount + ? null + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (trailing != null) + trailing!, + Flexible( + child: _buildCount(l10n, textStyle), ), - ), - ], - ), - leading: Theme( + ], + ), + leading: _onlyCount ? _buildCount(l10n, textStyle) : Theme( data: Theme.of(context).copyWith( splashFactory: NFListTileInkRipple.splashFactory, ), child: Row( + mainAxisAlignment: MainAxisAlignment.start, children: [ ContentListHeaderAction( icon: Icon(sort.orderAscending @@ -170,25 +191,30 @@ class ContentListHeader extends StatelessWidget { : Icons.south_rounded), onPressed: () { ContentControl.sort( + contentType: contentType, sort: sort.copyWith(orderAscending: !sort.orderAscending), ); }, ), - InkResponse( - onTap: _handleTap, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4.0, - vertical: 2.0, - ), - child: Text( - l10n.sortFeature(sort.feature), - style: textStyle, + Flexible( + child: InkResponse( + onTap: () => _handleTap(context), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 2.0, + ), + child: Text( + l10n.sortFeature(sort.feature, contentType), + softWrap: false, + overflow: TextOverflow.fade, + style: textStyle, + ), ), ), ), if (leading != null) - leading + leading! ], ), ), @@ -196,7 +222,7 @@ class ContentListHeader extends StatelessWidget { if (selectionController == null) return child; return IgnoreInSelection( - controller: selectionController, + controller: selectionController!, child: child, ); } @@ -204,16 +230,18 @@ class ContentListHeader extends StatelessWidget { class _RadioListTile extends StatelessWidget { const _RadioListTile({ - Key key, - @required this.value, - @required this.groupValue, - @required this.onChanged, + Key? key, + required this.value, + required this.groupValue, + required this.onChanged, this.title, }) : super(key: key); + final T value; final T groupValue; final ValueChanged onChanged; - final Widget title; + final Widget? title; + @override Widget build(BuildContext context) { return InkWell( @@ -231,13 +259,18 @@ class _RadioListTile extends StatelessWidget { value: value, splashRadius: 0.0, groupValue: groupValue, - onChanged: onChanged, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (value) { + if (value != null) { + onChanged(value); + } + }, ), - Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: title, - ), + if (title != null) + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: title, + ), ], ), ), @@ -248,19 +281,43 @@ class _RadioListTile extends StatelessWidget { /// A small button to be placed into [ContentListSortHeader]. class ContentListHeaderAction extends StatelessWidget { const ContentListHeaderAction({ - Key key, - this.icon, + Key? key, + required this.icon, this.onPressed, }) : super(key: key); final Widget icon; - final VoidCallback onPressed; + final VoidCallback? onPressed; + + static const size = 28.0; @override Widget build(BuildContext context) { return NFIconButton( icon: icon, - size: 28.0, + size: size, + iconSize: 20.0, + onPressed: onPressed, + ); + } +} + +/// A small button to be placed into [ContentListSortHeader] with animation. +class AnimatedContentListHeaderAction extends StatelessWidget { + const AnimatedContentListHeaderAction({ + Key? key, + required this.icon, + this.onPressed, + }) : super(key: key); + + final Widget icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return AnimatedIconButton( + icon: icon, + size: ContentListHeaderAction.size, iconSize: 20.0, onPressed: onPressed, ); diff --git a/lib/widgets/content_list_view/persistent_queue_tile.dart b/lib/widgets/content_list_view/persistent_queue_tile.dart new file mode 100644 index 000000000..7bd0a46bb --- /dev/null +++ b/lib/widgets/content_list_view/persistent_queue_tile.dart @@ -0,0 +1,371 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:boxy/boxy.dart'; +import 'package:flutter/material.dart'; + +import 'package:sweyer/sweyer.dart'; +import 'package:sweyer/constants.dart' as Constants; + +/// Needed for scrollbar computations. +const double kPersistentQueueTileHeight = kPersistentQueueTileArtSize + _tileVerticalPadding * 2; +const double _tileVerticalPadding = 8.0; +const double kPersistentQueueTileHorizontalPadding = 16.0; +const double _gridArtSize = 220.0; +const double _gridArtAssetScale = 1.2; +const double _gridCurrentIndicatorScale = 1.7; + +class PersistentQueueTile extends SelectableWidget { + const PersistentQueueTile({ + Key? key, + required this.queue, + this.trailing, + this.current, + this.onTap, + this.enableDefaultOnTap = true, + this.small = false, + this.grid = false, + this.gridArtSize = _gridArtSize, + this.gridArtAssetScale = _gridArtAssetScale, + this.gridCurrentIndicatorScale = _gridCurrentIndicatorScale, + double? horizontalPadding, + this.backgroundColor = Colors.transparent, + }) : assert(!grid || !small), + horizontalPadding = horizontalPadding ?? (small ? kSongTileHorizontalPadding : kPersistentQueueTileHorizontalPadding), + super(key: key); + + const PersistentQueueTile.selectable({ + Key? key, + required this.queue, + required int selectionIndex, + required SelectionController? selectionController, + bool selected = false, + bool longPressSelectionGestureEnabled = true, + bool handleTapInSelection = true, + this.trailing, + this.current, + this.onTap, + this.enableDefaultOnTap = true, + this.small = false, + this.grid = false, + this.gridArtSize = _gridArtSize, + this.gridArtAssetScale = _gridArtAssetScale, + this.gridCurrentIndicatorScale = _gridCurrentIndicatorScale, + double? horizontalPadding, + this.backgroundColor = Colors.transparent, + }) : assert(selectionController is SelectionController> || + selectionController is SelectionController>), + assert(!grid || !small), + horizontalPadding = horizontalPadding ?? (small ? kSongTileHorizontalPadding : kPersistentQueueTileHorizontalPadding), + super.selectable( + key: key, + selectionIndex: selectionIndex, + selected: selected, + longPressSelectionGestureEnabled: longPressSelectionGestureEnabled, + handleTapInSelection: handleTapInSelection, + selectionController: selectionController, + ); + + final T queue; + + /// Widget to be rendered at the end of the tile. + final Widget? trailing; + + /// Whether this queue is currently playing, if yes, enables animated + /// [CurrentIndicator] over the ablum art. + /// + /// If not specified, by default uses [ContentUtils.originIsCurrent]. + final bool? current; + final VoidCallback? onTap; + + /// Whether to handle taps by default. + /// By default opens the [PersistentQueueRoute]. + final bool enableDefaultOnTap; + + /// Creates a small variant of the tile with the sizes of [SelectableTile]. + final bool small; + + /// When `true`, will create a tile suitable to be shown in grid. + /// The [small] must be `false` when this is `true`. + final bool grid; + + /// The size of the art when [grid] is `true`. + final double gridArtSize; + + /// Value passed to [ContentArt.assetScale] when [grid] is `true`. + final double gridArtAssetScale; + + /// Value passed to [ContentArt.currentIndicatorScale] when [grid] is `true`. + final double gridCurrentIndicatorScale; + + /// Tile horizontal padding. Ignored when [grid] is `true`. + final double horizontalPadding; + + /// Background tile color. + /// By default tile background is transparent. + final Color? backgroundColor; + + @override + _PersistentQueueTileState createState() => _PersistentQueueTileState(); +} + +class _PersistentQueueTileState extends SelectableState, PersistentQueueTile> + with ContentTileComponentsMixin { + + @override + SelectionEntry toSelectionEntry() => SelectionEntry.fromContent( + content: widget.queue, + index: widget.selectionIndex!, + context: context, + ); + + void _handleTap() { + super.handleTap(() { + widget.onTap?.call(); + HomeRouter.of(context).goto(HomeRoutes.factory.persistentQueue(widget.queue)); + }); + } + + bool get current { + if (widget.current != null) + return widget.current!; + return ContentUtils.originIsCurrent(widget.queue); + } + + Widget _buildInfo() { + final theme = ThemeControl.theme; + final List children = [ + Text( + widget.queue.title, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.headline6, + ), + ]; + final queue = widget.queue; + if (queue is Album) { + children.add(ArtistWidget( + artist: queue.artist, + trailingText: queue.year.toString(), + textStyle: const TextStyle(fontSize: 14.0, height: 1.0), + )); + } else if (queue is Playlist) { + final l10n = getl10n(context); + children.add(Text( + l10n.contentsPluralWithCount(queue.length), + style: theme.textTheme.subtitle2!.merge(const TextStyle(fontSize: 14.0, height: 1.0)), + )); + } + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); + } + + Widget _buildTile() { + final source = ContentArtSource.persistentQueue(widget.queue); + + final Widget child; + if (widget.grid) { + child = SizedBox( + width: widget.gridArtSize, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ContentArt( + size: widget.gridArtSize, + defaultArtIconScale: (widget.gridArtSize / kPersistentQueueTileArtSize) / 1.5, + defaultArtIcon: ContentUtils.persistentQueueIcon(widget.queue), + assetHighRes: true, + currentIndicatorScale: widget.gridCurrentIndicatorScale, + assetScale: widget.gridArtAssetScale, + source: source, + current: current, + ), + _buildInfo(), + ], + ), + ); + } else { + child = Padding( + padding: EdgeInsets.symmetric( + horizontal: widget.horizontalPadding, + vertical: _tileVerticalPadding, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: widget.small + ? ContentArt.songTile( + source: source, + defaultArtIcon: ContentUtils.persistentQueueIcon(widget.queue), + current: current, + ) + : ContentArt.persistentQueueTile( + source: source, + defaultArtIcon: ContentUtils.persistentQueueIcon(widget.queue), + current: current, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: _buildInfo(), + ), + ), + if (selectionRoute) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.trailing != null) + widget.trailing!, + buildAddToSelection(), + ], + ) + else if (widget.trailing != null) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: widget.trailing, + ), + ], + ), + ); + } + + final onTap = widget.enableDefaultOnTap || selectable && widget.selectionController!.inSelection + ? _handleTap + : widget.onTap; + + if (widget.grid) { + return Stack( + children: [ + Align( + alignment: Alignment.topCenter, + child: CustomBoxy( + delegate: _BoxyDelegate(() => Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + splashColor: Constants.Theme.glowSplashColor.auto, + onLongPress: handleLongPress, + splashFactory: _InkRippleFactory(artSize: widget.gridArtSize), + ), + )), + children: [ + LayoutId(id: #tile, child: child), + ], + ), + ), + ], + ); + } + return InkWell( + onTap: onTap, + onLongPress: handleLongPress, + splashFactory: NFListTileInkRipple.splashFactory, + child: child, + ); + } + + @override + Widget build(BuildContext context) { + if (!selectable) + return _buildTile(); + + const checkmarkGridMargin = 10.0; + final theme = ThemeControl.theme; + final artSize = widget.grid ? widget.gridArtSize : kPersistentQueueTileArtSize; + return Stack( + children: [ + _buildTile(), + if (!selectionRoute) + if(animation.status == AnimationStatus.dismissed) + const SizedBox.shrink() + else + Positioned( + left: artSize + (widget.grid ? -checmarkLargeSize - checkmarkGridMargin : 2.0), + top: artSize + (widget.grid ? -checmarkLargeSize - checkmarkGridMargin : -7.0), + child: buildSelectionCheckmark(forceLarge: widget.grid), + ) + else if (widget.grid) + Positioned( + top: (widget.gridArtSize - AddToSelectionButton.size - checkmarkGridMargin).clamp(0.0, double.infinity), + // 8 padding is already in `buildAddToSelection`, so add 10 more to reach `checkmarkMargin` + right: 2.0, + child: Theme( + data: theme.copyWith(// TODO: probably add some dimming so it's better seen no matter the picture? + iconTheme: theme.iconTheme.copyWith(color: Colors.white), + ), + child: buildAddToSelection(), + ), + ), + ], + ); + } +} + + +class _BoxyDelegate extends BoxyDelegate { + _BoxyDelegate(this.builder); + final ValueGetter builder; + + @override + Size layout() { + final tile = getChild(#tile); + final tileSize = tile.layout(constraints); + + final ink = inflate(builder(), id: #ink); + ink.layout(constraints.tighten( + width: tileSize.width, + height: tileSize.height, + )); + + return Size( + tileSize.width, + tileSize.height, + ); + } +} + + +class _InkRippleFactory extends InteractiveInkFeatureFactory { + const _InkRippleFactory({required this.artSize}); + + final double artSize; + + @override + InteractiveInkFeature create({ + required MaterialInkController controller, + required RenderBox referenceBox, + required Offset position, + required Color color, + required TextDirection textDirection, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + ShapeBorder? customBorder, + double? radius, + VoidCallback? onRemoved, + }) { + return NFListTileInkRipple( + controller: controller, + referenceBox: referenceBox, + position: position, + color: color, + containedInkWell: containedInkWell, + rectCallback: () => Offset.zero & Size(artSize, artSize), + borderRadius: const BorderRadius.all(Radius.circular(kArtBorderRadius)), + customBorder: customBorder, + radius: radius, + onRemoved: onRemoved, + textDirection: textDirection, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/content_list_view/song_tile.dart b/lib/widgets/content_list_view/song_tile.dart new file mode 100644 index 000000000..dc23a1834 --- /dev/null +++ b/lib/widgets/content_list_view/song_tile.dart @@ -0,0 +1,323 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:flutter/material.dart'; +import 'package:sweyer/sweyer.dart'; + +/// Needed for scrollbar label computations +const double kSongTileHeight = 64.0; +const double kSongTileHorizontalPadding = 10.0; +const SongTileClickBehavior kSongTileClickBehavior = SongTileClickBehavior.play; +const SongTileVariant kSongTileVariant = SongTileVariant.albumArt; + +/// Describes how to respond to song tile clicks. +enum SongTileClickBehavior { + /// Always start the clicked song from play, + + /// A /// + /// Expands that player route. + play, + + /// Allow play/pause on the clicked song. + /// + /// Doesn't expand the player route. + playPause, +} + +/// Describes what to draw in the tile leading. +enum SongTileVariant { + /// Set by default, will draw an [AlbumArt.songTile] in the tile leading. + albumArt, + + /// Will draw [SongNumber] in the tile leading. + number +} + +/// Supposed to draw a [Song.track] number, or '-' symbol if it's null. +class SongNumber extends StatelessWidget { + SongNumber({ + Key? key, + String? number, + this.current = false, + }) : number = int.tryParse(number ?? ''), + super(key: key); + + final int? number; + final bool current; + + @override + Widget build(BuildContext context) { + Widget child; + if (current) { + child = Padding( + padding: const EdgeInsets.only(top: 2.0), + child: CurrentIndicator( + color: ThemeControl.theme.colorScheme.onBackground, + ), + ); + } else if (number != null && number! > 0 && number! < 999) { + // Since this class won't be used for playlsits, but only for albums, + // I limit the number to be from 0 to 999, in other cases consider it invalid/unsassigned and show a dot + child = Text( + number.toString(), + style: const TextStyle( + fontSize: 15.0, + fontWeight: FontWeight.w800, + ), + ); + } else { + child = Container( + width: 7.0, + height: 7.0, + decoration: BoxDecoration( + color: ThemeControl.theme.colorScheme.onBackground, + borderRadius: const BorderRadius.all( + Radius.circular(100.0), + ), + ), + ); + } + return Container( + alignment: Alignment.center, + width: kSongTileArtSize, + height: kSongTileArtSize, + padding: const EdgeInsets.only(right: 4.0), + child: child, + ); + } +} + +/// A [SongTile] that can be selected. +class SongTile extends SelectableWidget { + SongTile({ + Key? key, + required this.song, + this.trailing, + this.current, + this.onTap, + this.enableDefaultOnTap = true, + this.variant = kSongTileVariant, + this.clickBehavior = kSongTileClickBehavior, + this.horizontalPadding = kSongTileHorizontalPadding, + this.backgroundColor = Colors.transparent, + }) : super(key: key); + + SongTile.selectable({ + Key? key, + required this.song, + required int selectionIndex, + required SelectionController? selectionController, + bool selected = false, + bool longPressSelectionGestureEnabled = true, + bool handleTapInSelection = true, + this.trailing, + this.current, + this.onTap, + this.enableDefaultOnTap = true, + this.variant = kSongTileVariant, + this.clickBehavior = kSongTileClickBehavior, + this.horizontalPadding = kSongTileHorizontalPadding, + this.backgroundColor = Colors.transparent, + }) : assert(selectionController is SelectionController> || + selectionController is SelectionController>), + super.selectable( + key: key, + selectionIndex: selectionIndex, + selected: selected, + longPressSelectionGestureEnabled: longPressSelectionGestureEnabled, + handleTapInSelection: handleTapInSelection, + selectionController: selectionController, + ); + + final Song song; + + /// Widget to be rendered at the end of the tile. + final Widget? trailing; + + /// Whether this song is current, if yes, enables animated + /// [CurrentIndicator] over the ablum art/instead song number. + /// + /// If not specified, by default uses [ContentUtils.songIsCurrent]. + final bool? current; + final VoidCallback? onTap; + + /// Whether to handle taps by default. + /// By default plays song on tap. + final bool enableDefaultOnTap; + + final SongTileVariant variant; + + /// How to respond to tile clicks. + /// + /// Will be force treated as [SongTileClickBehavior.playPause] if [selectionRouteOf] is `true`. + final SongTileClickBehavior clickBehavior; + final double horizontalPadding; + + /// Background tile color. + /// By default tile background is transparent. + final Color backgroundColor; + + @override + _SongTileState createState() => _SongTileState(); +} + +class _SongTileState extends SelectableState, SongTile> with ContentTileComponentsMixin { + Color? previousBackgroundColor; + + @override + void didUpdateWidget(SongTile oldWidget) { + previousBackgroundColor = oldWidget.backgroundColor; + super.didUpdateWidget(oldWidget); + } + + @override + SelectionEntry toSelectionEntry() => SelectionEntry.fromContent( + content: widget.song, + index: widget.selectionIndex!, + context: context, + ); + + @override + bool? get widgetSelected => selectionRoute + ? widget.selectionController!.data.contains(toSelectionEntry()) + : super.widgetSelected; + + bool get showAlbumArt => widget.variant == SongTileVariant.albumArt; + + void _handleTap() { + super.handleTap(() async { + widget.onTap?.call(); + final song = widget.song; + final player = MusicPlayer.instance; + if (!selectionRoute && widget.clickBehavior == SongTileClickBehavior.play) { + playerRouteController.open(); + await player.setSong(song); + await player.play(); + } else { + if (song == ContentControl.state.currentSong) { + if (!player.playing) { + await player.play(); + } else { + await player.pause(); + } + } else { + await player.setSong(song); + await player.play(); + } + } + }); + } + + bool get current { + return widget.current ?? ContentUtils.songIsCurrent(widget.song); + } + + Widget _buildTile(Widget albumArt, [double? rightPadding]) { + rightPadding ??= widget.horizontalPadding; + final theme = Theme.of(context); + Widget title = Text( + widget.song.title, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.headline6, + ); + Widget subtitle = ArtistWidget( + artist: widget.song.artist, + trailingText: formatDuration(Duration(milliseconds: widget.song.duration)), + ); + if (!showAlbumArt) { + // Reduce padding between leading and title. + Widget translate(Widget child) { + return Transform.translate( + offset: const Offset(-16.0, 0.0), + child: child, + ); + } + + title = translate(title); + subtitle = translate(subtitle); + } + + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + tween: ColorTween(begin: previousBackgroundColor, end: widget.backgroundColor), + builder: (context, value, child) => Material( + color: value, + child: child, + ), + child: NFListTile( + dense: true, + isThreeLine: false, + contentPadding: EdgeInsets.only( + left: widget.horizontalPadding, + right: rightPadding, + ), + onTap: widget.enableDefaultOnTap || selectable && widget.selectionController!.inSelection + ? _handleTap + : widget.onTap, + onLongPress: handleLongPress, + title: title, + subtitle: subtitle, + leading: albumArt, + trailing: selectionRoute + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.trailing != null) + widget.trailing!, + buildAddToSelection(), + ], + ) + : widget.trailing, + ), + ); + } + + @override + Widget build(BuildContext context) { + Widget albumArt; + if (showAlbumArt) { + albumArt = ContentArt.songTile( + source: ContentArtSource.song(widget.song), + current: current, + ); + } else { + albumArt = SongNumber( + number: widget.song.track, + current: current, + ); + } + if (!selectable) + return _buildTile(albumArt); + return Stack( + children: [ + AnimatedBuilder( + animation: animation, + builder: (context, child) { + var rightPadding = widget.horizontalPadding; + if (!showAlbumArt && !selectionRoute) { + if (animation.status == AnimationStatus.forward || + animation.status == AnimationStatus.completed || + animation.value > 0.2) { + rightPadding += 40.0; + } + } + return _buildTile(albumArt, rightPadding); + }, + ), + if (!selectionRoute && animation.status != AnimationStatus.dismissed) + Positioned( + left: showAlbumArt ? 34.0 + widget.horizontalPadding : null, + right: showAlbumArt ? null : 4.0 + widget.horizontalPadding, + bottom: showAlbumArt ? 2.0 : 20.0, + child: Padding( + padding: const EdgeInsets.only(right: 6.0), + child: SelectionCheckmark(animation: animation), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/content_section.dart b/lib/widgets/content_section.dart new file mode 100644 index 000000000..76f889364 --- /dev/null +++ b/lib/widgets/content_section.dart @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'package:sweyer/sweyer.dart'; + +/// Creates a content section, which a few content tiles and a header with name +/// of content type. +/// +/// For example the search results are split into such sections. +class ContentSection extends StatelessWidget { + const ContentSection({ + Key? key, + this.contentType, + required this.list, + this.onHeaderTap, + this.maxPreviewCount = 5, + this.selectionController, + this.contentTileTapHandler, + }) : child = null, + super(key: key); + + const ContentSection.custom({ + Key? key, + this.contentType, + required this.list, + required this.child, + this.onHeaderTap, + }) : selectionController = null, + contentTileTapHandler = null, + maxPreviewCount = 0, + super(key: key); + + final Type? contentType; + final List list; + + final Widget? child; + + /// If specified, header will become tappable and near content name there will + /// be chevron icon, idicating that it's tappable. + final VoidCallback? onHeaderTap; + + /// Max amount of items shown in section. + /// + /// This does not affect the amount of items displayed on page opened + /// by tapping header. + final int maxPreviewCount; + + final ContentSelectionController? selectionController; + + /// Receives a type of tapped content tile and can be used to fire + /// additional callbacks. + final VoidCallback? contentTileTapHandler; + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + + Widget Function(int)? builder; + if (child == null) { + builder = (index) { + final item = list[index]; + return ContentTile( + contentType: contentType, + content: item, + selectionIndex: index, + selected: selectionController?.data.contains(SelectionEntry.fromContent( + content: item, + index: index, + context: context, + )) ?? false, + selectionController: selectionController, + onTap: () => contentTileTapHandler?.call(), + horizontalPadding: 12.0, + ); + }; + } + + final count = list.length; + + return Column( + children: [ + NFInkWell( + onTap: onHeaderTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.contents(contentType), + style: const TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.w800, + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 240), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: onHeaderTap == null + ? const SizedBox.shrink() + : const Icon(Icons.chevron_right_rounded), + ), + ], + ), + ), + ), + if (child != null) + child! + else + Column( + children: [ + for (int index = 0; index < math.min(maxPreviewCount, count); index++) + builder!(index), + ], + ) + ], + ); + } +} diff --git a/lib/widgets/content_tile.dart b/lib/widgets/content_tile.dart deleted file mode 100644 index 712900073..000000000 --- a/lib/widgets/content_tile.dart +++ /dev/null @@ -1,6 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -// TODO: when if add [ContentTile], change the above term. \ No newline at end of file diff --git a/lib/widgets/current_indicator.dart b/lib/widgets/current_indicator.dart index 8a37c7b37..e542ab7ac 100644 --- a/lib/widgets/current_indicator.dart +++ b/lib/widgets/current_indicator.dart @@ -6,15 +6,14 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/sweyer.dart'; /// Shows an indicator that marks out the current playing song tile. /// Consists of three equalizer bars. class CurrentIndicator extends StatelessWidget { - const CurrentIndicator({Key key, this.color = Colors.white}) - : assert(color != null), - super(key: key); + const CurrentIndicator({Key? key, this.color = Colors.white}) + : super(key: key); /// Color of the bars. final Color color; @@ -83,14 +82,14 @@ class CurrentIndicator extends StatelessWidget { class _Value { const _Value( this.height, [ - int milliseconds, - Curve curve, + int? milliseconds, + Curve? curve, ]) : _milliseconds = milliseconds, _curve = curve; final double height; - final int _milliseconds; - final Curve _curve; + final int? _milliseconds; + final Curve? _curve; int get milliseconds => _milliseconds ?? 180; Curve get curve => _curve ?? Curves.linear; @@ -100,20 +99,24 @@ class _Value { class _Bar extends StatefulWidget { const _Bar({ - Key key, - this.values, + Key? key, + required this.values, this.color, }) : super(key: key); + final List<_Value> values; - final Color color; + final Color? color; + @override _BarState createState() => _BarState(); } -class _BarState extends State<_Bar> with SingleTickerProviderStateMixin { - int index; - Timer timer; - StreamSubscription _playingSubscription; +class _BarState extends State<_Bar> { + late int index; + Timer? timer; + late StreamSubscription _playingSubscription; + + _Value get currentValue => widget.values[index]; @override void initState() { @@ -136,10 +139,10 @@ class _BarState extends State<_Bar> with SingleTickerProviderStateMixin { void _iterate() { if (!mounted) { assert(false); - timer.cancel(); + timer!.cancel(); timer = null; } - timer = Timer(dilate(widget.values[index].duration), () { + timer = Timer(dilate(currentValue.duration), () { setState(() { if (index == widget.values.length - 1) { index = 0; @@ -160,7 +163,7 @@ class _BarState extends State<_Bar> with SingleTickerProviderStateMixin { void stop() { if (timer != null && mounted) { setState(() { - timer.cancel(); + timer!.cancel(); timer = null; }); } @@ -177,8 +180,8 @@ class _BarState extends State<_Bar> with SingleTickerProviderStateMixin { Widget build(BuildContext context) { final animating = timer != null; return AnimatedContainer( - height: animating ? 1.0 + 19.0 * widget.values[index].height : 3.0, - curve: animating ? widget.values[index].curve : Curves.easeOutCubic, + height: animating ? 1.0 + 19.0 * currentValue.height : 3.0, + curve: animating ? currentValue.curve : Curves.easeOutCubic, decoration: BoxDecoration( color: widget.color, borderRadius: const BorderRadius.all( @@ -187,7 +190,7 @@ class _BarState extends State<_Bar> with SingleTickerProviderStateMixin { ), width: 5.0, duration: animating - ? widget.values[index].duration + ? currentValue.duration : const Duration(milliseconds: 500), ); } diff --git a/lib/widgets/debug.dart b/lib/widgets/debug.dart new file mode 100644 index 000000000..c5a788d30 --- /dev/null +++ b/lib/widgets/debug.dart @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:flutter/material.dart'; +import 'package:sweyer/sweyer.dart'; + +class DebugOverlay { + DebugOverlay(WidgetBuilder builder) { + assert(() { + _entry = OverlayEntry(builder: (context) { + return Stack( + children: [ + Positioned( + top: MediaQuery.of(context).size.height / 2.5, + bottom: 40, + left: 0, + right: 0, + child: builder(context), + ), + ], + ); + }); + HomeRouter.instance.navigatorKey.currentState!.overlay!.insert(_entry!); + return true; + }()); + } + + OverlayEntry? _entry; + + void dispose() { + assert(() { + final result = _entry != null; + _entry!.remove(); + return result; + }()); + } +} diff --git a/lib/widgets/drawer.dart b/lib/widgets/drawer.dart index c30ea9f54..79482a642 100644 --- a/lib/widgets/drawer.dart +++ b/lib/widgets/drawer.dart @@ -7,24 +7,23 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; /// Widget that builds drawer. class DrawerWidget extends StatefulWidget { - const DrawerWidget({Key key}) : super(key: key); + const DrawerWidget({Key? key}) : super(key: key); @override _DrawerWidgetState createState() => _DrawerWidgetState(); } -class _DrawerWidgetState extends State - with SingleTickerProviderStateMixin { +class _DrawerWidgetState extends State { /// Indicates that current route with drawer is ontop and it can take the control /// over the ui animations. bool _onTop = true; - SlidableController controller; + late SlidableController controller; @override void initState() { @@ -41,7 +40,7 @@ class _DrawerWidgetState extends State void _handleControllerStatusChange(AnimationStatus status) { // Change system UI on expanding/collapsing the drawer. - if (_onTop && HomeRouter.instance.drawerCanBeOpened) { + if (_onTop) { if (status == AnimationStatus.dismissed) { SystemUiStyleController.animateSystemUiOverlay( to: Constants.UiTheme.grey.auto, @@ -60,42 +59,35 @@ class _DrawerWidgetState extends State controller.reset(); } + final screenWidth = MediaQuery.of(context).size.width; + /// I don't bother myself applying drawer screen ui theme after /// the next route pops, like I do for [ShowFunctions.showBottomSheet] for example /// because I close the drawer after route push, so there's no way it will be open at this moment. return RouteAwareWidget( - onPushNext: () { - _onTop = false; - }, - onPopNext: () { - _onTop = true; - }, - child: AnimatedBuilder( - animation: controller, - builder: (context, child) => Slidable( - direction: SlideDirection.right, - start: -304.0 / screenWidth, - end: 0.0, - shouldGiveUpGesture: (event) { - return controller.value == 0.0 && - // when on another drag on the right to next tab - (event.delta.dx < 0.0 || - // when player route is opened, for example - !HomeRouter.instance.drawerCanBeOpened); - }, - onBarrierTap: controller.close, - barrier: Container(color: Colors.black26), - controller: controller, - barrierIgnoringStrategy: const IgnoringStrategy(dismissed: true), - hitTestBehaviorStrategy: const HitTestBehaviorStrategy.opaque(dismissed: HitTestBehavior.translucent), - child: SizedBox( - height: screenHeight, - width: screenWidth, - child: Container( - width: 304.0, - alignment: Alignment.centerLeft, - child: _DrawerWidgetContent(controller: controller), - ), + onPushNext: () => _onTop = false, + onPopNext: () => _onTop = true, + child: Slidable( + direction: SlideDirection.right, + start: -304.0 / screenWidth, + end: 0.0, + shouldGiveUpGesture: (event) { + return controller.value == 0.0 && + // when on another drag on the right to next tab + (event.delta.dx < 0.0 || + // when player route is opened, for example + !HomeRouter.instance.drawerCanBeOpened); + }, + onBarrierTap: controller.close, + barrier: Container(color: Colors.black26), + controller: controller, + barrierIgnoringStrategy: const IgnoringStrategy(dismissed: true), + hitTestBehaviorStrategy: const HitTestBehaviorStrategy.opaque(dismissed: HitTestBehavior.translucent), + child: SizedBox.expand( + child: Container( + width: 304.0, + alignment: Alignment.centerLeft, + child: _DrawerWidgetContent(controller: controller), ), ), ), @@ -104,8 +96,8 @@ class _DrawerWidgetState extends State } class _DrawerWidgetContent extends StatefulWidget { - _DrawerWidgetContent({Key key, @required this.controller}) : super(key: key); - final SlidableController controller; + _DrawerWidgetContent({Key? key, required this.controller}) : super(key: key); + final SlidableController? controller; @override _DrawerWidgetContentState createState() => _DrawerWidgetContentState(); @@ -113,23 +105,23 @@ class _DrawerWidgetContent extends StatefulWidget { class _DrawerWidgetContentState extends State<_DrawerWidgetContent> { /// Status bar height on my phone. -static const _baseTopPadding = 24.0; + static const _baseTopPadding = 24.0; double elevation = 0.0; @override void initState() { super.initState(); - widget.controller.addListener(_handleControllerChange); + widget.controller!.addListener(_handleControllerChange); } @override void dispose() { - widget.controller.removeListener(_handleControllerChange); + widget.controller!.removeListener(_handleControllerChange); super.dispose(); } void _handleControllerChange() { - if (widget.controller.value <= 0.01) { + if (widget.controller!.value <= 0.01) { if (elevation != 0.0) { setState(() { elevation = 0.0; @@ -145,12 +137,12 @@ static const _baseTopPadding = 24.0; } void _handleClickSettings() { - widget.controller.close(); + widget.controller!.close(); AppRouter.instance.goto(AppRoutes.settings); } void _handleClickDebug() { - widget.controller.close(); + widget.controller!.close(); AppRouter.instance.goto(AppRoutes.dev); } @@ -183,7 +175,7 @@ static const _baseTopPadding = 24.0; style: TextStyle( fontSize: 30.0, fontWeight: FontWeight.w800, - color: ThemeControl.theme.textTheme.headline6.color, + color: ThemeControl.theme.textTheme.headline6!.color, ), ), ), @@ -198,8 +190,8 @@ static const _baseTopPadding = 24.0; onTap: _handleClickSettings, ), ValueListenableBuilder( - valueListenable: ContentControl.devMode, - builder: (context, value, child) => value ? child : const SizedBox.shrink(), + valueListenable: Prefs.devMode, + builder: (context, value, child) => value ? child! : const SizedBox.shrink(), child: MenuItem( l10n.debug, icon: Icons.adb_rounded, @@ -215,15 +207,9 @@ static const _baseTopPadding = 24.0; } class MenuItem extends StatelessWidget { - final IconData icon; - final String title; - final VoidCallback onTap; - final VoidCallback onLongPress; - final double iconSize; - final double fontSize; const MenuItem( this.title, { - Key key, + Key? key, this.icon, this.onTap, this.onLongPress, @@ -231,6 +217,13 @@ class MenuItem extends StatelessWidget { this.fontSize = 15.0, }) : super(key: key); + final IconData? icon; + final String title; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final double iconSize; + final double fontSize; + @override Widget build(BuildContext context) { return NFListTile( diff --git a/lib/widgets/logo.dart b/lib/widgets/logo.dart index 785d4b3a3..7779a9602 100644 --- a/lib/widgets/logo.dart +++ b/lib/widgets/logo.dart @@ -10,8 +10,8 @@ import 'package:sweyer/sweyer.dart'; /// Creates a Sweyer logo. class SweyerLogo extends StatelessWidget { const SweyerLogo({ - Key key, - this.size, + Key? key, + this.size = kSongTileArtSize, this.color, }) : super(key: key); @@ -19,17 +19,18 @@ class SweyerLogo extends StatelessWidget { /// Background color to be used instead of [ThemeControl.colorForBlend], /// which is applied by default. - final Color color; + final Color? color; @override Widget build(BuildContext context) { + final cacheSize = (size * 1.65 * MediaQuery.of(context).devicePixelRatio).round(); return ClipRRect( borderRadius: const BorderRadius.all( Radius.circular(8.0), ), child: SizedBox( - width: size ?? kSongTileArtSize, - height: size ?? kSongTileArtSize, + width: size, + height: size, child: Stack( children: [ Transform.scale( @@ -37,9 +38,12 @@ class SweyerLogo extends StatelessWidget { child: Image.asset( Constants.Assets.ASSET_LOGO_MASK, color: color != null - ? getColorForBlend(color) + ? ContentArt.getColorToBlendInDefaultArt(color!) : ThemeControl.colorForBlend, + cacheHeight: cacheSize, + cacheWidth: cacheSize, colorBlendMode: BlendMode.plus, + filterQuality: FilterQuality.high, ), ), ], diff --git a/lib/widgets/play_pause_button.dart b/lib/widgets/play_pause_button.dart index 5fbe0cccc..224df3268 100644 --- a/lib/widgets/play_pause_button.dart +++ b/lib/widgets/play_pause_button.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/sweyer.dart'; import 'package:flare_flutter/flare_actor.dart'; import 'package:sweyer/constants.dart' as Constants; @@ -17,28 +17,28 @@ const double _kButtonSize = 66.0; class AnimatedPlayPauseButton extends StatefulWidget { const AnimatedPlayPauseButton({ - Key key, + Key? key, this.player, this.iconSize, this.size, this.iconColor, }) : super(key: key); - final AudioPlayer player; - final double iconSize; - final double size; - final Color iconColor; + final AudioPlayer? player; + final double? iconSize; + final double? size; + final Color? iconColor; @override AnimatedPlayPauseButtonState createState() => AnimatedPlayPauseButtonState(); } class AnimatedPlayPauseButtonState extends State with TickerProviderStateMixin { - AnimationController controller; - StreamSubscription _playingSubscription; + late AnimationController controller; + StreamSubscription? _playingSubscription; AudioPlayer get player => widget.player ?? MusicPlayer.instance; - String _animation; + late String _animation; set animation(String value) { setState(() { _animation = value; @@ -84,7 +84,7 @@ class AnimatedPlayPauseButtonState extends State with T @override void dispose() { - _playingSubscription.cancel(); + _playingSubscription?.cancel(); controller.dispose(); super.dispose(); } @@ -126,7 +126,7 @@ class AnimatedPlayPauseButtonState extends State with T ).animate(baseAnimation); final scaleAnimation = Tween(begin: 1.05, end: 0.89).animate(baseAnimation); final textScaleFactor = MediaQuery.of(context).textScaleFactor; - final color = widget.iconColor ?? ThemeControl.theme.iconTheme.color; + final color = widget.iconColor ?? ThemeControl.theme.iconTheme.color!; return NFIconButton( size: textScaleFactor * (widget.size ?? _kButtonSize), iconSize: textScaleFactor * (widget.iconSize ?? _kIconSize), @@ -137,17 +137,19 @@ class AnimatedPlayPauseButtonState extends State with T scale: scaleAnimation, // Needed because for some reason the color is not updated on theme change. key: ValueKey(color), - child: FlareActor( - Constants.Assets.ASSET_ANIMATION_PLAY_PAUSE, - animation: _animation, - callback: (value) { - if (value == 'pause_play' && _animation != 'play_pause') { - animation = 'play'; - } else if (value == 'play_pause' && _animation != 'pause_play') { - animation = 'pause'; - } - }, - color: color, + child: RepaintBoundary( + child: FlareActor( + Constants.Assets.ASSET_ANIMATION_PLAY_PAUSE, + animation: _animation, + callback: (value) { + if (value == 'pause_play' && _animation != 'play_pause') { + animation = 'play'; + } else if (value == 'play_pause' && _animation != 'pause_play') { + animation = 'pause'; + } + }, + color: color, + ), ), ), ), diff --git a/lib/widgets/screens.dart b/lib/widgets/screens.dart index 22e6ec576..8196b5c3d 100644 --- a/lib/widgets/screens.dart +++ b/lib/widgets/screens.dart @@ -4,15 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; import 'package:sweyer/sweyer.dart'; /// The screen that contains the text message and the widget slot. class CenterContentScreen extends StatelessWidget { const CenterContentScreen({ - Key key, - this.text, - this.widget, + Key? key, + required this.text, + required this.widget, }) : super(key: key); final String text; @@ -48,12 +47,10 @@ class CenterContentScreen extends StatelessWidget { top: 0.0, left: 0.0, right: 0.0, - child: PreferredSize( - preferredSize: const Size.fromHeight(kNFAppBarPreferredSize), - child: AppBar( - backgroundColor: Colors.transparent, - leading: const SettingsButton(), - ), + child: AppBar( + elevation: 0.0, + backgroundColor: Colors.transparent, + leading: const SettingsButton(), ), ), ], diff --git a/lib/widgets/scrollbar.dart b/lib/widgets/scrollbar.dart index e7a81aa29..2f9bec97a 100644 --- a/lib/widgets/scrollbar.dart +++ b/lib/widgets/scrollbar.dart @@ -6,9 +6,6 @@ * See ThirdPartyNotices.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @dart = 2.12 - - /// ########################################################################################### /// copied this from flutter https://github.com/flutter/flutter/commit/02efffc134 /// ########################################################################################### @@ -16,7 +13,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/sweyer.dart'; const double _kScrollbarThickness = 8.0; @@ -69,14 +66,17 @@ class AppScrollbar extends StatefulWidget { labelBuilder: !showLabel ? null : (context) { + final l10n = getl10n(context); final item = list[ (controller.position.pixels / kSongTileHeight - 1) .clamp(0.0, list.length - 1).round() ]; return NFScrollLabel( - text: contentPick( + text: contentPick>( song: () => (item as Song).title[0].toUpperCase(), album: () => (item as Album).album[0].toUpperCase(), + playlist: () => (item as Playlist).name[0].toUpperCase(), + artist: () => ContentUtils.localizedArtist((item as Artist).artist[0], l10n).toUpperCase(), )(), ); }, diff --git a/lib/widgets/seekbar.dart b/lib/widgets/seekbar.dart index 510ccc35b..5981ad925 100644 --- a/lib/widgets/seekbar.dart +++ b/lib/widgets/seekbar.dart @@ -8,13 +8,13 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; + import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; class Seekbar extends StatefulWidget { const Seekbar({ - Key key, + Key? key, this.color, this.player, this.duration, @@ -23,19 +23,19 @@ class Seekbar extends StatefulWidget { /// Color of the actove slider part. /// /// If non specified [ColorScheme.primary] color will be used. - final Color color; + final Color? color; /// Player to use instead of [MusicPlayer], which is used by default. - final AudioPlayer player; + final AudioPlayer? player; /// Predefined duration to use. - final Duration duration; + final Duration? duration; @override _SeekbarState createState() => _SeekbarState(); } -class _SeekbarState extends State { +class _SeekbarState extends State with SingleTickerProviderStateMixin { // Duration of playing track. Duration _duration = Duration.zero; @@ -43,23 +43,29 @@ class _SeekbarState extends State { double _value = 0.0; /// Value to perform drag. - double _localValue; + late double _localValue; /// Is user dragging slider right now. bool _isDragging = false; /// Value to work with. - double get workingValue => _isDragging ? _localValue : _value; + double? get workingValue => _isDragging ? _localValue : _value; - StreamSubscription _positionSubscription; - StreamSubscription _songChangeSubscription; + late StreamSubscription _positionSubscription; + StreamSubscription? _songChangeSubscription; AudioPlayer get player => widget.player ?? MusicPlayer.instance; + late AnimationController animationController; + late Animation thumbSizeAnimation; + @override void initState() { super.initState(); - _duration = widget.duration ?? player.duration; + final duration = widget.duration ?? player.duration; + if (duration != null) { + _duration = duration; + } _value = _positionToValue(player.position); // Handle track position movement _positionSubscription = player.positionStream.listen((position) { @@ -82,17 +88,30 @@ class _SeekbarState extends State { }); }); } + animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + thumbSizeAnimation = Tween( + begin: 7.0, + end: 9.0, + ).animate(CurvedAnimation( + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + parent: animationController, + )); } @override void dispose() { _positionSubscription.cancel(); _songChangeSubscription?.cancel(); + animationController.dispose(); super.dispose(); } double _positionToValue(Duration position) { - return (position.inMilliseconds / math.max(_duration.inMilliseconds, 1)).clamp(0.0, 1.0); + return (position.inMilliseconds / math.max(_duration.inMilliseconds, 1.0)).clamp(0.0, 1.0); } // Drag functions @@ -105,19 +124,19 @@ class _SeekbarState extends State { void _handleChanged(double newValue) { setState(() { - if (!_isDragging) - _isDragging = true; + if (animationController.status != AnimationStatus.completed && animationController.status != AnimationStatus.forward) + animationController.forward(); _localValue = newValue; }); } - /// FIXME: https://github.com/nt4f04uNd/sweyer/issues/6 Future _handleChangeEnd(double newValue) async { await player.seek(_duration * newValue); if (mounted) { setState(() { _isDragging = false; _value = newValue; + animationController.reverse(); }); } } @@ -137,25 +156,31 @@ class _SeekbarState extends State { width: 36.0 * scaleFactor, transform: Matrix4.translationValues(5.0, 0.0, 0.0), child: Text( - formatDuration(_duration * workingValue), + formatDuration(_duration * workingValue!), style: TextStyle( fontSize: 12.0, fontWeight: FontWeight.w700, - color: ThemeControl.theme.textTheme.headline6.color, + color: ThemeControl.theme.textTheme.headline6!.color, ), ), ), Expanded( - child: SliderTheme( - data: SliderThemeData( - trackHeight: 2.0, - thumbColor: color, - overlayColor: color.withOpacity(0.12), - activeTrackColor:color, - inactiveTrackColor: Constants.Theme.sliderInactiveColor.auto, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 7.5, + child: AnimatedBuilder( + animation: thumbSizeAnimation, + builder: (context, child) => SliderTheme( + data: SliderThemeData( + trackHeight: 2.0, + thumbColor: color, + overlayColor: color.withOpacity(ThemeControl.isLight ? 0.12 : 0.24), + activeTrackColor: color, + inactiveTrackColor: Constants.Theme.sliderInactiveColor.auto, + overlayShape: const RoundSliderOverlayShape(overlayRadius: 17.0), + thumbShape: RoundSliderThumbShape( + pressedElevation: 3.0, + enabledThumbRadius: thumbSizeAnimation.value, + ), ), + child: child!, ), child: Slider( value: _isDragging ? _localValue : _value, @@ -173,7 +198,7 @@ class _SeekbarState extends State { style: TextStyle( fontSize: 12.0, fontWeight: FontWeight.w700, - color: ThemeControl.theme.textTheme.headline6.color, + color: ThemeControl.theme.textTheme.headline6!.color, ), ), ), diff --git a/lib/widgets/selection.dart b/lib/widgets/selection.dart index 62220abb4..7b0a9d976 100644 --- a/lib/widgets/selection.dart +++ b/lib/widgets/selection.dart @@ -3,37 +3,115 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import 'dart:math'; +import 'dart:math' as math; +import 'package:back_button_interceptor/back_button_interceptor.dart'; +import 'package:boxy/boxy.dart'; import 'package:flare_flutter/flare_actor.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; -import 'package:collection/collection.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter/widgets.dart'; + import 'package:sweyer/sweyer.dart'; import 'package:sweyer/constants.dart' as Constants; +// See selection actions logic overview here +// https://docs.google.com/spreadsheets/d/1LYJ5Abb1zWhYMAUs0zRjx-aiMwJn-3XLS2NjoqV5le8 + /// Selection animation duration. const Duration kSelectionDuration = Duration(milliseconds: 350); -/// The [SelectionWidget] need its parent updates him. +/// Whether the [SelectionRoute] is currently opened in the given [context]. +/// +/// See [SelectableState.selectionRoute] for discussion of how selection works +/// when this is `true`. +bool selectionRouteOf(context) { + return HomeRouter.maybeOf(context)?.selectionArguments != null; +} + +/// A mixin for easy creation of [ContentSelectionController] and +/// connection with [SelectionRoute] selection controller. +/// +/// Usually this is a creator of [SelectionWidget]. /// -/// Mixin this to a parent of [SelectableWidget] and add [handleSelection]\ -/// and [handleSelectionStatus] as handlers to listeners to the controller(s). -mixin SelectionHandler on State { +/// For example see [TabsRoute]. +mixin SelectionHandlerMixin on State { + /// The selection controller initialized within [initSelectionController]. + late ContentSelectionController selectionController; + + /// Home router of context. + late HomeRouter? homeRouter = HomeRouter.maybeOf(context); + + /// Whether the [SelectionRoute] is currently opened in this context. + bool get selectionRoute => homeRouter?.selectionArguments != null; + + Type? _savedPrimaryContentType; + + /// Creates a selection controller. + /// + /// If [selectionRoute] is currently `true`, will instead listen to the + /// existing controller the selection route provides. + /// + /// If [listen] is `true`, the [handleSelection] is attached to the controller. + /// If [listenStatus] is `true`, the [handleSelectionStatus] is attached to the controller. + void initSelectionController(ValueGetter factory, { + bool listen = true, + bool listenStatus = false, + }) { + if (selectionRoute) { + selectionController = homeRouter!.selectionArguments!.selectionController; + _savedPrimaryContentType = selectionController.primaryContentType; + } else { + selectionController = factory(); + } + if (listen) + selectionController.addListener(handleSelection); + if (listenStatus) + selectionController.addStatusListener(handleSelectionStatus); + } + + /// Disposes the created selection controller, or, when [selectionRoute] is `true`, + /// stops listening to the one selection route has provided. + void disposeSelectionController() { + if (selectionRoute) { + selectionController.removeListener(handleSelection); + selectionController.removeStatusListener(handleSelectionStatus); + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + selectionController.primaryContentType = _savedPrimaryContentType; + }); + } else { + selectionController.dispose(); + } + } + /// Listens to [SelectionController.addListener]. /// By default just calls [setState]. + /// + /// Can be automatically bound with [initSelectionController]. @protected void handleSelection() { - setState(() { - /* update appbar and tiles on selection */ - }); + /// [ContentSelectionController.dispose] delays the controller disposal + /// to animate keep closing animation, so it is valid (unless it was + /// triggered in unmounted state in some other way). + if (mounted) { + setState(() {/* tiles on selection */}); + } } /// Listens to [SelectionController.addStatusListener]. /// By default just calls [setState]. + /// + /// Can be automatically bound with [initSelectionController]. @protected - void handleSelectionStatus(AnimationStatus _) { - setState(() {/* update appbar and tiles on selection status */}); + void handleSelectionStatus(AnimationStatus status) { + /// [ContentSelectionController.dispose] delays the controller disposal + /// to animate keep closing animation, so it is valid (unless it was + /// triggered in unmounted state in some other way). + if (mounted) { + setState(() {/* update appbar and tiles on selection status */}); + } } } @@ -44,29 +122,49 @@ mixin SelectionHandler on State { abstract class SelectableWidget extends StatefulWidget { /// Creates a widget, not selectable. const SelectableWidget({ - Key key, - }) : selected = null, + Key? key, + }) : selectionIndex = null, + selected = null, + longPressSelectionGestureEnabled = null, + handleTapInSelection = null, selectionController = null, super(key: key); /// Creates a selectable widget. const SelectableWidget.selectable({ - Key key, - @required this.selectionController, - this.selected = false, + Key? key, + required int this.selectionIndex, + required bool this.selected, + required bool this.longPressSelectionGestureEnabled, + required bool this.handleTapInSelection, + required this.selectionController, }) : super(key: key); + /// The index to pass to [SelectionEntry] in [SelectableState.toSelectionEntry]. + final int? selectionIndex; + /// Makes tiles aware whether they are selected in some global set. /// This will be used on first build, after this tile will have internal selection state. - final bool selected; + final bool? selected; + + /// Whether the long press selection gesture is enabled. + /// + /// Will be force treated as `false` if [selectionRouteOf] is `true`. + final bool? longPressSelectionGestureEnabled; + + /// Whether in selection the tap handling is enabled. + /// + /// Set this to `false` if there's a need to preserve the ability of user to + /// perform default tile tap actions in selection, and instead have some custom + /// for selection button for example in the tile trailing. + /// + /// Will be force treated as `false` if [selectionRouteOf] is `true`. + final bool? handleTapInSelection; /// A controller that drive the selection. /// /// If `null`, widget will be considered as not selectable - final SelectionController selectionController; - - /// Converts this widget to the entry [selectionController] is holding. - T toSelectionEntry(); + final SelectionController? selectionController; @override // TODO: remove this ignore when https://github.com/dart-lang/linter/issues/2345 is resolved @@ -75,8 +173,8 @@ abstract class SelectableWidget extends StatefulWidget { } /// A state to be used with [SelectableWidget]. -abstract class SelectableState extends State with SingleTickerProviderStateMixin { - AnimationController _controller; +abstract class SelectableState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; /// Returns animation that can be used for animating the selection. /// @@ -86,58 +184,83 @@ abstract class SelectableState extends State with /// To avoid this, animation is instantiated in [initState]. /// /// See also: - /// * [buildAnimation] that build the animation object + /// * [buildAnimation] that builds the animation Animation get animation => _animation; - Animation _animation; + late Animation _animation; /// Whether the widget is currently being selected. - bool get selected => _selected; - bool _selected; + bool get selected => selectable && (_controller.status == AnimationStatus.forward || + _controller.status == AnimationStatus.completed); + + /// Used to update the [selected] value when the widget updates. + /// + /// When returns null, not used. + bool? get widgetSelected => null; /// Whether the widget can be selected. bool get selectable => widget.selectionController != null; + /// Whether the widget is inside the selection route and is [selectable]. + /// + /// When this is true the default scheme when the [SelectableWidget.index] + /// is passed to [toSelectionEntry] is ignored and custom rules are defined. + /// + /// Specifically: + /// * songs from [SongOrigin]s are assigned with the index of them in global state, + /// same for other content, so for example when song in album is selected, it is + /// also selected in the all songs screen + /// * the method above is not suitable for [DuplicatingSongOriginMixin], such as + /// playlists, and content inside them should be considered unique. + /// To accomplish that by [DuplicatingSongOriginMixin] the entry also receives + /// the [SelectionEntry.origin], so the entries selected in it are scoped to this + /// particular origin. + bool get selectionRoute => selectable && selectionRouteOf(context); + + /// Converts this widget to the entry [selectionController] is holding. + /// + /// See also a discussion in [SelectionEntry]. + E toSelectionEntry(); + @override void initState() { super.initState(); if (!selectable) return; - _selected = widget.selected ?? false; _controller = AnimationController(vsync: this, duration: kSelectionDuration); _animation = buildAnimation(_controller); - if (_selected) { + if (widgetSelected ?? widget.selected!) { _controller.value = 1; } } @override - void didUpdateWidget(covariant T oldWidget) { - super.didUpdateWidget(oldWidget); + void didUpdateWidget(covariant W oldWidget) { if (selectable) { - if (widget.selectionController.notInSelection && _selected) { + if (widget.selectionController!.notInSelection && selected) { /// We have to check if controller is closing, i.e. user pressed global close button to quit the selection. /// /// We are assuming that parent updates us, as we can't add owr own status listener to the selection controller, /// because it is quite expensive for the list. - _selected = false; - _controller.value = widget.selectionController.animationController.value; + _controller.value = widget.selectionController!.animation.value; _controller.reverse(); - } else if (oldWidget.selected != widget.selected) { - _selected = widget.selected; - if (_selected) { - _controller.forward(); - } else { - _controller.reverse(); + } else { + final localWidgetSelected = widgetSelected; + if (oldWidget.selected != (localWidgetSelected ?? widget.selected)) { + if (localWidgetSelected ?? widget.selected!) { + _controller.forward(); + } else { + _controller.reverse(); + } } } } + super.didUpdateWidget(oldWidget); } @override void dispose() { - if (_controller != null) { + if (selectable) _controller.dispose(); - } super.dispose(); } @@ -146,8 +269,8 @@ abstract class SelectableState extends State with /// The `animation` is the bare, without any applied curves. /// /// Override this method to build your own custom animation. - Animation buildAnimation(Animation animation) { - return Tween( + Animation buildAnimation(Animation animation) { + return Tween( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( @@ -157,21 +280,15 @@ abstract class SelectableState extends State with )); } - /// Returns a aethod that either joins the selection, or closes it, dependent + /// Returns a method that either joins the selection, or closes it, dependent /// on current state of [selected]. /// /// Will return null if not [selectable], because it is common to pass it to [ListTile.onLongPress], /// and passing null will disable the long press gesture. - VoidCallback get toggleSelection => !selectable ? null : () { + VoidCallback? get handleLongPress => !selectable || selectionRouteOf(context) || !widget.longPressSelectionGestureEnabled! ? null : () { if (!selectable) return; - setState(() { - _selected = !_selected; - }); - if (_selected) - _select(); - else - _unselect(); + toggleSelection(); }; /// Checks whether widget is selectable and the selection controller @@ -180,40 +297,69 @@ abstract class SelectableState extends State with /// If yes, on taps will be handled by calling [toggleSelection], /// otherwise calls the [onTap] callback. void handleTap(VoidCallback onTap) { - if (selectable && widget.selectionController.inSelection) { + if (selectable && !selectionRouteOf(context) && widget.selectionController!.inSelection && widget.handleTapInSelection!) { toggleSelection(); } else { onTap(); } } - void _select() { - widget.selectionController.selectItem(widget.toSelectionEntry()); + void toggleSelection() { + setState(() { + if (selected) { + unselect(); + } else { + select(); + } + }); + } + + void select() { + widget.selectionController!.selectItem(toSelectionEntry()); _controller.forward(); } - void _unselect() { - widget.selectionController.unselectItem(widget.toSelectionEntry()); + void unselect() { + widget.selectionController!.unselectItem(toSelectionEntry()); _controller.reverse(); } } /// Signature, used for [ContentSelectionController.actionsBuilder]. -typedef _ActionsBuilder = SelectionActionsBar Function(BuildContext); - -class ContentSelectionController extends SelectionController { - ContentSelectionController({ - @required AnimationController animationController, - @required this.actionsBuilder, +typedef _ActionsBuilder = _SelectionActionsBar Function(BuildContext); + +class ContentSelectionController extends SelectionController with RouteAware { + ContentSelectionController._({ + required AnimationController animationController, + required this.context, + required this.actionsBuilder, + this.overlay, this.ignoreWhen, - Set data, + Set? data, }) : super( animationController: animationController, data: data, ); + ContentSelectionController._alwaysInSelection({ + required this.context, + required this.actionsBuilder, + this.overlay, + Set? data, + }) : ignoreWhen = null, + super.alwaysInSelection(data: data); + + + /// Needed to listen listen to a [DismissibleRoute], and as soon as it's + /// dismissed, the selection will be closed. + final BuildContext context; + /// Will build selection controls overlay widget. - final _ActionsBuilder actionsBuilder; + final _ActionsBuilder? actionsBuilder; + + /// An overlay to use. By default the one provided by [HomeState.overlayKey] + /// is used. + OverlayState? overlay; /// Before entering selection, controller will check this getter, and if it /// returns `true`, selection will be cancelled out. @@ -221,116 +367,200 @@ class ContentSelectionController extends SelectionCont /// This is needed, beucase in lists I allow multiple gestures at once, and if user holds one finger /// and then taps tile with another finger, this will cause the selection menu to /// be displayed over player route, which is not wanted. - final ValueGetter ignoreWhen; + final ValueGetter? ignoreWhen; + + /// Notifies about changes of [primaryContentType]. + ValueListenable get onContentTypeChange => _primaryContentTypeNotifier; + final ValueNotifier _primaryContentTypeNotifier = ValueNotifier(null); + + /// Current primary content type for when `T` is [Content], and not + /// specific subclass of it, like [Song]. It is an error to set this + /// value when `T` not [Content]. + /// + /// When [Content] is being used in this way, that means that multiple + /// types of content can be selected, and in some cases, there can be + /// a situation some of the content type is primarily shown to user + /// (and standalone, without other content types). + /// + /// For example in tabs route the that denotes the currently selected tab, + /// or in search route that denotes currently filtered content type. + Type? get primaryContentType => _primaryContentTypeNotifier.value; + set primaryContentType(Type? value) { + assert(T != Content, 'T must be a subclass of Content'); + _primaryContentTypeNotifier.value = value; + } /// Constucts a controller for particular `T` [Content] type. /// /// Generally, it's recommended to pass navigator state to [vsync], so controller can /// safely make deferred disposal. /// - /// If [counter] is `true`, will show a couter in the title. - /// - /// If [closeButton] is `true`, will show a selection close button in the title. + /// By default controller automatically creates a selection actions bar overlay. + /// The [actionsBar] can be set to `false` to disable this behavior. + /// + /// By default actions bar is filled with a few default actions, + /// which should always be visible. The [additionalPlayActionsBuilder] parameter + /// allows to add additional actions just before the "play next" and "add to queue" actions. + /// + /// If [counter] is `true`, will show a couter in the actions bar title. + /// + /// If [closeButton] is `true`, will show a selection close button in the actions bar. + /// + /// For other parameters, see the class properties. @factory - static ContentSelectionController forContent( - TickerProvider vsync, { - ValueGetter ignoreWhen, + static ContentSelectionController> create({ + required TickerProvider vsync, + required BuildContext context, + bool actionsBar = true, + List Function(BuildContext)? additionalPlayActionsBuilder, bool counter = false, bool closeButton = false, + ValueGetter? ignoreWhen, }) { - final actionsBuilder = contentPick( - song: (context) { - final controller = ContentSelectionController.of(context); - return SelectionActionsBar( - controller: controller, - left: [ActionsSelectionTitle( + return ContentSelectionController>._( + context: context, + ignoreWhen: ignoreWhen, + animationController: AnimationController( + vsync: vsync, + duration: kSelectionDuration, + ), + actionsBuilder: !actionsBar ? null : (context) { + return _SelectionActionsBar( + left: [_ActionsSelectionTitle( + selectedTitle: false, counter: counter, closeButton: closeButton, )], - right: const [ - GoToAlbumSelectionAction(), - PlayNextSelectionAction(), - AddToQueueSelectionAction(), - ], + right: _getActions(additionalPlayActionsBuilder?.call(context) ?? const [])(), ); }, - album: (context) { - final controller = ContentSelectionController.of(context); - return SelectionActionsBar( - controller: controller, - left: [ActionsSelectionTitle( - counter: counter, - closeButton: closeButton, + ); + } + + /// Creats content [SelectionController.alwaysInSelection], with immutable, always in selection state + /// for particular `T` [Content] type. + /// + /// Call [activate] on then to create the selection bar overlay. + /// + /// If there's a need in custom [overlay], usually it's it might be not available + /// at the time of controller creation. In this case you can delay the activation + /// until the next frame. + /// + /// Will show counter in the actions bar. + /// + /// The [actions] can be used to display custom actions at the right side + /// of the actions bar. + @factory + static ContentSelectionController> createAlwaysInSelection({ + required BuildContext context, + OverlayState? overlay, + List Function(BuildContext)? actionsBuilder, + }) { + return ContentSelectionController>._alwaysInSelection( + context: context, + overlay: overlay, + actionsBuilder: (context) { + return _SelectionActionsBar( + left: const [_ActionsSelectionTitle( + counter: true, )], - right: const [ - PlayNextSelectionAction(), - AddToQueueSelectionAction(), - ], + right: actionsBuilder?.call(context) ?? const [], ); }, - fallback: (context) { - final controller = ContentSelectionController.of(context); - return SelectionActionsBar( - controller: controller, - left: [ActionsSelectionTitle( - counter: counter, - closeButton: closeButton, - )], - right: const [ - GoToAlbumSelectionAction(), - PlayNextSelectionAction(), - AddToQueueSelectionAction(), - ], - ); - } ); - return ContentSelectionController>( - actionsBuilder: actionsBuilder, - ignoreWhen: ignoreWhen, - animationController: AnimationController( - vsync: vsync, - duration: kSelectionDuration, - ), + } + + static ValueGetter> _getActions(List additionalPlayActions) { + const commonActions = [ + _PlayAsQueueSelectionAction(), + _ShuffleAsQueueSelectionAction(), + _AddToPlaylistSelectionAction(), + ]; + return contentPick>>( + song: () => commonActions + [ + const _GoToArtistSelectionAction(), + const _GoToAlbumSelectionAction(), + ...additionalPlayActions, + const _PlayNextSelectionAction(), + const _AddToQueueSelectionAction(), + ], + album: () => commonActions + [ + ...additionalPlayActions, + const _PlayNextSelectionAction(), + const _AddToQueueSelectionAction(), + ], + playlist: () => commonActions + [ + const _EditPlaylistSelectionAction(), + ...additionalPlayActions, + const _PlayNextSelectionAction(), + const _AddToQueueSelectionAction(), + ], + artist: () => commonActions + [ + ...additionalPlayActions, + const _PlayNextSelectionAction(), + const _AddToQueueSelectionAction(), + ], + fallback: () => commonActions + [ + const _GoToArtistSelectionAction(), + const _GoToAlbumSelectionAction(), + const _EditPlaylistSelectionAction(), + ...additionalPlayActions, + const _PlayNextSelectionAction(), + const _AddToQueueSelectionAction(), + ], ); } - static ContentSelectionController of(BuildContext context) { - final widget = context.getElementForInheritedWidgetOfExactType<_ContentSelectionControllerProvider>() + static ContentSelectionController _of(BuildContext context) { + final widget = context.getElementForInheritedWidgetOfExactType<_ContentSelectionControllerProvider>()! .widget as _ContentSelectionControllerProvider; return widget.controller; } - Color _lastNavColor; - OverlayEntry _overlayEntry; - ValueNotifier get _notifier => ContentControl.state.selectionNotifier; + /// Returns true when data, or song origins inside it, have at least one song, + /// in other words, if [ContentUtils.flatten] for this controller would return + /// non-empty array. + bool get hasAtLeastOneSong => data.any((el) => el is! SelectionEntry || el.data.songIds.isNotEmpty); + Color? _lastNavColor; + OverlayEntry? _overlayEntry; + SlidableController? _dismissibleRouteController; + ValueNotifier get _notifier => ContentControl.state.selectionNotifier; - @override - void notifyStatusListeners(AnimationStatus status) { - if (status == AnimationStatus.forward) { - if (ignoreWhen?.call() ?? false) { - close(); - return; - } - if (_notifier.value != null && _notifier.value != this) { - /// Close previous selection. - _notifier.value.close(); - assert(false, 'There can only be one active controller'); - } + /// Creates the actions bar overlay. + void activate() { + if (ignoreWhen?.call() ?? false) { + close(); + return; + } + if (_notifier.value != null && _notifier.value != this) { + // Close previous selection. + _notifier.value!.close(); + assert(false, 'There can only be one active controller'); + } + for (final observer in NFWidgets.routeObservers!) + observer.subscribe(this, ModalRoute.of(context)!); + _dismissibleRouteController = DismissibleRoute.controllerOf(context); + _dismissibleRouteController?.addDragEventListener(_handleDismissibleRouteDrag); + + if (actionsBuilder != null) { _overlayEntry = OverlayEntry( - builder: (context) => _ContentSelectionControllerProvider( - controller: this, - child: Builder( - builder: (_context) => Builder( - builder: (_context) => actionsBuilder(_context), - ), + builder: (context) => RepaintBoundary( + child: _ContentSelectionControllerProvider( + controller: this, + child: Builder( + builder: (_context) => Builder( + builder: (_context) => actionsBuilder!(_context), + ), + ), ), ), ); - HomeState.overlayKey.currentState.insert(_overlayEntry); - _notifier.value = this; - /// Animate system UI. + final localOverlay = overlay ?? HomeState.overlayKey.currentState!; + localOverlay.insert(_overlayEntry!); + + // Animate system UI final lastUi = SystemUiStyleController.lastUi; _lastNavColor = lastUi.systemNavigationBarColor; SystemUiStyleController.animateSystemUiOverlay( @@ -338,11 +568,21 @@ class ContentSelectionController extends SelectionCont systemNavigationBarColor: Constants.UiTheme.grey.auto.systemNavigationBarColor ), duration: kSelectionDuration, - curve: SelectionActionsBar.forwardCurve, + curve: _SelectionActionsBar.forwardCurve, ); + } + + _notifier.value = this; + } + + @override + void notifyStatusListeners(AnimationStatus status) { + if (status == AnimationStatus.forward) { + activate(); } else if (status == AnimationStatus.reverse) { - if (!ContentControl.disposed) + if (!ContentControl.disposed) { _notifier.value = null; + } _animateNavBack(); } else if (status == AnimationStatus.dismissed) { _removeOverlay(); @@ -350,6 +590,20 @@ class ContentSelectionController extends SelectionCont super.notifyStatusListeners(status); } + @override + void didPop() { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + // This might be called during the build which will cause a crash + close(); + }); + } + + void _handleDismissibleRouteDrag(SlidableDragEvent event) { + if (event is SlidableDragEnd && event.closing) { + close(); + } + } + void _animateNavBack() { if (_lastNavColor == null) return; @@ -358,7 +612,7 @@ class ContentSelectionController extends SelectionCont systemNavigationBarColor: _lastNavColor, ), duration: kSelectionDuration, - curve: SelectionActionsBar.reverseCurve.flipped, + curve: _SelectionActionsBar.reverseCurve.flipped, ); _lastNavColor = null; } @@ -366,21 +620,28 @@ class ContentSelectionController extends SelectionCont void _removeOverlay() { if (_overlayEntry != null) { _animateNavBack(); - _overlayEntry.remove(); + _overlayEntry!.remove(); _overlayEntry = null; } } @override void dispose() { + _dismissibleRouteController?.removeDragEventListener(_handleDismissibleRouteDrag); + _dismissibleRouteController = null; + for (final observer in NFWidgets.routeObservers!) + observer.unsubscribe(this); + if (ContentControl.disposed) { _removeOverlay(); + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) async { + _primaryContentTypeNotifier.dispose(); + }); super.dispose(); } else { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) async { _notifier.value = null; - clearListeners(); - clearStatusListeners(); + _primaryContentTypeNotifier.dispose(); if (inSelection) await close(); _removeOverlay(); @@ -392,8 +653,8 @@ class ContentSelectionController extends SelectionCont class _ContentSelectionControllerProvider extends InheritedWidget { _ContentSelectionControllerProvider({ - @required Widget child, - @required this.controller, + required Widget child, + required this.controller, }) : super(child: child); final ContentSelectionController controller; @@ -402,14 +663,60 @@ class _ContentSelectionControllerProvider extends InheritedWidget { bool updateShouldNotify(covariant InheritedWidget oldWidget) => false; } +/// Creats a selection controller and automatically rebuilds, when it updates. +class ContentSelectionControllerCreator extends StatefulWidget { + ContentSelectionControllerCreator({ + Key? key, + required this.builder, + this.child, + }) : super(key: key); + + final Widget Function(BuildContext context, ContentSelectionController selectionController, Widget? child) builder; + final Widget? child; + + @override + _SelectionControllerCreatorState createState() => _SelectionControllerCreatorState(); +} + +class _SelectionControllerCreatorState extends State> + with SelectionHandlerMixin { + + @override + void initState() { + super.initState(); + initSelectionController(() => ContentSelectionController.create( + vsync: AppRouter.instance.navigatorKey.currentState!, + context: context, + closeButton: true, + counter: true, + ignoreWhen: () => playerRouteController.opened, + )); + } + + @override + void dispose() { + disposeSelectionController(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, selectionController, widget.child); + } +} + class SelectionCheckmark extends StatefulWidget { const SelectionCheckmark({ - Key key, - @required this.animation, + Key? key, + required this.animation, + this.ignorePointer = true, + this.scaleAnimation = true, this.size = 21.0, }) : super(key: key); - final Animation animation; + final Animation animation; + final bool ignorePointer; + final bool scaleAnimation; final double size; @override @@ -422,27 +729,43 @@ class _SelectionCheckmarkState extends State { @override void initState() { super.initState(); + _update(); widget.animation.addStatusListener(_handleStatusChange); } + @override + void didUpdateWidget(covariant SelectionCheckmark oldWidget) { + if (oldWidget.animation != widget.animation) { + oldWidget.animation.removeStatusListener(_handleStatusChange); + widget.animation.addStatusListener(_handleStatusChange); + _update(); + } + super.didUpdateWidget(oldWidget); + } + @override void dispose() { widget.animation.removeStatusListener(_handleStatusChange); super.dispose(); } - void _handleStatusChange(AnimationStatus status) { - if (status == AnimationStatus.forward) { + void _update() { + if (widget.animation.status == AnimationStatus.forward) { _flareAnimation = 'play'; } } + void _handleStatusChange(AnimationStatus status) { + _update(); + } + @override Widget build(BuildContext context) { return IgnorePointer( + ignoring: widget.ignorePointer, child: AnimatedBuilder( animation: widget.animation, - builder: (context, child) => ScaleTransition( + builder: (context, child) => !widget.scaleAnimation ? child! : ScaleTransition( scale: widget.animation, child: child, ), @@ -472,39 +795,38 @@ class _SelectionCheckmarkState extends State { /// Ignores its subtree when selection controller is in selection. class IgnoreInSelection extends StatelessWidget { const IgnoreInSelection({ - Key key, - @required this.controller, + Key? key, + required this.controller, this.child, }) : super(key: key); - final Widget child; + final Widget? child; final SelectionController controller; @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: controller.animationController, + return AnimationStrategyBuilder( + strategy: const IgnoringStrategy( + forward: true, + completed: true, + ), + animation: controller.animation, child: child, - builder: (context, child) => IgnorePointer( - ignoring: const IgnoringStrategy( - forward: true, - completed: true, - ).evaluate(controller.animationController), + builder: (context, value, child) => IgnorePointer( + ignoring: value, child: child ), ); } } -class SelectionActionsBar extends StatelessWidget { - const SelectionActionsBar({ - Key key, - @required this.controller, +class _SelectionActionsBar extends StatelessWidget { + const _SelectionActionsBar({ + Key? key, this.left = const [], this.right = const [], }) : super(key: key); - final SelectionController controller; final List left; final List right; @@ -521,42 +843,55 @@ class SelectionActionsBar extends StatelessWidget { @override Widget build(BuildContext context) { - final selectionAnimation = controller.animationController; + final selectionAnimation = ContentSelectionController._of(context).animation; final fadeAnimation = CurvedAnimation( curve: forwardCurve, reverseCurve: reverseCurve, parent: selectionAnimation, ); + final rightList = right.reversed.toList(); return Align( alignment: Alignment.bottomCenter, - child: AnimatedBuilder( + child: AnimationStrategyBuilder( + strategy: const IgnoringStrategy( + reverse: true, + dismissed: true, + ), animation: selectionAnimation, - builder: (context, child) => FadeTransition( - opacity: fadeAnimation, - child: IgnorePointer( - ignoring: const IgnoringStrategy( - reverse: true, - dismissed: true, - ).evaluate(selectionAnimation), - child: child, - ), + builder: (context, value, child) => IgnorePointer( + ignoring: value, + child: child, ), - child: Container( - height: kSongTileHeight, - color: ThemeControl.theme.colorScheme.secondary, - padding: const EdgeInsets.only(bottom: 6.0), - child: Material( - color: Colors.transparent, - child: ListTile( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row(children: left), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: right, - ), - ], + child: FadeTransition( + opacity: fadeAnimation, + child: Container( + height: kSongTileHeight, + color: ThemeControl.theme.colorScheme.secondary, + padding: const EdgeInsets.only(bottom: 6.0), + child: Material( + color: Colors.transparent, + child: ListTile( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: left), + const SizedBox(width: 10.0, height: double.infinity), + Expanded( + child: Material( + color: Colors.transparent, + child: ScrollConfiguration( + behavior: const GlowlessScrollBehavior(), + child: ListView.builder( + scrollDirection: Axis.horizontal, + reverse: true, + itemCount: rightList.length, + itemBuilder: (context, index) => Center(child: rightList[index]), + ), + ), + ), + ), + ], + ), ), ), ), @@ -566,18 +901,23 @@ class SelectionActionsBar extends StatelessWidget { } } -class _SelectionAnimation extends AnimatedWidget { - _SelectionAnimation({ - @required Animation animation, - @required this.child, +/// Animation that emerges the item, by default from left to right. +class EmergeAnimation extends AnimatedWidget { + const EmergeAnimation({ + Key? key, + required Animation animation, + required this.child, this.begin = const Offset(-1.0, 0.0), this.end = Offset.zero, - }) : super(listenable: animation); + }) : super(key: key, listenable: animation); final Widget child; final Offset begin; final Offset end; + @override + Animation get listenable => super.listenable as Animation; + @override Widget build(BuildContext context) { final animation = Tween( @@ -589,70 +929,171 @@ class _SelectionAnimation extends AnimatedWidget { reverseCurve: Curves.easeInCubic, )); return ClipRect( - child: AnimatedBuilder( + child: AnimationStrategyBuilder( + strategy: const IgnoringStrategy( + dismissed: true, + reverse: true, + ), animation: listenable, - child: child, - builder: (context, child) => IgnorePointer( - ignoring: const IgnoringStrategy( - dismissed: true, - reverse: true, - ).evaluate(listenable), - child: SlideTransition( - position: animation, + child: SlideTransition( + position: animation, + child: RepaintBoundary( child: child, ), ), + builder: (context, value, child) => IgnorePointer( + ignoring: value, + child: child, + ), + ), + ); + } +} + +/// Checks whether the action is supported and hides it, if it's not. +/// +/// Calls the build again, when selection is updated, excluding the +/// selection closing. +class _ActionBuilder extends StatefulWidget { + _ActionBuilder({ + Key? key, + required this.controller, + required this.builder, + required this.shown, + this.child, + }) : super(key: key); + + final ContentSelectionController controller; + + final TransitionBuilder builder; + + final Widget? child; + + /// Condition to check whether the action should be shown or not. + final ValueGetter shown; + + @override + _ActionBuilderState createState() => _ActionBuilderState(); +} + +class _ActionBuilderState extends State<_ActionBuilder> with SelectionHandlerMixin { + UniqueKey key = UniqueKey(); + bool shown = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(handleSelection); + } + + @override + void didUpdateWidget(covariant _ActionBuilder oldWidget) { + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(handleSelection); + widget.controller.addListener(handleSelection); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + widget.controller.removeListener(handleSelection); + super.dispose(); + } + + @override + void handleSelection() { + if (widget.controller.closeSelectionWhenEmpty && widget.controller.data.length == 1 && widget.controller.lengthIncreased) { + // Prevents animation we enter the selection by updating the key + key = UniqueKey(); + } + super.handleSelection(); + } + + @override + Widget build(BuildContext context) { + if (widget.controller.inSelection) { + /// The condition ensures animation will not play on close, because [SelectionAppBar] + /// has its own animation, and combination of them both doesn't look good. + shown = widget.shown.call(); + } + return AnimatedSwitcher( + key: key, + duration: kSelectionDuration, + transitionBuilder: (child, animation) => EmergeAnimation( + animation: animation, + child: child, ), + child: !shown + ? const SizedBox.shrink() + : widget.builder(context, widget.child), ); } } /// Creates a selection title. -class ActionsSelectionTitle extends StatelessWidget { - const ActionsSelectionTitle({ - Key key, +class _ActionsSelectionTitle extends StatelessWidget { + const _ActionsSelectionTitle({ + Key? key, this.counter = false, + this.selectedTitle = true, this.closeButton = false, }) : super(key: key); - /// If true, in place of "Actions" label, [SelectionCounter] will be shown. - final bool counter; - /// If true, will show a selection close button. final bool closeButton; + /// If true, before the counter the "Selected" word will be shown. + final bool selectedTitle; + + /// If true, in place of "Actions" label, [SelectionCounter] will be shown. + final bool counter; + @override Widget build(BuildContext context) { final l10n = getl10n(context); - final controller = ContentSelectionController.of(context); + final controller = ContentSelectionController._of(context); return Row( children: [ if (closeButton) - _SelectionAnimation( - animation: controller.animationController, + EmergeAnimation( + animation: controller.animation, child: NFIconButton( size: NFConstants.iconButtonSize, iconSize: NFConstants.iconSize, color: ThemeControl.theme.colorScheme.onSurface, onPressed: () => controller.close(), - icon: const Icon(Icons.close), + icon: const Icon(Icons.close_rounded), ), ), - Padding( - padding: EdgeInsets.only( - left: counter ? 10.0 : 8.0, - bottom: 2.0 + if (selectedTitle && counter) + Padding( + padding: EdgeInsets.only(bottom: 2.0, left: closeButton ? 12.0 : 30.0), + child: EmergeAnimation( + animation: controller.animation, + child: Text( + l10n.selected, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 17.0), + ), + ), ), - child: _SelectionAnimation( - animation: controller.animationController, - child: !counter - ? Text(l10n.actions) - : const Padding( - padding: EdgeInsets.only(top: 2.0), - child: SelectionCounter() - ), + if (counter) + Padding( + padding: EdgeInsets.only( + left: counter ? 10.0 : 8.0, + bottom: 2.0 + ), + child: EmergeAnimation( + animation: controller.animation, + child: Padding( + padding: EdgeInsets.only(left: selectedTitle ? 0.0 : closeButton ? 5.0 : 10.0), + child: const SelectionCounter(textStyle: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 19.0, + )) + ), + ), ), - ), ], ); } @@ -660,25 +1101,37 @@ class ActionsSelectionTitle extends StatelessWidget { /// Creates a counter that shows how many items are selected. class SelectionCounter extends StatefulWidget { - const SelectionCounter({Key key, this.controller}) : super(key: key); + const SelectionCounter({ + Key? key, + this.textStyle, + this.controller, + }) : super(key: key); - /// Selection controller, if none specified, will try to fetch it from context. - final ContentSelectionController controller; + /// Text style of the counter. + /// + /// By default [appBarTitleTextStyle] is used. + final TextStyle? textStyle; + + /// Selection controller, if none specified, will try to fetch it from context. + final ContentSelectionController? controller; @override _SelectionCounterState createState() => _SelectionCounterState(); } -class _SelectionCounterState extends State with SelectionHandler { - ContentSelectionController controller; - int selectionCount; +class _SelectionCounterState extends State with SelectionHandlerMixin { + late ContentSelectionController controller; + late int selectionCount; + UniqueKey key = UniqueKey(); + + int get minCount => controller.closeSelectionWhenEmpty ? 1 : 0; @override void initState() { super.initState(); - controller = widget.controller ?? ContentSelectionController.of(context); + controller = widget.controller ?? ContentSelectionController._of(context); controller.addListener(handleSelection); - selectionCount = max(1, controller.data.length); + selectionCount = math.max(minCount, controller.data.length); } @override @@ -686,7 +1139,7 @@ class _SelectionCounterState extends State with SelectionHandl super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller?.removeListener(handleSelection); - controller = widget.controller ?? ContentSelectionController.of(context); + controller = widget.controller ?? ContentSelectionController._of(context); controller.addListener(handleSelection); } } @@ -697,108 +1150,45 @@ class _SelectionCounterState extends State with SelectionHandl super.dispose(); } + @override + void handleSelection() { + if (controller.closeSelectionWhenEmpty && controller.data.length == 1 && controller.lengthIncreased) { + // Prevents animation we enter the selection by updating the key + key = UniqueKey(); + } + super.handleSelection(); + } + @override Widget build(BuildContext context) { // This will prevent animation when controller is closing if (controller.inSelection) { // Not letting to go less 1 to not play animation from 1 to 0 - selectionCount = max(1, controller.data.length); + selectionCount = math.max(minCount, controller.data.length); } return CountSwitcher( - // Prevents animation we enter the selection - key: ValueKey(controller.status == AnimationStatus.dismissed), + key: key, childKey: ValueKey(selectionCount), valueIncreased: controller.lengthIncreased, child: Container( - /// Line up width with other actions, so they animate identically with [_SelectionAnimation] + /// Line up width with other actions, so they animate identically with [EmergeAnimation] constraints: const BoxConstraints(minWidth: NFConstants.iconButtonSize), - padding: const EdgeInsets.only(left: 5.0), child: Text( selectionCount.toString(), - style: appBarTitleTextStyle, + style: widget.textStyle ?? appBarTitleTextStyle, ), ), ); } } -/// Action that leads to the song album. -class GoToAlbumSelectionAction extends StatefulWidget { - const GoToAlbumSelectionAction({Key key}) : super(key: key); - - @override - _GoToAlbumSelectionActionState createState() => _GoToAlbumSelectionActionState(); -} - -class _GoToAlbumSelectionActionState extends State { - ContentSelectionController controller; - - @override - void initState() { - super.initState(); - controller = ContentSelectionController.of(context); - controller.addListener(_handleControllerChange); - controller.addStatusListener(_handleStatusControllerChange); - } - - @override - void dispose() { - controller.removeListener(_handleControllerChange); - controller.removeStatusListener(_handleStatusControllerChange); - super.dispose(); - } - - void _handleControllerChange() { - setState(() { - /* update ui to hide when data lenght is greater than 1 */ - }); - } - - void _handleStatusControllerChange(AnimationStatus status) { - setState(() { - /* update ui to hide when data lenght is greater than 1 */ - }); - } - - void _handleTap() { - final song = controller.data.first.data as Song; - final album = song.getAlbum(); - HomeRouter.instance.goto(HomeRoutes.factory.album(album)); - controller.close(); - } - - @override - Widget build(BuildContext context) { - final l10n = getl10n(context); - final data = controller.data; +//************** ACTIONS ************** - return AnimatedSwitcher( - duration: kSelectionDuration, - transitionBuilder: (child, animation) => _SelectionAnimation( - animation: animation, - child: child, - ), - child: - data.length > 1 || - data.length == 1 && (data.first.data is! Song || (data.first.data as Song).albumId == null) || - HomeRouter.instance.routes.last == HomeRoutes.album && playerRouteController.closed // disable action in album route - ? const SizedBox.shrink() - : _SelectionAnimation( - animation: controller.animationController, - child: NFIconButton( - tooltip: l10n.goToAlbum, - icon: const Icon(Icons.album_rounded), - iconSize: 23.0, - onPressed: _handleTap, - ), - ), - ); - } -} +//*********** Queue actions *********** -/// Action that queues a [Song] or [Album] to be played next. -class PlayNextSelectionAction extends StatelessWidget { - const PlayNextSelectionAction({Key key}) : super(key: key); +/// Action that queues a [Song] or a [SongOrigin] to be played next. +class _PlayNextSelectionAction extends StatelessWidget { + const _PlayNextSelectionAction({Key? key}) : super(key: key); void _handleSongs(List> entries) { if (entries.isEmpty) @@ -811,35 +1201,54 @@ class PlayNextSelectionAction extends StatelessWidget { ); } - void _handleAlbums(List> entries) { + void _handleOrigins(List> entries) { if (entries.isEmpty) return; // Reverse order is proper here entries.sort((a, b) => b.index.compareTo(a.index)); for (final entry in entries) { - ContentControl.playQueueNext(entry.data); + ContentControl.playOriginNext(entry.data); } } void _handleTap(ContentSelectionController controller) { contentPick( - song: () => _handleSongs(controller.data.toList()), - album: () => _handleAlbums(controller.data.toList()), + song: () => _handleSongs(controller.data.toList() as List>), + album: () => _handleOrigins(controller.data.toList() as List>), + playlist: () => _handleOrigins(controller.data.toList() as List>), + artist: () => _handleOrigins(controller.data.toList() as List>), fallback: () { - final entries = controller.data.toList(); + final List> entries = controller.data.toList(); final List> songs = []; final List> albums = []; + final List> playlists = []; + final List> artists = []; for (final entry in entries) { if (entry is SelectionEntry) { songs.add(entry); } else if (entry is SelectionEntry) { albums.add(entry); + } else if (entry is SelectionEntry) { + playlists.add(entry); + } else if (entry is SelectionEntry) { + artists.add(entry); } else { - throw ArgumentError('This action only supports Song and Album selection simultaniously'); + throw UnimplementedError(); } } + assert(() { + contentPick( + song: null, + album: null, + playlist: null, + artist: null, + ); + return true; + }()); _handleSongs(songs); - _handleAlbums(albums); + _handleOrigins(albums); + _handleOrigins(playlists); + _handleOrigins(artists); }, )(); controller.close(); @@ -848,22 +1257,27 @@ class PlayNextSelectionAction extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = getl10n(context); - final controller = ContentSelectionController.of(context); - return _SelectionAnimation( - animation: controller.animationController, - child: NFIconButton( - tooltip: l10n.playNext, - icon: const Icon(Icons.playlist_play_rounded), - iconSize: 30.0, - onPressed: () => _handleTap(controller), + final controller = ContentSelectionController._of(context); + return _ActionBuilder( + shown: () => true, + controller: controller, + builder: (context, child) => EmergeAnimation( + animation: controller.animation, + child: AnimatedIconButton( + tooltip: l10n.playNext, + duration: const Duration(milliseconds: 240), + icon: const Icon(SweyerIcons.play_next), + iconSize: 30.0, + onPressed: !controller.hasAtLeastOneSong ? null : () => _handleTap(controller), + ), ), ); } } -/// Action that adds a [Song] or an [Album] to the end of the queue. -class AddToQueueSelectionAction extends StatelessWidget { - const AddToQueueSelectionAction({Key key}) +/// Action that adds a [Song] or a [SongOrigin] to the end of the queue. +class _AddToQueueSelectionAction extends StatelessWidget { + const _AddToQueueSelectionAction({Key? key}) : super(key: key); void _handleSongs(List> entries) { @@ -877,34 +1291,53 @@ class AddToQueueSelectionAction extends StatelessWidget { ); } - void _handleAlbums(List> entries) { + void _handleOrigins(List> entries) { if (entries.isEmpty) return; entries.sort((a, b) => a.index.compareTo(b.index)); for (final entry in entries) { - ContentControl.addQueueToQueue(entry.data); + ContentControl.addOriginToQueue(entry.data); } } void _handleTap(ContentSelectionController controller) { contentPick( - song: () => _handleSongs(controller.data.toList()), - album: () => _handleAlbums(controller.data.toList()), + song: () => _handleSongs(controller.data.toList() as List>), + album: () => _handleOrigins(controller.data.toList() as List>), + playlist: () => _handleOrigins(controller.data.toList() as List>), + artist: () => _handleOrigins(controller.data.toList() as List>), fallback: () { - final entries = controller.data.toList(); + final List> entries = controller.data.toList(); final List> songs = []; final List> albums = []; + final List> playlists = []; + final List> artists = []; for (final entry in entries) { if (entry is SelectionEntry) { songs.add(entry); } else if (entry is SelectionEntry) { albums.add(entry); + } else if (entry is SelectionEntry) { + playlists.add(entry); + } else if (entry is SelectionEntry) { + artists.add(entry); } else { - throw ArgumentError('This action only supports Song and Album selection simultaniously'); + throw UnimplementedError(); } } + assert(() { + contentPick( + song: null, + album: null, + playlist: null, + artist: null, + ); + return true; + }()); _handleSongs(songs); - _handleAlbums(albums); + _handleOrigins(albums); + _handleOrigins(playlists); + _handleOrigins(artists); }, )(); controller.close(); @@ -913,20 +1346,387 @@ class AddToQueueSelectionAction extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = getl10n(context); - final controller = ContentSelectionController.of(context); - return _SelectionAnimation( - animation: controller.animationController, - child: NFIconButton( - tooltip: l10n.addToQueue, - icon: const Icon(Icons.queue_music_rounded), - iconSize: 30.0, - onPressed: () => _handleTap(controller), + final controller = ContentSelectionController._of(context); + return _ActionBuilder( + shown: () => true, + controller: controller, + builder: (context, child) => EmergeAnimation( + animation: controller.animation, + child: AnimatedIconButton( + tooltip: l10n.addToQueue, + duration: const Duration(milliseconds: 240), + icon: const Icon(SweyerIcons.add_to_queue), + iconSize: 30.0, + onPressed: !controller.hasAtLeastOneSong ? null : () => _handleTap(controller), + ), + ), + ); + } +} + +/// Action that plays selected content as queue. +class _PlayAsQueueSelectionAction extends StatelessWidget { + const _PlayAsQueueSelectionAction({Key? key}) + : super(key: key); + + void _handleTap(ContentSelectionController controller) { + final songs = ContentUtils.flatten(ContentUtils.selectionSortAndPack(controller.data).merged); + ContentControl.setQueue( + type: QueueType.arbitrary, + shuffled: false, + songs: songs, + ); + MusicPlayer.instance.setSong(songs.first); + MusicPlayer.instance.play(); + playerRouteController.open(); + controller.close(); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + final controller = ContentSelectionController._of(context); + return _ActionBuilder( + shown: () => true, + controller: controller, + builder: (context, child) => EmergeAnimation( + animation: controller.animation, + child: AnimatedIconButton( + tooltip: l10n.playContentList, + duration: const Duration(milliseconds: 240), + icon: const Icon(Icons.play_arrow_rounded), + iconSize: 27.0, + onPressed: !controller.hasAtLeastOneSong ? null : () => _handleTap(controller), + ), ), ); } } -//*********** Appbar actions *********** +/// Action that shulles and plays selected content as queue. +class _ShuffleAsQueueSelectionAction extends StatelessWidget { + const _ShuffleAsQueueSelectionAction({Key? key}) + : super(key: key); + + void _handleTap(ContentSelectionController controller) { + final songs = ContentUtils.flatten(ContentUtils.selectionSortAndPack(controller.data).merged); + ContentControl.setQueue( + type: QueueType.arbitrary, + shuffled: true, + shuffleFrom: songs, + ); + MusicPlayer.instance.setSong(ContentControl.state.queues.current.songs[0]); + MusicPlayer.instance.play(); + playerRouteController.open(); + controller.close(); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + final controller = ContentSelectionController._of(context); + return _ActionBuilder( + shown: () => true, + controller: controller, + builder: (context, child) => EmergeAnimation( + animation: controller.animation, + child: AnimatedIconButton( + tooltip: l10n.shuffleContentList, + duration: const Duration(milliseconds: 240), + icon: const Icon(Icons.shuffle_rounded), + iconSize: 22.0, + onPressed: !controller.hasAtLeastOneSong ? null : () => _handleTap(controller), + ), + ), + ); + } +} + +/// Action that removes a song from the queue. +class RemoveFromQueueSelectionAction extends StatelessWidget { + const RemoveFromQueueSelectionAction({Key? key}) : super(key: key); + + void _handleTap(ContentSelectionController> controller) { + for (final entry in controller.data) { + final removed = ContentControl.removeFromQueue(entry.data); + assert(removed); + } + controller.close(); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + final controller = ContentSelectionController._of(context) as ContentSelectionController>; + return _ActionBuilder( + controller: controller, + shown: () => true, + builder: (context, child) => child!, + child: EmergeAnimation( + animation: controller.animation, + child: NFIconButton( + tooltip: l10n.removeFromQueue, + icon: const Icon(Icons.remove_rounded), + onPressed: () => _handleTap(controller), + ), + ), + ); + } +} + +//*********** Navigation actions *********** + +/// Action that leads to the song album. +class _GoToAlbumSelectionAction extends StatelessWidget { + const _GoToAlbumSelectionAction({Key? key}) : super(key: key); + + void _handleTap(ContentSelectionController controller) { + final song = controller.data.first.data as Song; + final album = song.getAlbum(); + if (album != null) { + HomeRouter.instance.goto(HomeRoutes.factory.content(album)); + } + controller.close(); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + final controller = ContentSelectionController._of(context); + assert(controller is ContentSelectionController> || + controller is ContentSelectionController>); + final data = controller.data; + + return _ActionBuilder( + controller: controller, + shown: () { + return data.length == 1 && + data.first.data is Song && (data.first.data as Song).albumId != null && + (HomeRouter.instance.currentRoute.hasDifferentLocation(HomeRoutes.album) || playerRouteController.opened); // disable action in album route + }, + builder: (context, child) => child!, + child: EmergeAnimation( + animation: controller.animation, + child: NFIconButton( + tooltip: l10n.goToAlbum, + icon: const Icon(Album.icon), + iconSize: 23.0, + onPressed: () => _handleTap(controller), + ), + ), + ); + } +} + +/// Action that leads to the song or album artist. +class _GoToArtistSelectionAction extends StatelessWidget { + const _GoToArtistSelectionAction({Key? key}) : super(key: key); + + void _handleTap(ContentSelectionController controller) { + final content = controller.data.first.data; + if (content is Song) { + HomeRouter.instance.goto(HomeRoutes.factory.content(content.getArtist())); + } else if (content is Album) { + HomeRouter.instance.goto(HomeRoutes.factory.content(content.getArtist())); + } else { + throw UnimplementedError(); + } + controller.close(); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + final controller = ContentSelectionController._of(context); + assert(controller is ContentSelectionController> || + controller is ContentSelectionController> || + controller is ContentSelectionController>); + final data = controller.data; + + return _ActionBuilder( + controller: controller, + shown: () { + return data.length == 1 && (data.first.data is Song || data.first.data is Album) && + (HomeRouter.instance.currentRoute.hasDifferentLocation(HomeRoutes.artist) || playerRouteController.opened); // disable action in album route + }, + builder: (context, child) => child!, + child: EmergeAnimation( + animation: controller.animation, + child: NFIconButton( + tooltip: l10n.goToArtist, + icon: const Icon(Artist.icon), + iconSize: 23.0, + onPressed: () => _handleTap(controller), + ), + ), + ); + } +} + +/// Action that opens a playlist edit mode. +class _EditPlaylistSelectionAction extends StatelessWidget { + const _EditPlaylistSelectionAction({Key? key}) : super(key: key); + + void _handleTap(ContentSelectionController controller) { + final playlist = controller.data.first.data as Playlist; + HomeRouter.instance.goto(HomeRoutes.playlist.withArguments(PersistentQueueArguments(queue: playlist, editing: true))); + controller.close(); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + final controller = ContentSelectionController._of(context); + assert(controller is ContentSelectionController> || + controller is ContentSelectionController>); + final data = controller.data; + + return _ActionBuilder( + controller: controller, + shown: () { + return data.length == 1 && data.first.data is Playlist; // disable action in album route + }, + builder: (context, child) => child!, + child: EmergeAnimation( + animation: controller.animation, + child: NFIconButton( + tooltip: "${l10n.edit} ${l10n.playlist.toLowerCase()}", + icon: const Icon(Icons.edit_rounded, size: 21.0), + // iconSize: 23.0, + onPressed: () => _handleTap(controller), + ), + ), + ); + } +} + +//*********** Content related actions *********** + +/// Action that adds songs to playlist(s). +/// All types of contents are supported. +class _AddToPlaylistSelectionAction extends StatelessWidget { + const _AddToPlaylistSelectionAction({Key? key}) : super(key: key); + + void _handleTap(BuildContext context, ContentSelectionController controller) { + ShowFunctions.instance.showDialog( + context, + ui: Constants.UiTheme.modalOverGrey.auto, + title: Builder( + builder: (context) { + final l10n = getl10n(context); + return Text(l10n.addToPlaylist); + }, + ), + contentPadding: const EdgeInsets.only(top: 14.0, bottom: 0.0), + content: StreamBuilder( + stream: ContentControl.state.onContentChange, + builder: (context, snapshot) { + final playlists = ContentControl.state.playlists; + final screenSize = MediaQuery.of(context).size; + return SizedBox( + height: kSongTileHeight + playlists.length * kPersistentQueueTileHeight, + width: screenSize.width, + child: ScrollConfiguration( + behavior: const GlowlessScrollBehavior(), + child: ContentListView( + list: playlists, + leading: const CreatePlaylistInListAction(), + enableDefaultOnTap: false, + onItemTap: (index) { + final playlist = playlists[index]; + ContentControl.insertSongsInPlaylist( + index: playlist.length + 1, + songs: ContentUtils.flatten(ContentUtils.selectionSortAndPack(controller.data).merged), + playlist: playlist, + ); + Navigator.pop(context); + controller.close(); + }, + ), + ), + ); + }, + ), + buttonSplashColor: Constants.Theme.glowSplashColor.auto, + acceptButton: const SizedBox(), + // acceptButton: Builder( + // builder: (context) => NFButton.accept( + // text: getl10n(context).add, + // splashColor: Constants.Theme.glowSplashColor.auto, + // onPressed: () { + // // onSubmit(); + // controller.close(); + // }, + // ), + // ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + final controller = ContentSelectionController._of(context); + return _ActionBuilder( + controller: controller, + shown: () => true, + builder: (context, child) => EmergeAnimation( + animation: controller.animation, + child: NFIconButton( + tooltip: l10n.addToPlaylist, + icon: const Icon(Icons.playlist_add_rounded), + onPressed: !controller.hasAtLeastOneSong ? null : () => _handleTap(context, controller), + ), + ), + ); + } +} + +/// Action that removes a song from playlist. +class RemoveFromPlaylistSelectionAction extends StatelessWidget { + const RemoveFromPlaylistSelectionAction({ + Key? key, + required this.controller, + required this.playlist, + }) : super(key: key); + + final ContentSelectionController controller; + final Playlist playlist; + + void _handleTap(BuildContext context) { + final entries = controller.data + .cast>() + .toList() + ..sort((a, b) => a.index.compareTo(b.index)); + final list = entries.map((el) => el.data).toList(); + _showActionConfirmationDialog( + context: context, + controller: controller, + list: list, + localizedAction: (l10n) => l10n.remove, + onSubmit: () { + ContentControl.removeFromPlaylistAt( + indexes: controller.data.map((el) => el.index).toList(), + playlist: playlist, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + return _ActionBuilder( + controller: controller, + shown: () => true, + builder: (context, child) => child!, + child: NFIconButton( + tooltip: l10n.removeFromPlaylist, + icon: const Icon(Icons.delete_outline_rounded), + onPressed: () => _handleTap(context), + ), + ); + } +} /// Displays an action to delete songs. /// Only meant to be displayed in app bar. @@ -935,8 +1735,8 @@ class AddToQueueSelectionAction extends StatelessWidget { /// With the lattter, will automatically check if selection contains only songs and hide the button, if not. class DeleteSongsAppBarAction extends StatefulWidget { const DeleteSongsAppBarAction({ - Key key, - @required this.controller + Key? key, + required this.controller }) : super(key: key); final ContentSelectionController> controller; @@ -945,98 +1745,468 @@ class DeleteSongsAppBarAction extends StatefulWidget { _DeleteSongsAppBarActionState createState() => _DeleteSongsAppBarActionState(); } -class _DeleteSongsAppBarActionState extends State> with SelectionHandler { - Type type; - bool shown = false; +class _DeleteSongsAppBarActionState extends State> with SelectionHandlerMixin { + late Type type; + late Type typeToDelete; @override void initState() { super.initState(); type = typeOf(); - assert(type == Song || type == Content, 'Only Song and Content types are supported'); - widget.controller.addListener(handleSelection); - widget.controller.addStatusListener(handleSelectionStatus); + assert(type == Song || type == Playlist || type == Content, 'Only Song, Playlist and Content types are supported'); + } + + Future _handleDelete() async { + assert(typeToDelete == Song || typeToDelete == Playlist); + if (typeToDelete == Song) { + final entries = widget.controller.data + .cast>() + .toList() + ..sort((a, b) => a.index.compareTo(b.index)); + if (ContentControl.sdkInt >= 30) { + // On Android R the deletion is performed with OS dialog. + await ContentControl.deleteSongs(entries.map((e) => e.data).toSet()); + widget.controller.close(); + } else { + // On all versions below show in app dialog. + final list = entries.map((el) => el.data).toList(); + _showActionConfirmationDialog( + context: context, + controller: widget.controller, + list: list, + localizedAction: (l10n) => l10n.delete, + onSubmit: () { + ContentControl.deleteSongs(entries.map((e) => e.data).toSet()); + }, + ); + } + } else if (typeToDelete == Playlist) { + final entries = widget.controller.data + .cast>() + .toList() + ..sort((a, b) => a.index.compareTo(b.index)); + final list = entries.map((el) => el.data).toList(); + _showActionConfirmationDialog( + context: context, + controller: widget.controller, + list: list, + localizedAction: (l10n) => l10n.delete, + onSubmit: () { + ContentControl.deletePlaylists(list); + }, + ); + } } @override - void dispose() { - widget.controller.removeListener(handleSelection); - widget.controller.removeStatusListener(handleSelectionStatus); - super.dispose(); + Widget build(BuildContext context) { + return _ActionBuilder( + controller: widget.controller, + shown: () { + if (type == Song || type == Playlist) { + typeToDelete = type; + return true; + } + if (type == Content) { + SelectionEntry? initEntry; + for (final entry in widget.controller.data) { + if (entry is! SelectionEntry && entry is! SelectionEntry) { + return false; + } + if (initEntry == null) { + initEntry = entry; + typeToDelete = entry.data.runtimeType; + } else { + // When types are mixed, like selection contains both songs and playlists + if (typeToDelete != entry.data.runtimeType) { + return false; + } + } + } + return true; + } + return false; + }, + builder: (context, child) => child!, + child: NFIconButton( + icon: const Icon(Icons.delete_outline_rounded), + onPressed: _handleDelete, + ), + ); } +} - Future _handleDelete() async { - final songs = widget.controller.data.cast>(); - if (ContentControl.sdkInt >= 30) { - // On Android R the deletion is performed with OS dialog. - await ContentControl.deleteSongs(songs.map((e) => e.data.sourceId).toSet()); - widget.controller.close(); - } else { - // On all versions below show in app dialog. - final l10n = getl10n(context); - final count = songs.length; - Song song; - if (count == 1) { - song = ContentControl.state.allSongs.byId.get(songs.first.data.sourceId); - } - ShowFunctions.instance.showDialog( - context, - title: Text( - '${l10n.delete} ${count > 1 ? count.toString() + ' ' : ''}${l10n.tracksPlural(count).toLowerCase()}', - ), - content: Text.rich( - TextSpan( - style: const TextStyle(fontSize: 15.0), +void _showActionConfirmationDialog({ + required BuildContext context, + required ContentSelectionController controller, + required List list, + required VoidCallback onSubmit, + required String Function(AppLocalizations) localizedAction, +}) { + final count = list.length; + E? entry; + if (count == 1) { + entry = list.first; + } + + ShowFunctions.instance.showDialog( + context, + ui: Constants.UiTheme.modalOverGrey.auto, + title: Builder( + builder: (context) { + final l10n = getl10n(context); + return Text( + '${localizedAction(getl10n(context))} ${count > 1 ? '$count ' : ''}${l10n.contentsPlural(count).toLowerCase()}', + ); + }, + ), + content: SingleChildScrollView( + child: Builder( + builder: (context) { + final l10n = getl10n(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextSpan(text: l10n.deletionPromptDescriptionP1), - TextSpan( - text: song != null - ? '${song.title}?' - : l10n.deletionPromptDescriptionP2, - style: song != null - ? const TextStyle(fontWeight: FontWeight.w700) - : null, + Text.rich( + TextSpan( + style: const TextStyle(fontSize: 15.0), + children: [ + TextSpan(text: "${l10n.areYouSureYouWantTo} ${localizedAction(l10n).toLowerCase()}"), + TextSpan( + text: ' ${entry != null ? entry.title : '${l10n.selectedPlural.toLowerCase()} ${l10n.contents().toLowerCase()}'}?', + style: entry != null + ? const TextStyle(fontWeight: FontWeight.w700) + : null, + ), + ], + ), + ), + const SizedBox(height: 8.0), + _DeletionArtsPreview( + list: list.toList(), ), ], + ); + }, + ), + ), + buttonSplashColor: Constants.Theme.glowSplashColor.auto, + acceptButton: Builder( + builder: (context) => NFButton.accept( + text: localizedAction(getl10n(context)), + splashColor: Constants.Theme.glowSplashColor.auto, + textStyle: const TextStyle(color: Constants.AppColors.red), + onPressed: () { + onSubmit(); + controller.close(); + }, + ), + ), + ); +} + +/// Shows the preview arts from [list] and, if there are more that are not fit, +/// adds a text at the end "and N more". +/// +/// Supports only songs and playlists. +class _DeletionArtsPreview extends StatefulWidget { + const _DeletionArtsPreview({Key? key, required this.list}) : super(key: key); + + final List list; + + @override + State<_DeletionArtsPreview> createState() => _DeletionArtsPreviewState(); +} + +class _DeletionArtsPreviewState extends State<_DeletionArtsPreview> with SingleTickerProviderStateMixin { + /// Maximum amount of arts per row + static const maxArts = 5; + static const itemSize = kPersistentQueueTileArtSize; + static const moreTextWidth = 110.0; + static const spacing = 8.0; + static const animationDuration = Duration(milliseconds: 300); + + late AnimationController controller; + + @override + void initState() { + super.initState(); + controller = AnimationController( + vsync: this, + duration: animationDuration, + ); + BackButtonInterceptor.add(backButtonInterceptor); + } + + @override + void dispose() { + controller.dispose(); + BackButtonInterceptor.remove(backButtonInterceptor); + super.dispose(); + } + + /// Using interceptor to gain a priority over the [HomeRouter.handleNecessaryPop]. + bool backButtonInterceptor(bool stopDefaultButtonEvent, RouteInfo info) { + Navigator.of(context).maybePop(); + BackButtonInterceptor.remove(backButtonInterceptor); + return true; + } + + bool showMore = false; + + void _handleMoreTap() { + setState(() { + showMore = !showMore; + if (showMore) { + controller.forward(); + } else { + controller.reverse(); + } + }); + } + + Widget _buildArt(T item, double size) { + assert(item is Song || item is PersistentQueue); + + return ContentArt( + source: ContentArtSource(item), + size: size, + defaultArtIcon: item is PersistentQueue ? ContentUtils.persistentQueueIcon(item) : null, + ); + } + + @override + Widget build(BuildContext context) { + assert(T == Song || T == Playlist); + + final l10n = getl10n(context); + final theme = ThemeControl.theme; + final mediaQuery = MediaQuery.of(context); + final correctedMoreTextWidth = moreTextWidth * mediaQuery.textScaleFactor; + + return CustomBoxy( + delegate: _BoxyArtsPreviewDelegate( + minWidth: itemSize + correctedMoreTextWidth, + // Width for N arts with spacing between them, excluding + // the spacing at the end. + maxWidth: itemSize * maxArts + spacing * (math.max(0, maxArts - 1)), + minHeight: itemSize, + maxHeight: double.infinity, + ), + children: [ + AnimatedBuilder( + animation: controller, + builder: (context, child) => LayoutBuilder( + builder: (context, constraints) { + final double intermediatePreviewsPerRow = math.min(constraints.maxWidth / (itemSize + spacing), maxArts.toDouble()); + final int previewsPerRow = intermediatePreviewsPerRow.toInt(); + // The amount of previews to show in "more" grid, so they fill + // the entire available space + final int gridPreviewsPerRow = intermediatePreviewsPerRow.ceil(); + final double gridItemSize = (constraints.maxWidth - spacing * (gridPreviewsPerRow - 1)) / gridPreviewsPerRow; + final exceeded = widget.list.length > previewsPerRow; + final List previews = exceeded + ? widget.list.sublist(0, (constraints.maxWidth - correctedMoreTextWidth) ~/ (itemSize + spacing)) + : widget.list; + final length = previews.length + (exceeded ? 1 : 0); + final rows = (widget.list.length / gridPreviewsPerRow).ceil(); + + final animation = Tween( + begin: itemSize, + end: math.min(gridItemSize * rows + (rows - 1) * spacing, mediaQuery.size.height / 2), + ).animate(CurvedAnimation( + curve: Curves.easeOut, + reverseCurve: Curves.easeInCubic, + parent: controller, + )); + + return GestureDetector( + onTap: exceeded ? _handleMoreTap : null, + child: SizedBox( + height: animation.value, + width: constraints.maxWidth, + child: ScrollConfiguration( + behavior: const GlowlessScrollBehavior(), + child: AnimatedSwitcher( + layoutBuilder: (Widget? currentChild, List previousChildren) { + return Stack( + alignment: Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + duration: animationDuration, + child: !showMore + ? SizedBox( + height: itemSize, + width: constraints.maxWidth, + child: ListView.separated( + itemCount: length, + itemBuilder: (context, index) { + if (!exceeded || index != length - 1) { + return _buildArt(previews[index], itemSize); + } + return Container( + width: correctedMoreTextWidth, + height: itemSize, + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 10.0, + horizontal: 10.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.onBackground, + borderRadius: const BorderRadius.all(Radius.circular(100.0)), + ), + child: Text( + l10n.andNMore(widget.list.length - previews.length), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.0, + height: 1, + fontWeight: FontWeight.w800, + color: theme.colorScheme.background, + ), + ), + ), + ); + }, + separatorBuilder: (context, index) => const SizedBox(width: spacing), + scrollDirection: Axis.horizontal, + ), + ) + : SizedBox( + width: constraints.maxWidth, + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: gridPreviewsPerRow, + crossAxisSpacing: spacing, + mainAxisSpacing: spacing, + ), + itemBuilder: (context, index) => _buildArt(widget.list[index], gridItemSize), + itemCount: widget.list.length, + ), + ), + ), + ), + ), + ); + } ), ), - buttonSplashColor: Constants.Theme.glowSplashColor.auto, - acceptButton: NFButton.accept( - text: l10n.delete, - splashColor: Constants.Theme.glowSplashColor.auto, - textStyle: const TextStyle(color: Constants.AppColors.red), - onPressed: () { - ContentControl.deleteSongs(songs.map((e) => e.data.sourceId).toSet()); - widget.controller.close(); - }, - ), - ); + ], + ); + } +} + +class _BoxyArtsPreviewDelegate extends BoxyDelegate { + _BoxyArtsPreviewDelegate({ + required this.minWidth, + required this.maxWidth, + required this.minHeight, + required this.maxHeight, + }); + + final double minWidth; + final double maxWidth; + final double minHeight; + final double maxHeight; + + @override + Size layout() { + return children.first.layout(constraints); + } + + @override + double minIntrinsicWidth(double height) { + return minWidth; + } + + @override + double maxIntrinsicWidth(double height) { + return maxWidth; + } + + @override + double minIntrinsicHeight(double width) { + return minHeight; + } + + @override + double maxIntrinsicHeight(double width) { + return maxHeight; + } +} + +//*********** Selection meta actions *********** + +/// Action that selects all content, usually based on the current +/// [ContentSelectionController.primaryContentType]. +class SelectAllSelectionAction extends StatelessWidget { + const SelectAllSelectionAction({ + Key? key, + required this.controller, + required this.entryFactory, + required this.getAll, + }) : super(key: key); + + final ContentSelectionController controller; + + final SelectionEntry Function(T, int index) entryFactory; + + /// Should return a list of all content to select, all + /// values must be of the same type, for example it cannot + /// contain both [Song]s and [Album]s. + final ValueGetter> getAll; + + void _handleTap(BuildContext context) { + final all = getAll(); + if (all.isNotEmpty) { + // TODO: selectAll method for the selection controller maybe? (not this "all", but a list of items) + final first = all.first; + assert(all.every((el) => el.runtimeType == first.runtimeType)); + + final selectedOfThisType = controller.data.where((el) => el.runtimeType == entryFactory(first, 0).runtimeType); + bool allSelected = true; + for (int i = 0; i < all.length; i++) { + if (!selectedOfThisType.contains(entryFactory(all[i], i))) { + allSelected = false; + break; + } + } + if (allSelected) { + for (int i = 0; i < all.length; i++) { + controller.data.remove(entryFactory(all[i], i)); + } + // ignore: invalid_use_of_protected_member + controller.notifyListeners(); + } else { + for (int i = 0; i < all.length; i++) + controller.data.add(entryFactory(all[i], i)); + // ignore: invalid_use_of_protected_member + controller.notifyListeners(); + } + if (controller.data.isEmpty) { + controller.close(); + } } } @override Widget build(BuildContext context) { - if (widget.controller.inSelection) { - /// The condition ensures animation will not play on close, because [SelectionAppBar] - /// has its own animation, and combination of them both doesn't look good. - shown = type == Song || - (type == Content && - widget.controller.data.firstWhereOrNull((el) => el is SelectionEntry) == null); - } - return AnimatedSwitcher( - // Prevents animation we enter the selection - key: ValueKey(widget.controller.status == AnimationStatus.dismissed), - duration: kSelectionDuration, - transitionBuilder: (child, animation) => _SelectionAnimation( - animation: animation, - child: child, + final l10n = getl10n(context); + return _ActionBuilder( + controller: controller, + shown: () => true, + builder: (context, child) => child!, + child: NFIconButton( + tooltip: l10n.selectAll, + icon: const Icon(Icons.select_all_rounded), + onPressed: () => _handleTap(context), ), - child: !shown - ? const SizedBox.shrink() - : NFIconButton( - icon: const Icon(Icons.delete_outline_rounded), - onPressed: _handleDelete, - ), ); } } diff --git a/lib/widgets/settings_widgets.dart b/lib/widgets/settings_widgets.dart index d1c228e3d..be27fc766 100644 --- a/lib/widgets/settings_widgets.dart +++ b/lib/widgets/settings_widgets.dart @@ -9,25 +9,24 @@ import 'package:sweyer/sweyer.dart'; /// Creates a setting item with [title], [description] and [content] sections class SettingItem extends StatelessWidget { const SettingItem({ - Key key, - @required this.title, + Key? key, + required this.title, + required this.content, this.description, this.trailing, - this.content, - }) : assert(title != null), - super(key: key); + }) : super(key: key); /// Text displayed as main title of the settings final String title; + /// A place for a custom widget (e.g. slider) + final Widget content; + /// Text displayed as the settings description - final String description; + final String? description; /// A place for widget to display at the end of title line - final Widget trailing; - - /// A place for a custom widget (e.g. slider) - final Widget content; + final Widget? trailing; @override Widget build(BuildContext context) { @@ -35,12 +34,12 @@ class SettingItem extends StatelessWidget { padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 10.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ //******** Title ******** Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ Padding( padding: const EdgeInsets.only(top: 10.0), child: Text( @@ -48,7 +47,8 @@ class SettingItem extends StatelessWidget { style: const TextStyle(fontSize: 16.0), ), ), - if (trailing != null) trailing + if (trailing != null) + trailing! ], ), //******** Description ******** @@ -56,9 +56,9 @@ class SettingItem extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 2.0), child: Text( - description, + description!, style: TextStyle( - color: ThemeControl.theme.textTheme.caption.color, + color: ThemeControl.theme.textTheme.caption!.color, ), ), ), @@ -76,17 +76,18 @@ class SettingItem extends StatelessWidget { /// The [child] is untouchable in the animation. class ChangedSwitcher extends StatefulWidget { ChangedSwitcher({ - Key key, - this.changed, + Key? key, this.child, + this.changed = false, }) : super(key: key); + final Widget? child; + /// When true, the [child] is shown and clickable. /// When false, the [child] is hidden and untouchable, but occupies the same space. /// /// Represents that some setting has been changed. final bool changed; - final Widget child; @override _ChangedSwitcherState createState() => _ChangedSwitcherState(); @@ -105,7 +106,7 @@ class _ChangedSwitcherState extends State { duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, padding: widget.changed - ? const EdgeInsets.only(right: 0.0) + ? EdgeInsets.zero : const EdgeInsets.only(right: 3.0), child: widget.child, ), diff --git a/lib/widgets/shared_axis_tab_view.dart b/lib/widgets/shared_axis_tab_view.dart index 147de90d3..a4f31a6cd 100644 --- a/lib/widgets/shared_axis_tab_view.dart +++ b/lib/widgets/shared_axis_tab_view.dart @@ -6,12 +6,12 @@ import 'package:animations/animations.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; +import 'package:sweyer/sweyer.dart'; /// Coordinates the logic behind the [SharedAxisTabView]. class SharedAxisTabController extends ChangeNotifier { SharedAxisTabController({ - @required this.length, + required this.length, int initialIndex = 0, }) : _index = initialIndex, _prevIndex = initialIndex; @@ -30,7 +30,9 @@ class SharedAxisTabController extends ChangeNotifier { /// If true, the active tab cannot be changed, neither by [changeTab], /// nor by user drag. bool canChange = true; - + + /// Changes the tab and notifies liteners. + /// Updates can be disabled with [canChange] `false`. void changeTab(int value) { if (!canChange) return; @@ -47,9 +49,9 @@ class SharedAxisTabController extends ChangeNotifier { /// * [SharedAxisTabController] that controls this view class SharedAxisTabView extends StatefulWidget { const SharedAxisTabView({ - Key key, - @required this.children, - @required this.controller, + Key? key, + required this.children, + required this.controller, this.tabBuilder = _defaultTabBuilder, }) : super(key: key); @@ -132,7 +134,7 @@ class _SharedAxisTabViewState extends State { child: AnimatedBuilder( animation: animation, child: child, - builder: (context, child) => widget.tabBuilder(context, animation, secondaryAnimation, child), + builder: (context, child) => widget.tabBuilder(context, animation, secondaryAnimation, child!), ), ); }, diff --git a/lib/widgets/show_functions.dart b/lib/widgets/show_functions.dart new file mode 100644 index 000000000..47f1c636d --- /dev/null +++ b/lib/widgets/show_functions.dart @@ -0,0 +1,206 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:sweyer/sweyer.dart'; +import 'package:flutter/material.dart' + hide showBottomSheet, showGeneralDialog, showModalBottomSheet; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:collection/collection.dart'; +import 'package:sweyer/constants.dart' as Constants; + +/// Class that contains composed 'show' functions, like [showDialog] and others +class ShowFunctions extends NFShowFunctions { + /// Empty constructor will allow enheritance. + ShowFunctions(); + ShowFunctions._(); + static final instance = ShowFunctions._(); + + /// Shows toast from `fluttertoast` plugin. + Future showToast({ + required String msg, + Toast? toastLength, + double fontSize = 15.0, + ToastGravity? gravity, + Color? textColor, + Color? backgroundColor, + }) async { + backgroundColor ??= ThemeControl.theme.colorScheme.primary; + + return Fluttertoast.showToast( + msg: msg, + toastLength: toastLength, + fontSize: fontSize, + gravity: gravity, + textColor: textColor, + backgroundColor: backgroundColor, + fontAsset: 'assets/fonts/Manrope/manrope-semibold.ttf', + timeInSecForIosWeb: 20000, + ); + } + + /// Opens songs search + void showSongsSearch( + BuildContext context, { + String query = '', + bool openKeyboard = true, + }) { + HomeRouter.of(context).goto(HomeRoutes.search.withArguments(SearchArguments( + query: query, + openKeyboard: openKeyboard, + ))); + } + + /// Shows a dialog to create a playlist. + Future showCreatePlaylist(TickerProvider vsync, BuildContext context) async { + final l10n = getl10n(context); + final theme = ThemeControl.theme; + final TextEditingController controller = TextEditingController(); + final AnimationController animationController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 300), + ); + controller.addListener(() { + if (controller.text.trim().isNotEmpty) + animationController.forward(); + else + animationController.reverse(); + }); + final animation = ColorTween( + begin: theme.disabledColor, + end: theme.colorScheme.onSecondary, + ).animate(CurvedAnimation( + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + parent: animationController, + )); + bool submitted = false; + String? name; + Future submit(BuildContext context) async { + if (!submitted) { + submitted = true; + name = await ContentControl.createPlaylist(controller.text); + Navigator.of(context).maybePop(name); + } + } + await ShowFunctions.instance.showDialog( + context, + ui: Constants.UiTheme.modalOverGrey.auto, + title: Text(l10n.newPlaylist), + content: Builder( + builder: (context) => AppTextField( + autofocus: true, + controller: controller, + onSubmit: (value) { + submit(context); + }, + onDispose: () { + controller.dispose(); + animationController.dispose(); + }, + ), + ), + buttonSplashColor: Constants.Theme.glowSplashColor.auto, + acceptButton: AnimatedBuilder( + animation: animation, + builder: (context, child) => IgnorePointer( + ignoring: const IgnoringStrategy( + dismissed: true, + reverse: true, + ).ask(animation), + child: NFButton( + text: l10n.create, + textStyle: TextStyle(color: animation.value), + splashColor: Constants.Theme.glowSplashColor.auto, + onPressed: () async { + submit(context); + }, + ), + ), + ), + ); + return name == null ? null : ContentControl.state.playlists.firstWhereOrNull((el) => el.name == name); + } + + /// Will show up a snack bar notification that something's went wrong + /// + /// From that snack bar will be possible to proceed to special alert to see the error details with the ability to copy them. + /// [errorDetails] string to show in the alert + void showError({ required String errorDetails }) { + final context = AppRouter.instance.navigatorKey.currentContext!; + final l10n = getl10n(context); + final theme = ThemeControl.theme; + final globalKey = GlobalKey(); + NFSnackbarController.showSnackbar( + NFSnackbarEntry( + globalKey: globalKey, + child: NFSnackbar( + title: Text( + '😮 ' + l10n.oopsErrorOccurred, + style: TextStyle( + fontSize: 15.0, + color: theme.colorScheme.onError, + ), + ), + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + top: 4.0, + bottom: 4.0, + ), + color: theme.colorScheme.error, + trailing: NFButton( + variant: NFButtonVariant.raised, + text: l10n.details, + color: theme.colorScheme.onPrimary, + textStyle: const TextStyle(color: Colors.black), + onPressed: () { + globalKey.currentState!.close(); + showAlert( + context, + title: Text( + l10n.errorDetails, + textAlign: TextAlign.center, + ), + titlePadding: defaultAlertTitlePadding.copyWith( + left: 12.0, + right: 12.0, + ), + contentPadding: const EdgeInsets.only( + top: 16.0, + left: 2.0, + right: 2.0, + bottom: 10.0, + ), + content: PrimaryScrollController( + controller: ScrollController(), + child: Builder( + builder: (context) { + return AppScrollbar( + child: SingleChildScrollView( + child: SelectableText( + errorDetails, + // TODO: temporarily do not apply AlwaysScrollableScrollPhysics, because of this issue https://github.com/flutter/flutter/issues/71342 + // scrollPhysics: AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()), + style: const TextStyle(fontSize: 11.0), + selectionControls: NFTextSelectionControls( + backgroundColor: ThemeControl.theme.colorScheme.background, + ), + ), + ), + ); + } + ), + ), + additionalActions: [ + NFCopyButton(text: errorDetails), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/slider.dart b/lib/widgets/slider.dart index fccf1a48c..007470a50 100644 --- a/lib/widgets/slider.dart +++ b/lib/widgets/slider.dart @@ -10,9 +10,9 @@ import 'package:sweyer/sweyer.dart'; /// Creates slider with labels of min and max values class LabelledSlider extends StatelessWidget { const LabelledSlider({ - Key key, - @required this.value, - @required this.onChanged, + Key? key, + required this.value, + required this.onChanged, this.onChangeStart, this.onChangeEnd, this.min = 0.0, @@ -25,10 +25,7 @@ class LabelledSlider extends StatelessWidget { this.minLabel, this.maxLabel, this.themeData = const SliderThemeData(), - }) : assert(value != null), - assert(min != null), - assert(max != null), - assert(min <= max), + }) : assert(min <= max), assert(value >= min && value <= max), assert(divisions == null || divisions > 0), super(key: key); @@ -40,10 +37,10 @@ class LabelledSlider extends StatelessWidget { final ValueChanged onChanged; /// See [Slider.onChangeStart] - final ValueChanged onChangeStart; + final ValueChanged? onChangeStart; /// See [Slider.onChangeEnd] - final ValueChanged onChangeEnd; + final ValueChanged? onChangeEnd; /// See [Slider.min] final double min; @@ -52,25 +49,25 @@ class LabelledSlider extends StatelessWidget { final double max; /// See [Slider.divisions] - final int divisions; + final int? divisions; /// See [Slider.label] - final String label; + final String? label; /// See [Slider.activeColor] - final Color activeColor; + final Color? activeColor; /// See [Slider.inactiveColor] - final Color inactiveColor; + final Color? inactiveColor; /// See [Slider.semanticFormatterCallback] - final SemanticFormatterCallback semanticFormatterCallback; + final SemanticFormatterCallback? semanticFormatterCallback; /// Label to display min value before the slider - final String minLabel; + final String? minLabel; /// Label to display max value after the slider - final String maxLabel; + final String? maxLabel; final SliderThemeData themeData; @@ -83,7 +80,7 @@ class LabelledSlider extends StatelessWidget { //******** Min Label ******** if (minLabel != null) Text( - minLabel, + minLabel!, style: const TextStyle(fontSize: 13), ), @@ -93,7 +90,7 @@ class LabelledSlider extends StatelessWidget { height: 30.0, child: SliderTheme( data: SliderThemeData( - trackShape: themeData.trackShape ?? const TrackShapeWithMargin(), + trackShape: themeData.trackShape ?? const TrackShapeWithMargin(horizontalMargin: 12.0), valueIndicatorShape: const PaddleSliderValueIndicatorShape(), overlayColor: Colors.transparent, activeTrackColor: activeColor ?? ThemeControl.theme.colorScheme.primary, @@ -117,36 +114,10 @@ class LabelledSlider extends StatelessWidget { //******** Max Label ******** if (maxLabel != null) Text( - maxLabel, + maxLabel!, style: const TextStyle(fontSize: 13), ), ], ); } } - -/// Put it into slider theme to make custom track margin -class TrackShapeWithMargin extends RoundedRectSliderTrackShape { - const TrackShapeWithMargin({ - this.horizontalMargin = 12.0, - }); - - /// Margin to be applied for each side horizontally - final double horizontalMargin; - - @override - Rect getPreferredRect({ - @required RenderBox parentBox, - Offset offset = Offset.zero, - @required SliderThemeData sliderTheme, - bool isEnabled = false, - bool isDiscrete = false, - }) { - final double trackHeight = sliderTheme.trackHeight; - final double trackLeft = offset.dx + horizontalMargin; - final double trackTop = - offset.dy + (parentBox.size.height - trackHeight) / 2; - final double trackWidth = parentBox.size.width - horizontalMargin * 2; - return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); - } -} diff --git a/lib/widgets/song_tile.dart b/lib/widgets/song_tile.dart deleted file mode 100644 index 6bfcf8eff..000000000 --- a/lib/widgets/song_tile.dart +++ /dev/null @@ -1,248 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) nt4f04und. All rights reserved. -* Licensed under the BSD-style license. See LICENSE in the project root for license information. -*--------------------------------------------------------------------------------------------*/ - -import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; -import 'package:sweyer/sweyer.dart'; - -/// Needed for scrollbar label computations -const double kSongTileHeight = 64.0; -const double kSongTileHorizontalPadding = 10.0; - -/// Describes what to draw in the tile leading. -enum SongTileVariant { - /// Set by default, will draw an [AlbumArt.songTile] in the tile leading. - albumArt, - - /// Will draw [SongNumber] in the tile leading. - number -} - -/// Supposed to draw a [Song.track] number, or '-' symbol if it's null. -class SongNumber extends StatelessWidget { - SongNumber({ - Key key, - String number, - this.current = false, - }) : assert(current != null), - number = int.tryParse(number ?? ''), - super(key: key); - - final int number; - final bool current; - - @override - Widget build(BuildContext context) { - Widget child; - if (current) { - child = Padding( - padding: const EdgeInsets.only(top: 2.0), - child: CurrentIndicator( - color: ThemeControl.theme.colorScheme.onBackground, - ), - ); - } else if (number != null && number > 0 && number < 999) { - // since this class won't be used for playlsits, but only for albums - // i limit the number to be from 0 to 999, in other cases consider it invalid/unsassigned and show a dot - child = Text( - number.toString(), - style: const TextStyle( - fontSize: 15.0, - fontWeight: FontWeight.w800, - ), - ); - } else { - child = Container( - width: 7.0, - height: 7.0, - decoration: BoxDecoration( - color: ThemeControl.theme.colorScheme.onBackground, - borderRadius: const BorderRadius.all( - Radius.circular(100.0), - ), - ), - ); - } - return Container( - alignment: Alignment.center, - width: kSongTileArtSize, - height: kSongTileArtSize, - padding: const EdgeInsets.only(right: 4.0), - child: child, - ); - } -} - -/// A [SongTile] that can be selected. -class SongTile extends SelectableWidget { - SongTile({ - Key key, - @required this.song, - this.current, - this.onTap, - this.clickBehavior = SongClickBehavior.play, - this.variant = SongTileVariant.albumArt, - this.horizontalPadding = kSongTileHorizontalPadding, - }) : assert(song != null), - index = null, - super(key: key); - - SongTile.selectable({ - Key key, - @required this.song, - @required this.index, - @required SelectionController selectionController, - bool selected = false, - this.current, - this.onTap, - this.clickBehavior = SongClickBehavior.play, - this.variant = SongTileVariant.albumArt, - this.horizontalPadding = kSongTileHorizontalPadding, - }) : assert(song != null), - assert(index != null), - assert(selectionController != null), - assert(selectionController is SelectionController> || - selectionController is SelectionController>), - super.selectable( - key: key, - selected: selected, - selectionController: selectionController, - ); - - final Song song; - final int index; - - /// Whether this song is current, if yes, enables animated - /// [CurrentIndicator] over the ablum art/instead song number. - /// - /// If not specified, by default true for equality of [Song.sourceId] - /// of song with given [index] and current song: - /// - /// ```dart - /// song.sourceId == ContentControl.state.currentSong.sourceId - /// ``` - final bool current; - final VoidCallback onTap; - final SongClickBehavior clickBehavior; - final SongTileVariant variant; - final double horizontalPadding; - - @override - SelectionEntry toSelectionEntry() => SelectionEntry( - index: index, - data: song, - ); - - @override - _SongTileState createState() => _SongTileState(); -} - -class _SongTileState extends SelectableState { - bool get showAlbumArt => widget.variant == SongTileVariant.albumArt; - - void _handleTap() { - super.handleTap(() async { - if (widget.onTap != null) { - widget.onTap(); - } - await MusicPlayer.instance.handleSongClick( - context, - widget.song, - behavior: widget.clickBehavior, - ); - }); - } - - bool get current { - if (widget.current != null) - return widget.current; - return widget.song.sourceId == ContentControl.state.currentSong.sourceId; - } - - Widget _buildTile( - Widget albumArt, [ - double rightPadding, - ]) { - rightPadding ??= widget.horizontalPadding; - final theme = ThemeControl.theme; - Widget title = Text( - widget.song.title, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.headline6, - ); - Widget subtitle = ArtistWidget(artist: widget.song.artist); - if (!showAlbumArt) { - // Reduce padding between leading and title. - Widget translate(Widget child) { - return Transform.translate( - offset: const Offset(-16.0, 0.0), - child: child, - ); - } - - title = translate(title); - subtitle = translate(subtitle); - } - return NFListTile( - dense: true, - isThreeLine: false, - contentPadding: EdgeInsets.only( - left: widget.horizontalPadding, - right: rightPadding, - ), - onTap: _handleTap, - onLongPress: toggleSelection, - title: title, - subtitle: subtitle, - leading: albumArt, - ); - } - - @override - Widget build(BuildContext context) { - Widget albumArt; - if (showAlbumArt) { - albumArt = AlbumArt.songTile( - source: AlbumArtSource( - path: widget.song.albumArt, - contentUri: widget.song.contentUri, - albumId: widget.song.albumId, - ), - current: current, - ); - } else { - albumArt = SongNumber( - number: widget.song.track, - current: current, - ); - } - if (!selectable) - return _buildTile(albumArt); - return Stack( - children: [ - AnimatedBuilder( - animation: animation, - builder: (context, child) { - var rightPadding = widget.horizontalPadding; - if (!showAlbumArt) { - if (animation.status == AnimationStatus.forward || - animation.status == AnimationStatus.completed || - animation.value > 0.2) { - rightPadding += 40.0; - } - } - return _buildTile(albumArt, rightPadding); - }, - ), - Positioned( - left: showAlbumArt ? 34.0 + widget.horizontalPadding : null, - right: showAlbumArt ? null : 10.0 + widget.horizontalPadding, - bottom: showAlbumArt ? 2.0 : 20.0, - child: SelectionCheckmark(animation: animation), - ), - ], - ); - } -} diff --git a/lib/widgets/spinner.dart b/lib/widgets/spinner.dart index 6785c55b3..a884356a5 100644 --- a/lib/widgets/spinner.dart +++ b/lib/widgets/spinner.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:sweyer/sweyer.dart'; class Spinner extends StatelessWidget { - const Spinner({Key key}) : super(key: key); + const Spinner({Key? key}) : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/widgets/standalone_player.dart b/lib/widgets/standalone_player.dart index a6ea74fd2..99b051af2 100644 --- a/lib/widgets/standalone_player.dart +++ b/lib/widgets/standalone_player.dart @@ -8,23 +8,23 @@ import 'dart:ui'; import 'package:just_audio/just_audio.dart'; import 'package:flutter/material.dart'; -import 'package:nt4f04unds_widgets/nt4f04unds_widgets.dart'; import 'package:sweyer/sweyer.dart'; // I was just playing around with UI and saved this file for future use. // Currently it's not used anywhere. class _StandalonePlayer extends StatefulWidget { - _StandalonePlayer({Key key}) : super(key: key); + _StandalonePlayer({Key? key}) : super(key: key); @override _StandalonePlayerState createState() => _StandalonePlayerState(); } class _StandalonePlayerState extends State<_StandalonePlayer> with SingleTickerProviderStateMixin { - AudioPlayer player; - AnimationController controller; - Timer timer; + late AudioPlayer player; + late AnimationController controller; + Timer? timer; + static const fadeDuration = Duration(milliseconds: 300); @override @@ -56,6 +56,7 @@ class _StandalonePlayerState extends State<_StandalonePlayer> with SingleTickerP timer?.cancel(); timer = Timer(const Duration(milliseconds: 1500) + fadeDuration, () { controller.reverse(); + timer = null; }); } @@ -63,7 +64,7 @@ class _StandalonePlayerState extends State<_StandalonePlayer> with SingleTickerP _show(); setState(() { if (player.processingState == ProcessingState.completed) { - player.seek(const Duration()); + player.seek(Duration.zero); } if (player.playing) { player.pause(); @@ -86,7 +87,7 @@ class _StandalonePlayerState extends State<_StandalonePlayer> with SingleTickerP padding: const EdgeInsets.only(top: 20.0), child: Stack( children: [ - const AlbumArt.playerRoute( + const ContentArt.playerRoute( source: null, borderRadius: 0, ), @@ -95,13 +96,15 @@ class _StandalonePlayerState extends State<_StandalonePlayer> with SingleTickerP onTap: _handleTap, child: AnimatedBuilder( animation: controller, - child: Container( - alignment: Alignment.center, - color: Colors.black38, - child: IgnorePointer( - child: AnimatedPlayPauseButton( - player: player, - iconSize: 46, + child: RepaintBoundary( + child: Container( + alignment: Alignment.center, + color: Colors.black38, + child: IgnorePointer( + child: AnimatedPlayPauseButton( + player: player, + iconSize: 46, + ), ), ), ), @@ -187,7 +190,9 @@ void _openStandalonePlayerRoute(BuildContext context) { scale: routeScaleAnimation, child: FadeTransition( opacity: routeFadeAnimation, - child: child, + child: RepaintBoundary(// TODO: test RepaintBoundaries in this file + child: child, + ), ), ), ), diff --git a/lib/widgets/sweyer_icons.dart b/lib/widgets/sweyer_icons.dart new file mode 100644 index 000000000..5474fc472 --- /dev/null +++ b/lib/widgets/sweyer_icons.dart @@ -0,0 +1,16 @@ +/// Flutter icons SweyerIcons +/// Copyright (C) 2021 by original authors @ fluttericon.com, fontello.com +/// This font was generated by FlutterIcon.com, which is derived from Fontello. + +import 'package:flutter/widgets.dart'; + +class SweyerIcons { + SweyerIcons._(); + + static const _kFontFam = 'SweyerIcons'; + static const String? _kFontPkg = null; + + static const IconData play_next = IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData add_to_queue = IconData(0xe801, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData share = IconData(0xe802, fontFamily: _kFontFam, fontPackage: _kFontPkg); +} diff --git a/lib/widgets/text_field.dart b/lib/widgets/text_field.dart new file mode 100644 index 000000000..9a461e522 --- /dev/null +++ b/lib/widgets/text_field.dart @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:flutter/material.dart'; +import 'package:sweyer/sweyer.dart'; + +class AppTextField extends StatefulWidget { + AppTextField({ + Key? key, + this.onSubmit, + this.autofocus = false, + this.isDense = false, + this.textStyle, + this.hintStyle, + this.contentPadding, + this.controller, + this.onDispose, + }) : super(key: key); + + final ValueSetter? onSubmit; + final bool autofocus; + final bool isDense; + final TextStyle? textStyle; + final TextStyle? hintStyle; + final EdgeInsetsGeometry? contentPadding; + final TextEditingController? controller; + final VoidCallback? onDispose; + + @override + _AppTextFieldState createState() => _AppTextFieldState(); +} + +class _AppTextFieldState extends State { + @override + void dispose() { + widget.onDispose?.call(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = getl10n(context); + final theme = ThemeControl.theme; + return TextField( + selectionControls: NFTextSelectionControls(), + controller: widget.controller, + autofocus: widget.autofocus, + style: theme.textTheme.headline6?.merge(widget.textStyle) + ?? widget.textStyle, + maxLines: 1, + onSubmitted: widget.onSubmit, + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: widget.contentPadding, + hintText: l10n.title, + hintStyle: widget.hintStyle, + isDense: widget.isDense, + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index d3442f3ba..3573ba99e 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -3,17 +3,15 @@ * Licensed under the BSD-style license. See LICENSE in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export 'album_art.dart'; -export 'album_tile.dart'; +export 'content_list_view/content_list_view.dart'; export 'app_bar_border.dart'; export 'artist.dart'; export 'bottom_track_panel.dart'; export 'buttons.dart'; -export 'content_list_view.dart'; -export 'content_tile.dart'; +export 'content_art.dart'; +export 'content_section.dart'; export 'current_indicator.dart'; export 'drawer.dart'; -export 'list_header.dart'; export 'logo.dart'; export 'play_pause_button.dart'; export 'screens.dart'; @@ -22,6 +20,8 @@ export 'seekbar.dart'; export 'selection.dart'; export 'settings_widgets.dart'; export 'shared_axis_tab_view.dart'; +export 'show_functions.dart'; export 'slider.dart'; -export 'song_tile.dart'; export 'spinner.dart'; +export 'sweyer_icons.dart'; +export 'text_field.dart'; diff --git a/pubspec.lock b/pubspec.lock index 51c206322..02992c70a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,49 +7,42 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "20.0.0" + version: "22.0.0" analyzer: - dependency: "direct overridden" + dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "1.4.0" + version: "1.7.2" animations: dependency: "direct main" description: name: animations url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.2" + version: "2.0.1" args: dependency: transitive description: name: args url: "https://pub.dartlang.org" source: hosted - version: "1.6.0" + version: "2.1.1" async: dependency: "direct main" description: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.6.0" + version: "2.7.0" audio_service: dependency: "direct main" description: path: audio_service - ref: "67944252c2484272562078b3168fa30f4a6f6505" - resolved-ref: "67944252c2484272562078b3168fa30f4a6f6505" - url: "https://github.com/nt4f04uNd/audio_service/" + ref: sweyer + resolved-ref: f088b43a75a58d3fa4ce4f292c9ab392cfe8bf3d + url: "https://github.com/nt4f04uNd/audio_service" source: git version: "0.16.2" audio_service_platform_interface: @@ -57,7 +50,7 @@ packages: description: path: audio_service_platform_interface ref: sweyer - resolved-ref: "67944252c2484272562078b3168fa30f4a6f6505" + resolved-ref: f088b43a75a58d3fa4ce4f292c9ab392cfe8bf3d url: "https://github.com/nt4f04und/audio_service.git" source: git version: "1.0.0" @@ -66,17 +59,17 @@ packages: description: path: audio_service_web ref: sweyer - resolved-ref: "67944252c2484272562078b3168fa30f4a6f6505" + resolved-ref: f088b43a75a58d3fa4ce4f292c9ab392cfe8bf3d url: "https://github.com/nt4f04und/audio_service.git" source: git version: "0.16.2" audio_session: - dependency: "direct overridden" + dependency: transitive description: name: audio_session url: "https://pub.dartlang.org" source: hosted - version: "0.1.0" + version: "0.1.5" auto_size_text: dependency: "direct main" description: @@ -84,69 +77,34 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.0-nullsafety.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - build: - dependency: transitive - description: - name: build - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - build_config: - dependency: transitive - description: - name: build_config - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.7" - build_daemon: - dependency: transitive - description: - name: build_daemon - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.10" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - build_runner: - dependency: "direct dev" + back_button_interceptor: + dependency: "direct main" description: - name: build_runner + name: back_button_interceptor url: "https://pub.dartlang.org" source: hosted - version: "1.12.2" - build_runner_core: + version: "5.0.1" + boolean_selector: dependency: transitive description: - name: build_runner_core + name: boolean_selector url: "https://pub.dartlang.org" source: hosted - version: "6.1.12" - built_collection: - dependency: transitive + version: "2.1.0" + boxy: + dependency: "direct main" description: - name: built_collection + name: boxy url: "https://pub.dartlang.org" source: hosted - version: "5.0.0" - built_value: - dependency: transitive + version: "2.0.2" + cached_network_image: + dependency: "direct main" description: - name: built_value + name: cached_network_image url: "https://pub.dartlang.org" source: hosted - version: "8.0.4" + version: "3.0.0" characters: dependency: transitive description: @@ -160,21 +118,14 @@ packages: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" + version: "1.3.1" cli_util: dependency: transitive description: name: cli_util url: "https://pub.dartlang.org" source: hosted - version: "0.3.0" + version: "0.3.3" clock: dependency: transitive description: @@ -182,13 +133,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" - code_builder: + cloud_functions: + dependency: "direct main" + description: + name: cloud_functions + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.2" + cloud_functions_platform_interface: + dependency: transitive + description: + name: cloud_functions_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.7" + cloud_functions_web: dependency: transitive description: - name: code_builder + name: cloud_functions_web url: "https://pub.dartlang.org" source: hosted - version: "3.7.0" + version: "4.0.9" collection: dependency: transitive description: @@ -202,9 +167,9 @@ packages: name: convert url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.0.1" crypto: - dependency: "direct overridden" + dependency: transitive description: name: crypto url: "https://pub.dartlang.org" @@ -216,14 +181,14 @@ packages: name: dart_style url: "https://pub.dartlang.org" source: hosted - version: "1.3.14" + version: "2.0.1" device_info: dependency: "direct main" description: name: device_info url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.2" device_info_platform_interface: dependency: transitive description: @@ -244,7 +209,7 @@ packages: name: equatable url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.3" fading_edge_scrollview: dependency: transitive description: @@ -265,14 +230,14 @@ packages: name: ffi url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.1.2" file: dependency: transitive description: name: file url: "https://pub.dartlang.org" source: hosted - version: "6.1.0" + version: "6.1.2" firebase: dependency: transitive description: @@ -286,63 +251,56 @@ packages: name: firebase_analytics url: "https://pub.dartlang.org" source: hosted - version: "8.0.0" + version: "8.1.2" firebase_analytics_platform_interface: dependency: transitive description: name: firebase_analytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.1" firebase_analytics_web: dependency: transitive description: name: firebase_analytics_web url: "https://pub.dartlang.org" source: hosted - version: "0.3.0" + version: "0.3.0+1" firebase_core: dependency: "direct main" description: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.3.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "4.0.0" + version: "4.0.1" firebase_core_web: dependency: transitive description: name: firebase_core_web url: "https://pub.dartlang.org" source: hosted - version: "1.0.2" + version: "1.1.0" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.7" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" - fixnum: - dependency: transitive - description: - name: fixnum - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" + version: "3.0.6" flare_dart: dependency: transitive description: @@ -362,13 +320,20 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_cache_manager: + flutter_blurhash: dependency: transitive + description: + name: flutter_blurhash + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.0" + flutter_cache_manager: + dependency: "direct main" description: name: flutter_cache_manager url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.1.2" flutter_localizations: dependency: "direct main" description: flutter @@ -407,27 +372,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" - graphs: - dependency: transitive - description: - name: graphs - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" http: dependency: transitive description: name: http url: "https://pub.dartlang.org" source: hosted - version: "0.13.1" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" + version: "0.13.3" http_parser: dependency: transitive description: @@ -435,13 +386,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.0.0" - image: - dependency: transitive - description: - name: image - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.2" intl: dependency: transitive description: @@ -453,18 +397,11 @@ packages: dependency: "direct dev" description: path: "." - ref: "198ba30710a7aeba94813389dac47c9092d11e6c" - resolved-ref: "198ba30710a7aeba94813389dac47c9092d11e6c" + ref: "01600ef565707e59dd24f99590b7da1edb6f9ca4" + resolved-ref: "01600ef565707e59dd24f99590b7da1edb6f9ca4" url: "https://github.com/nt4f04uNd/intl_translation" source: git version: "0.17.10+1" - io: - dependency: transitive - description: - name: io - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" js: dependency: transitive description: @@ -472,48 +409,34 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.3" - json_annotation: - dependency: transitive - description: - name: json_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.1" just_audio: dependency: "direct main" description: name: just_audio url: "https://pub.dartlang.org" source: hosted - version: "0.7.4" + version: "0.7.5" just_audio_platform_interface: dependency: transitive description: name: just_audio_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.1.0" just_audio_web: dependency: transitive description: name: just_audio_web url: "https://pub.dartlang.org" source: hosted - version: "0.3.1" - logging: - dependency: transitive - description: - name: logging - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" + version: "0.3.2" marquee: dependency: "direct main" description: name: marquee url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.2.0" matcher: dependency: transitive description: @@ -527,30 +450,30 @@ packages: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" - mime: - dependency: transitive - description: - name: mime - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" + version: "1.7.0" multiple_localization: dependency: "direct main" description: name: multiple_localization url: "https://pub.dartlang.org" source: hosted - version: "0.2.1" + version: "0.2.2" nt4f04unds_widgets: dependency: "direct main" description: path: "." - ref: "1.1.3" - resolved-ref: "3913a198878f90566ce97f0fd7e3b878f0af8a2d" + ref: "1.1.5" + resolved-ref: "964447ad63670edd98812ce612b2e082a2f51b94" url: "https://github.com/nt4f04uNd/nt4f04unds_widgets/" source: git - version: "1.1.3" + version: "1.1.5" + octo_image: + dependency: transitive + description: + name: octo_image + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0+1" package_config: dependency: transitive description: @@ -564,7 +487,14 @@ packages: name: package_info url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.2" + palette_generator: + dependency: "direct main" + description: + name: palette_generator + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" path: dependency: "direct main" description: @@ -578,7 +508,7 @@ packages: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.2" path_provider_linux: dependency: transitive description: @@ -606,14 +536,14 @@ packages: name: path_provider_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.1" pedantic: dependency: transitive description: name: pedantic url: "https://pub.dartlang.org" source: hosted - version: "1.11.0" + version: "1.11.1" percent_indicator: dependency: "direct main" description: @@ -627,21 +557,21 @@ packages: name: permission_handler url: "https://pub.dartlang.org" source: hosted - version: "6.1.3" + version: "7.2.0" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.1.3" + version: "3.6.0" petitparser: - dependency: "direct overridden" + dependency: transitive description: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "4.1.0" + version: "4.2.0" platform: dependency: transitive description: @@ -655,14 +585,7 @@ packages: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" - pool: - dependency: transitive - description: - name: pool - url: "https://pub.dartlang.org" - source: hosted - version: "1.5.0" + version: "2.0.1" process: dependency: transitive description: @@ -677,13 +600,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" rxdart: dependency: "direct main" description: @@ -697,7 +613,7 @@ packages: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.0.6" shared_preferences_linux: dependency: transitive description: @@ -733,20 +649,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" - shelf: - dependency: transitive - description: - name: shelf - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" sky_engine: dependency: transitive description: flutter @@ -758,7 +660,7 @@ packages: name: sliver_tools url: "https://pub.dartlang.org" source: hosted - version: "0.2.1" + version: "0.2.5" source_span: dependency: transitive description: @@ -794,13 +696,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" - stream_transform: - dependency: transitive - description: - name: stream_transform - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" string_scanner: dependency: transitive description: @@ -828,14 +723,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.3.0" - timing: - dependency: transitive - description: - name: timing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" + version: "0.4.1" typed_data: dependency: transitive description: @@ -849,7 +737,7 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.0.3" + version: "6.0.9" url_launcher_linux: dependency: transitive description: @@ -870,14 +758,14 @@ packages: name: url_launcher_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.0.4" url_launcher_web: dependency: transitive description: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.1" url_launcher_windows: dependency: transitive description: @@ -913,20 +801,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" win32: dependency: transitive description: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.2.5" xdg_directories: dependency: transitive description: @@ -934,13 +815,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.0" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "5.1.0" yaml: dependency: transitive description: @@ -949,5 +823,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=2.12.0 <3.0.0" - flutter: ">=2.2.0-11.0.pre.158" + dart: ">=2.13.0 <3.0.0" + flutter: ">=2.4.0-5.0.pre.73" diff --git a/pubspec.yaml b/pubspec.yaml index b71058b4e..38c7368c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,30 +4,35 @@ homepage: https://github.com/nt4f04uNd/sweyer repository: https://github.com/nt4f04uNd/sweyer issue_tracker: https://github.com/nt4f04uNd/sweyer/issues publish_to: none -version: 1.0.3+5 - +version: 1.0.4+6 + environment: - sdk: ">=2.6.0<3.0.0" - flutter: 2.2.0-11.0.pre.158 # https://github.com/nt4f04uNd/flutter/tree/sweyer + sdk: '>=2.13.0 <3.0.0' + flutter: 2.4.0-5.0.pre.73 + # See https://github.com/nt4f04uNd/flutter/tree/sweyer + # For this release it's https://github.com/flutter/flutter/commit/252f6a02855194f17678dd29254cf6216f049f14 + # with cherry-picked https://github.com/flutter/flutter/pull/82687 and https://github.com/flutter/flutter/pull/85499 for scrollbar fixes + # The HEAD is https://github.com/nt4f04uNd/flutter/commit/e298ed772fa188dc1df57a4c769be5781390ca33 dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter - # color_thief_flutter: ^1.0.2 uuid: ^3.0.4 + path: ^1.8.0 async: ^2.5.0 rxdart: ^0.26.0 sliver_tools: ^0.2.1 animations: ^2.0.0 - permission_handler: ^6.1.1 + permission_handler: ^7.0.0 shared_preferences: ^2.0.5 path_provider: ^2.0.1 percent_indicator: ^3.0.1 firebase_core: ^1.0.3 - firebase_crashlytics: ^2.0.0 - firebase_analytics: ^8.0.0 + firebase_crashlytics: ^2.0.1 + firebase_analytics: ^8.0.1 + cloud_functions: ^1.1.2 package_info: ^2.0.0 url_launcher: ^6.0.3 flare_flutter: ^2.0.6 # TODO: update to 3.0.0 when https://github.com/2d-inc/Flare-Flutter/issues/301 is resolved @@ -37,10 +42,16 @@ dependencies: device_info: ^2.0.0 enum_to_string: ^2.0.1 sqflite: ^2.0.0+3 - path: ^1.8.0 + boxy: ^2.0.0 + back_button_interceptor: ^5.0.0 flutter_sticky_header: ^0.6.0 + palette_generator: ^0.3.0 multiple_localization: ^0.2.1 + cached_network_image: ^3.0.0 + flutter_cache_manager: ^3.1.1 + # quick_actions: ^0.6.0 # TODO: quick actions are blocked on https://github.com/ryanheise/audio_service/issues/671 + # fluttertoast: ^8.0.3 fluttertoast: # path: C:/dev/projects/native/FlutterToast @@ -49,10 +60,6 @@ dependencies: git: url: https://github.com/nt4f04uNd/fluttertoast/ ref: f753a91591569acece70a9e8748745efa9faabfb # patch - # flutter_media_metadata: - # path: C:/dev/projects/native/flutter_media_metadata - - # # git: https://github.com/alexmercerind/flutter_media_metadata just_audio: ^0.7.3 @@ -65,11 +72,12 @@ dependencies: audio_service: # Using this branch for custom notifications https://github.com/nt4f04uNd/audio_service/commits/sweyer # See https://github.com/ryanheise/audio_service/issues/633 + # path: C:/dev/projects/native/audio_service/audio_service - git: - url: https://github.com/nt4f04uNd/audio_service/ - ref: 67944252c2484272562078b3168fa30f4a6f6505 # sweyer + git: + url: https://github.com/nt4f04uNd/audio_service + ref: sweyer path: audio_service # git: @@ -81,27 +89,16 @@ dependencies: git: url: https://github.com/nt4f04uNd/nt4f04unds_widgets/ - ref: 1.1.3 - -dependency_overrides: - analyzer: ^1.0.0 - petitparser: ^4.0.0 - crypto: ^3.0.0 - audio_session: - ^0.1.0 - # git: - # url: https://github.com/ryanheise/audio_session.git - # ref: master + ref: 1.1.5 dev_dependencies: flutter_test: sdk: flutter - build_runner: ^1.7.2 # intl_translation: ^0.17.10+1 intl_translation: git: url: https://github.com/nt4f04uNd/intl_translation - ref: 198ba30710a7aeba94813389dac47c9092d11e6c + ref: 01600ef565707e59dd24f99590b7da1edb6f9ca4 flutter: uses-material-design: true @@ -122,3 +119,6 @@ flutter: weight: 700 - asset: assets/fonts/Manrope/manrope-semibold.ttf weight: 600 + - family: SweyerIcons + fonts: + - asset: assets/fonts/SweyerIcons/SweyerIcons.ttf diff --git a/static_assets/edit/add_to_queue.svg b/static_assets/edit/add_to_queue.svg new file mode 100644 index 000000000..3f06a63e7 --- /dev/null +++ b/static_assets/edit/add_to_queue.svg @@ -0,0 +1 @@ +add_to_queue \ No newline at end of file diff --git a/static_assets/edit/play_next.svg b/static_assets/edit/play_next.svg new file mode 100644 index 000000000..24902632e --- /dev/null +++ b/static_assets/edit/play_next.svg @@ -0,0 +1 @@ +play_next \ No newline at end of file diff --git a/static_assets/edit/share.svg b/static_assets/edit/share.svg new file mode 100644 index 000000000..fd4946dfe --- /dev/null +++ b/static_assets/edit/share.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/static_assets/readme/1.jpg b/static_assets/readme/1.jpg index cb9274a12..37f2087de 100644 Binary files a/static_assets/readme/1.jpg and b/static_assets/readme/1.jpg differ diff --git a/static_assets/readme/2.jpg b/static_assets/readme/2.jpg index a42fa04bc..94cc4e2c3 100644 Binary files a/static_assets/readme/2.jpg and b/static_assets/readme/2.jpg differ diff --git a/static_assets/readme/3.jpg b/static_assets/readme/3.jpg index 2f3153c31..6215df275 100644 Binary files a/static_assets/readme/3.jpg and b/static_assets/readme/3.jpg differ diff --git a/static_assets/readme/4.jpg b/static_assets/readme/4.jpg index 90b0db089..fd6d7ec4c 100644 Binary files a/static_assets/readme/4.jpg and b/static_assets/readme/4.jpg differ diff --git a/static_assets/tablet_screenshots/1.png b/static_assets/tablet_screenshots/1.png new file mode 100644 index 000000000..d3e564c31 Binary files /dev/null and b/static_assets/tablet_screenshots/1.png differ diff --git a/static_assets/tablet_screenshots/2.png b/static_assets/tablet_screenshots/2.png new file mode 100644 index 000000000..ebeb7db38 Binary files /dev/null and b/static_assets/tablet_screenshots/2.png differ diff --git a/static_assets/tablet_screenshots/3.png b/static_assets/tablet_screenshots/3.png new file mode 100644 index 000000000..87926648c Binary files /dev/null and b/static_assets/tablet_screenshots/3.png differ diff --git a/static_assets/tablet_screenshots/4.png b/static_assets/tablet_screenshots/4.png new file mode 100644 index 000000000..398ab9a29 Binary files /dev/null and b/static_assets/tablet_screenshots/4.png differ diff --git a/test/benchmarks/sql_vs_json.dart b/test/benchmarks/sql_vs_json.dart index b81a77de0..2adc071d6 100644 --- a/test/benchmarks/sql_vs_json.dart +++ b/test/benchmarks/sql_vs_json.dart @@ -6,11 +6,12 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; -import 'package:meta/meta.dart'; import 'package:path/path.dart'; import 'package:sqflite/sqflite.dart'; import 'package:sweyer/sweyer.dart'; +import '../test.dart'; + /// Comparison of JSON serialization vs `sqflite` /// /// Results - JSON absolutely kills SQL (tested with `sqflite 2.0.0+3`): @@ -41,33 +42,8 @@ import 'package:sweyer/sweyer.dart'; /// ``` void main() { TestWidgetsFlutterBinding.ensureInitialized(); - - final album = Album( - id: 0, - album: 'album', - albumArt: 'albumArt_albumArt_albumArt', - artist: 'artist', - artistId: 0, - firstYear: 2000, - lastYear: 2000, - numberOfSongs: 1000, - ); - final songs = List.generate(1000, (index) => Song( - id: index, - album: 'album', - albumId: 0, - artist: 'artist', - artistId: 0, - title: 'title', - track: 'track', - dateAdded: 0, - dateModified: 0, - duration: 0, - size: 0, - data: 'data_data_data_data_data_data_data_data', - origin: album, - )); + final songs = List.generate(1000, (index) => songWith(id: index)); testWidgets('sql', (_) async { final db = SongsDatabase.instance; @@ -99,10 +75,10 @@ class SongsDatabase { static const _DATABASE = 'TEST.db'; static const TABLE = 'TEST'; - Completer _completer; + Completer? _completer; Future get _database async { if (_completer != null) - return _completer.future; + return _completer!.future; _completer = Completer(); await openDatabase( join(await getDatabasesPath(), _DATABASE), @@ -112,11 +88,11 @@ class SongsDatabase { onDowngrade: (database, oldVersion, newVersion) {}, onUpgrade: (database, oldVersion, newVersion) {}, onOpen: (database) { - _completer.complete(database); + _completer!.complete(database); }, version: 1, ); - return _completer.future; + return _completer!.future; } /// Table of all songs. @@ -132,9 +108,9 @@ class SongsDatabase { class Table { Table({ - @required this.name, - @required Database database, - @required this.factory, + required this.name, + required Database database, + required this.factory, }) : _database = database; /// Table name. @@ -144,7 +120,7 @@ class Table { final Database _database; /// Recieves map of data and should create an item from it. - final T Function(Map data) factory; + final T Function(Map data) factory; Future> queryAll() async { return (await _database.query(name)) @@ -187,8 +163,8 @@ class Table { class SqlSong { SqlSong({ - @required this.id, - @required this.origin, + required this.id, + required this.origin, }) : assert(() { if (origin is Album) { return true; @@ -204,7 +180,7 @@ class SqlSong { } final int id; - final PersistentQueue origin; + final SongOrigin? origin; Map toMap() { return { @@ -212,13 +188,13 @@ class SqlSong { if (origin != null) 'origin_type': 'album', if (origin != null) - 'origin_id': origin.id, + 'origin_id': origin!.id, }; } factory SqlSong.fromMap(Map map) { final originType = map['origin_type']; - PersistentQueue origin; + PersistentQueue? origin; assert(originType == 'album'); if (originType == 'album') { origin = ContentControl.state.albums[map['origin_id']]; diff --git a/test/routes/routes_test.dart b/test/routes/routes_test.dart new file mode 100644 index 000000000..929e298f7 --- /dev/null +++ b/test/routes/routes_test.dart @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sweyer/sweyer.dart'; + +import '../test.dart'; + +void main() { + test('HomeRoutes comparison test', () async { + final albumRoute1 = HomeRoutes.factory.content(albumWith()); + final albumRoute2 = HomeRoutes.factory.content(albumWith()); + final artistRoute = HomeRoutes.factory.content(artistWith()); + + expect(albumRoute1, equals(albumRoute2)); + expect(albumRoute1.hasSameLocation(HomeRoutes.album), true); + expect(artistRoute.hasDifferentLocation(HomeRoutes.album), true); + }); +} diff --git a/test/test.dart b/test/test.dart new file mode 100644 index 000000000..886dbb572 --- /dev/null +++ b/test/test.dart @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) nt4f04und. All rights reserved. +* Licensed under the BSD-style license. See LICENSE in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import 'package:sweyer/sweyer.dart'; + +final _testSong = Song( + id: 0, + album: 'album', + albumId: 0, + artist: 'artist', + artistId: 0, + genre: 'genre', + genreId: 0, + title: 'title', + track: 'track', + dateAdded: 0, + dateModified: 0, + duration: 0, + size: 0, + data: 'data_data_data_data_data_data_data_data', + isFavorite: false, + generationAdded: 0, + generationModified: 0, + origin: _testAlbum, + duplicationIndex: 0, +); + +const _testAlbum = Album( + id: 0, + album: 'album', + albumArt: 'albumArt_albumArt_albumArt', + artist: 'artist', + artistId: 0, + firstYear: 2000, + lastYear: 2000, + numberOfSongs: 1000, +); + +const _testArtist = Artist( + id: 0, + artist: 'artist', + numberOfAlbums: 1, + numberOfTracks: 1, +); + +SongCopyWith songWith = _testSong.copyWith; +AlbumCopyWith albumWith = _testAlbum.copyWith; +ArtistCopyWith artistWith = _testArtist.copyWith;