diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index f6631329..7643783a 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,11 +1,6 @@ - - diff --git a/fastlane/metadata/android/en-US/changelogs/130.txt b/fastlane/metadata/android/en-US/changelogs/130.txt index 3d2ccf81..b787ae47 100644 --- a/fastlane/metadata/android/en-US/changelogs/130.txt +++ b/fastlane/metadata/android/en-US/changelogs/130.txt @@ -1,9 +1,5 @@ ### Features -- Added custom buttons for shuffling the current queue and setting repeat mode (Android Auto) -- Properly handling nested directory structures (Android Auto) -- Add a toast when adding tracks to the playlist -- Allow pinning when offline - -### Dependencies -- Update koin -- Update media3 to v1.1.0 \ No newline at end of file +- Improved display of rating stars +- Completely modernize all older code parts +- Updates for Android 14 +- Update dependencies \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb4af1c9..a584032f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,22 +2,23 @@ # You need to run ./gradlew wrapper after updating the version gradle = "8.1.1" -navigation = "2.7.3" +navigation = "2.7.5" gradlePlugin = "8.1.2" androidxcar = "1.2.0" androidxcore = "1.12.0" ktlint = "0.43.2" -ktlintGradle = "11.6.0" -detekt = "1.23.0" +ktlintGradle = "11.6.1" +detekt = "1.23.3" preferences = "1.2.1" media3 = "1.1.1" androidSupport = "1.7.0" -materialDesign = "1.9.0" +materialDesign = "1.10.0" constraintLayout = "2.1.4" +activity = "1.8.0" multidex = "2.0.1" -room = "2.5.2" -kotlin = "1.9.10" +room = "2.6.0" +kotlin = "1.9.20" ksp = "1.9.10-1.0.13" kotlinxCoroutines = "1.7.3" viewModelKtx = "2.6.2" @@ -25,21 +26,21 @@ swipeRefresh = "1.1.0" retrofit = "2.9.0" ## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24 -jackson = "2.13.5" -okhttp = "4.11.0" +jackson = "2.15.3" +okhttp = "4.12.0" koin = "3.5.0" picasso = "2.8" junit4 = "4.13.2" junit5 = "5.10.0" -mockito = "5.5.0" +mockito = "5.7.0" mockitoKotlin = "5.1.0" kluent = "1.73" apacheCodecs = "1.16.0" -robolectric = "4.10.3" +robolectric = "4.11.1" timber = "5.0.1" fastScroll = "2.0.1" -colorPicker = "2.2.4" +colorPicker = "2.3.0" rxJava = "3.1.8" rxAndroid = "3.0.2" multiType = "4.3.0" @@ -66,6 +67,7 @@ navigationFragmentKtx = { module = "androidx.navigation:navigation-fragment-kt navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" } navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" } navigationSafeArgs = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation"} +activity = { module = "androidx.activity:activity-ktx", version.ref = "activity" } preferences = { module = "androidx.preference:preference", version.ref = "preferences" } media3common = { module = "androidx.media3:media3-common", version.ref = "media3" } media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d11cdd90..8838ba97 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 0adc8e1a..1aa94a42 100755 --- a/gradlew +++ b/gradlew @@ -145,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -153,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -202,11 +202,11 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 5f9420ae..15f1f6d7 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -12,8 +12,8 @@ android { defaultConfig { applicationId "org.moire.ultrasonic" - versionCode 129 - versionName "4.7.1" + versionCode 130 + versionName "4.8.0" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk diff --git a/ultrasonic/detekt-baseline.xml b/ultrasonic/detekt-baseline.xml index a8789e05..cae63ce3 100644 --- a/ultrasonic/detekt-baseline.xml +++ b/ultrasonic/detekt-baseline.xml @@ -1,9 +1,7 @@ - - TooManyFunctions:PlaybackService.kt$PlaybackService : MediaLibraryServiceKoinComponentCoroutineScope - UnusedPrivateMember:UApp.kt$private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder - ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.<no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) ) + + ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Failed to write log to %s", file) ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Log file rotated, logging into file %s", file?.name) ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name) @@ -11,19 +9,11 @@ LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?) LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?) LongMethod:PlaylistsFragment.kt$PlaylistsFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean - LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken, additionalId: String? ) LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean - LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array<ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) ) MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192 - MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50 MagicNumber:RESTMusicService.kt$RESTMusicService$206 - NestedBlockDepth:DownloadHandler.kt$DownloadHandler$private fun downloadRecursively( fragment: Fragment, id: String, name: String?, isShare: Boolean, isDirectory: Boolean, save: Boolean, append: Boolean, autoPlay: Boolean, shuffle: Boolean, background: Boolean, playNext: Boolean, unpin: Boolean, isArtist: Boolean ) - TooGenericExceptionCaught:FileLoggerTree.kt$FileLoggerTree$x: Throwable - TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$x: Throwable TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService - UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle - - + diff --git a/ultrasonic/lint-baseline.xml b/ultrasonic/lint-baseline.xml index f77f8b2f..45e1a6d3 100644 --- a/ultrasonic/lint-baseline.xml +++ b/ultrasonic/lint-baseline.xml @@ -70,50 +70,6 @@ column="1"/> - - - - - - - - - - - - - - - - + android:preserveLegacyExternalStorage="true" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="false" + android:theme="@style/Theme.Material3.DynamicColors.Dark" + android:usesCleartextTraffic="true" + tools:ignore="UnusedAttribute" + tools:targetApi="q"> - + + + + + - - - - - - diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ChatFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ChatFragment.java deleted file mode 100644 index 6a091f72..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ChatFragment.java +++ /dev/null @@ -1,297 +0,0 @@ -package org.moire.ultrasonic.fragment; - -import android.os.Bundle; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.inputmethod.EditorInfo; -import android.widget.EditText; -import android.widget.ListAdapter; -import android.widget.ListView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.ChatMessage; -import org.moire.ultrasonic.service.MusicService; -import org.moire.ultrasonic.service.MusicServiceFactory; -import org.moire.ultrasonic.util.BackgroundTask; -import org.moire.ultrasonic.util.CancellationToken; -import org.moire.ultrasonic.util.FragmentBackgroundTask; -import org.moire.ultrasonic.util.Settings; -import org.moire.ultrasonic.util.Util; -import org.moire.ultrasonic.view.ChatAdapter; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Timer; -import java.util.TimerTask; - -import kotlin.Lazy; - -import static org.koin.java.KoinJavaComponent.inject; - -import com.google.android.material.button.MaterialButton; - -/** - * Provides online chat functionality - */ -public class ChatFragment extends Fragment { - - private ListView chatListView; - private EditText messageEditText; - private MaterialButton sendButton; - private Timer timer; - private volatile static Long lastChatMessageTime = (long) 0; - private static final ArrayList messageList = new ArrayList<>(); - private CancellationToken cancellationToken; - private SwipeRefreshLayout swipeRefresh; - - private final Lazy activeServerProvider = inject(ActiveServerProvider.class); - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - Util.applyTheme(this.getContext()); - super.onCreate(savedInstanceState); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - return inflater.inflate(R.layout.chat, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - swipeRefresh = view.findViewById(R.id.chat_refresh); - swipeRefresh.setEnabled(false); - - cancellationToken = new CancellationToken(); - messageEditText = view.findViewById(R.id.chat_edittext); - sendButton = view.findViewById(R.id.chat_send); - - sendButton.setOnClickListener(view1 -> sendMessage()); - - chatListView = view.findViewById(R.id.chat_entries_list); - chatListView.setTranscriptMode(ListView.TRANSCRIPT_MODE_ALWAYS_SCROLL); - chatListView.setStackFromBottom(true); - - String serverName = activeServerProvider.getValue().getActiveServer().getName(); - String userName = activeServerProvider.getValue().getActiveServer().getUserName(); - String title = String.format("%s [%s@%s]", getResources().getString(R.string.button_bar_chat), userName, serverName); - - FragmentTitle.Companion.setTitle(this, title); - setHasOptionsMenu(true); - - messageEditText.setImeActionLabel("Send", KeyEvent.KEYCODE_ENTER); - - messageEditText.addTextChangedListener(new TextWatcher() - { - @Override - public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) - { - } - - @Override - public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) - { - } - - @Override - public void afterTextChanged(Editable editable) - { - sendButton.setEnabled(!Util.isNullOrWhiteSpace(editable.toString())); - } - }); - - messageEditText.setOnEditorActionListener((v, actionId, event) -> { - if (actionId == EditorInfo.IME_ACTION_DONE || (actionId == EditorInfo.IME_NULL && event.getAction() == KeyEvent.ACTION_DOWN)) - { - sendMessage(); - return true; - } - - return false; - }); - - load(); - timerMethod(); - } - - @Override - public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { - inflater.inflate(R.menu.chat, menu); - super.onCreateOptionsMenu(menu, inflater); - } - - /* - * Listen for option item selections so that we receive a notification - * when the user requests a refresh by selecting the refresh action bar item. - */ - @Override - public boolean onOptionsItemSelected(MenuItem item) { - // Check if user triggered a refresh: - if (item.getItemId() == R.id.menu_refresh) { - // Start the refresh background task. - load(); - return true; - } - // User didn't trigger a refresh, let the superclass handle this action - return super.onOptionsItemSelected(item); - } - - @Override - public void onResume() - { - super.onResume(); - - if (!messageList.isEmpty()) - { - ListAdapter chatAdapter = new ChatAdapter(getContext(), messageList); - chatListView.setAdapter(chatAdapter); - } - - if (timer == null) - { - timerMethod(); - } - } - - @Override - public void onPause() - { - super.onPause(); - - if (timer != null) - { - timer.cancel(); - timer = null; - } - } - - @Override - public void onDestroyView() { - cancellationToken.cancel(); - super.onDestroyView(); - } - - private void timerMethod() - { - int refreshInterval = Settings.getChatRefreshInterval(); - - if (refreshInterval > 0) - { - timer = new Timer(); - - timer.schedule(new TimerTask() - { - @Override - public void run() - { - getActivity().runOnUiThread(() -> load()); - } - }, refreshInterval, refreshInterval); - } - } - - private void sendMessage() - { - if (messageEditText != null) - { - final String message; - Editable text = messageEditText.getText(); - - if (text == null) - { - return; - } - - message = text.toString(); - - if (!Util.isNullOrWhiteSpace(message)) - { - messageEditText.setText(""); - - BackgroundTask task = new FragmentBackgroundTask(getActivity(), false, swipeRefresh, cancellationToken) - { - @Override - protected Void doInBackground() throws Throwable - { - MusicService musicService = MusicServiceFactory.getMusicService(); - musicService.addChatMessage(message); - return null; - } - - @Override - protected void done(Void result) - { - load(); - } - }; - - task.execute(); - } - } - } - - private synchronized void load() - { - BackgroundTask> task = new FragmentBackgroundTask>(getActivity(), false, swipeRefresh, cancellationToken) - { - @Override - protected List doInBackground() throws Throwable - { - MusicService musicService = MusicServiceFactory.getMusicService(); - return musicService.getChatMessages(lastChatMessageTime); - } - - @Override - protected void done(List result) - { - if (result != null && !result.isEmpty()) - { - // Reset lastChatMessageTime if we have a newer message - for (ChatMessage message : result) - { - if (message.getTime() > lastChatMessageTime) - { - lastChatMessageTime = message.getTime(); - } - } - - // Reverse results to show them on the bottom - Collections.reverse(result); - messageList.addAll(result); - - ListAdapter chatAdapter = new ChatAdapter(getContext(), messageList); - chatListView.setAdapter(chatAdapter); - } - } - - @Override - protected void error(Throwable error) { - // Stop the timer in case of an error, otherwise it may repeat the error message forever - if (timer != null) - { - timer.cancel(); - timer = null; - } - super.error(error); - } - }; - - task.execute(); - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java deleted file mode 100644 index bf59e1c8..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.util; - -import android.app.Activity; -import android.os.Handler; - -/** - * @author Sindre Mehus - */ -public abstract class BackgroundTask implements ProgressListener -{ - private final Activity activity; - private final Handler handler; - - public BackgroundTask(Activity activity) - { - this.activity = activity; - handler = new Handler(); - } - - protected Activity getActivity() - { - return activity; - } - - protected Handler getHandler() - { - return handler; - } - - public abstract void execute(); - - protected abstract T doInBackground() throws Throwable; - - protected abstract void done(T result); - - protected void error(Throwable error) - { - CommunicationError.handleError(error, activity); - } - - protected String getErrorMessage(Throwable error) - { - return CommunicationError.getErrorMessage(error); - } - - @Override - public abstract void updateProgress(final String message); - - @Override - public void updateProgress(int messageId) - { - updateProgress(activity.getResources().getString(messageId)); - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/FragmentBackgroundTask.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/FragmentBackgroundTask.java deleted file mode 100644 index 7625a8eb..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/FragmentBackgroundTask.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.moire.ultrasonic.util; - -import android.app.Activity; - -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -/** - * @author Sindre Mehus - * @version $Id$ - */ -public abstract class FragmentBackgroundTask extends BackgroundTask -{ - private final boolean changeProgress; - private final SwipeRefreshLayout swipe; - private final CancellationToken cancel; - - public FragmentBackgroundTask(Activity activity, boolean changeProgress, - SwipeRefreshLayout swipe, CancellationToken cancel) - { - super(activity); - this.changeProgress = changeProgress; - this.swipe = swipe; - this.cancel = cancel; - } - - @Override - public void execute() - { - if (changeProgress) - { - if (swipe != null) swipe.setRefreshing(true); - } - - new Thread() - { - @Override - public void run() - { - try - { - final T result = doInBackground(); - if (cancel.isCancellationRequested()) - { - return; - } - - getHandler().post(new Runnable() - { - @Override - public void run() - { - if (changeProgress) - { - if (swipe != null) swipe.setRefreshing(false); - } - - done(result); - } - }); - } - catch (final Throwable t) - { - if (cancel.isCancellationRequested()) - { - return; - } - getHandler().post(new Runnable() - { - @Override - public void run() - { - if (changeProgress) - { - if (swipe != null) swipe.setRefreshing(false); - } - - error(t); - } - }); - } - } - }.start(); - } - - @Override - public void updateProgress(final String message) - { - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java deleted file mode 100644 index a2c25fa2..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java +++ /dev/null @@ -1,66 +0,0 @@ -package org.moire.ultrasonic.util; - -import android.app.Activity; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -/** - * @author Sindre Mehus - * @version $Id$ - */ -public abstract class LoadingTask extends BackgroundTask -{ - private final SwipeRefreshLayout swipe; - private final CancellationToken cancel; - - public LoadingTask(Activity activity, SwipeRefreshLayout swipe, CancellationToken cancel) - { - super(activity); - this.swipe = swipe; - this.cancel = cancel; - } - - - @Override - public void execute() - { - swipe.setRefreshing(true); - - new Thread() - { - @Override - public void run() - { - try - { - final T result = doInBackground(); - if (cancel.isCancellationRequested()) - { - return; - } - - getHandler().post(() -> { - swipe.setRefreshing(false); - done(result); - }); - } - catch (final Throwable t) - { - if (cancel.isCancellationRequested()) - { - return; - } - - getHandler().post(() -> { - swipe.setRefreshing(false); - error(t); - }); - } - } - }.start(); - } - - @Override - public void updateProgress(final String message) - { - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ProgressListener.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/ProgressListener.java deleted file mode 100644 index 3b32a736..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ProgressListener.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.util; - -/** - * @author Sindre Mehus - */ -public interface ProgressListener -{ - void updateProgress(String message); - - void updateProgress(int messageId); -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShareDetails.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShareDetails.java deleted file mode 100644 index 005df384..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShareDetails.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.moire.ultrasonic.util; - -import org.moire.ultrasonic.domain.Track; - -import java.util.List; - -/** - * Created by Josh on 12/17/13. - */ -public class ShareDetails -{ - public String Description; - public boolean ShareOnServer; - public long Expiration; - public List Entries; -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/TimeSpanPreference.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/TimeSpanPreference.java deleted file mode 100644 index 799f7f2d..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/TimeSpanPreference.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.moire.ultrasonic.util; - -import android.content.Context; -import android.util.AttributeSet; -import androidx.preference.DialogPreference; -import org.moire.ultrasonic.R; - -/** - * Created by Joshua Bahnsen on 12/22/13. - */ -public class TimeSpanPreference extends DialogPreference -{ - Context context; - - public TimeSpanPreference(Context context, AttributeSet attrs) - { - super(context, attrs); - this.context = context; - - setPositiveButtonText(android.R.string.ok); - setNegativeButtonText(android.R.string.cancel); - - setDialogIcon(null); - - } - - public String getText() - { - String persisted = getPersistedString(""); - - if (!"".equals(persisted)) - { - return persisted.replace(':', ' '); - } - - return this.context.getResources().getString(R.string.time_span_disabled); - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index 4cadda0d..b6b6f8e4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -23,7 +23,6 @@ import android.view.MenuItem import android.view.View import android.widget.ImageView import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat @@ -39,6 +38,7 @@ import androidx.media3.common.Player.STATE_READY import androidx.navigation.NavController import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.findNavController import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.navigateUp import androidx.navigation.ui.onNavDestinationSelected @@ -50,6 +50,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.android.ext.android.inject +import org.koin.androidx.scope.ScopeActivity import org.koin.androidx.viewmodel.ext.android.viewModel import org.moire.ultrasonic.NavigationGraphDirections import org.moire.ultrasonic.R @@ -63,7 +64,6 @@ import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign -import org.moire.ultrasonic.subsonic.DownloadHandler import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.InfoDialog import org.moire.ultrasonic.util.LocaleHelper @@ -81,7 +81,7 @@ import timber.log.Timber * onCreate/onResume/onDestroy methods... */ @Suppress("TooManyFunctions") -class NavigationActivity : AppCompatActivity() { +class NavigationActivity : ScopeActivity() { private var videoMenuItem: MenuItem? = null private var chatMenuItem: MenuItem? = null private var bookmarksMenuItem: MenuItem? = null @@ -485,15 +485,19 @@ class NavigationActivity : AppCompatActivity() { val currentFragment = host?.childFragmentManager?.fragments?.last() ?: return val service = MusicServiceFactory.getMusicService() val musicDirectory = service.getRandomSongs(Settings.maxSongs) - val downloadHandler: DownloadHandler by inject() - downloadHandler.addTracksToMediaController( + + mediaPlayerManager.addToPlaylist( songs = musicDirectory.getTracks(), - insertionMode = MediaPlayerManager.InsertionMode.CLEAR, autoPlay = true, shuffle = false, - fragment = currentFragment, - playlistName = null + insertionMode = MediaPlayerManager.InsertionMode.CLEAR ) + + if (Settings.shouldTransitionOnPlayback) { + currentFragment.findNavController().popBackStack(R.id.playerFragment, true) + currentFragment.findNavController().navigate(R.id.playerFragment) + } + return } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt index a50f082f..5633b35b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt @@ -17,6 +17,10 @@ import android.widget.TextView import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.drakeet.multitype.ItemViewBinder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.R @@ -63,16 +67,21 @@ class ArtistRowBinder( if (showArtistPicture()) { holder.coverArt.visibility = View.VISIBLE - val key = FileUtil.getArtistArtKey(item.name, false) - imageLoaderProvider.executeOn { - it.loadImage( - view = holder.coverArt, - id = holder.coverArtId, - key = key, - large = false, - size = 0, - defaultResourceId = R.drawable.ic_contact_picture - ) + CoroutineScope(Dispatchers.IO).launch { + val key = FileUtil.getArtistArtKey(item.name, false) + + withContext(Dispatchers.Main) { + imageLoaderProvider.executeOn { + it.loadImage( + view = holder.coverArt, + id = holder.coverArtId, + key = key, + large = false, + size = 0, + defaultResourceId = R.drawable.ic_contact_picture + ) + } + } } } else { holder.coverArt.visibility = View.GONE diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index d462c57c..c644b757 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -37,7 +37,6 @@ import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util.themeColor -import timber.log.Timber const val INDICATOR_THICKNESS_INDEFINITE = 5 const val INDICATOR_THICKNESS_DEFINITE = 10 @@ -80,10 +79,6 @@ class TrackViewHolder(val view: View) : private var rxBusSubscription: CompositeDisposable? = null - init { - Timber.v("New ViewHolder created") - } - @Suppress("ComplexMethod") fun setSong( song: Track, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt index 06672dc1..0d1d51c1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt @@ -7,8 +7,6 @@ package org.moire.ultrasonic.data -import android.os.Handler -import android.os.Looper import androidx.room.Room import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -106,6 +104,30 @@ class ActiveServerProvider( } } + /** + * Sets the Active Server by its unique id + * @param serverId: The id of the desired server + */ + fun setActiveServerById(serverId: Int) { + val oldServerId = Settings.activeServer + if (oldServerId == serverId) return + + // Notify components about the change before actually resetting the MusicService + // so they can react by e.g. stopping playback on the old server + RxBus.activeServerChangingPublisher.onNext(oldServerId) + + // Use a coroutine to post the server change to the end of the message queue + launch { + withContext(Dispatchers.Main) { + resetMusicService() + Settings.activeServer = serverId + + RxBus.activeServerChangedPublisher.onNext(getActiveServer(serverId)) + Timber.i("setActiveServerById done, new id: %s", serverId) + } + } + } + @Synchronized fun getActiveMetaDatabase(): MetaDatabase { val activeServer = getActiveServerId() @@ -234,29 +256,6 @@ class ActiveServerProvider( return Settings.activeServer } - /** - * Sets the Active Server by its unique id - * @param serverId: The id of the desired server - */ - fun setActiveServerById(serverId: Int) { - val oldServerId = Settings.activeServer - if (oldServerId == serverId) return - - // Notify components about the change before actually resetting the MusicService - // so they can react by e.g. stopping playback on the old server - RxBus.activeServerChangingPublisher.onNext(oldServerId) - - // Post the server change to the end of the message queue, - // so the cleanup have time to finish - Handler(Looper.getMainLooper()).post { - resetMusicService() - Settings.activeServer = serverId - - RxBus.activeServerChangedPublisher.onNext(serverId) - Timber.i("setActiveServerById done, new id: %s", serverId) - } - } - /** * Queries if Scrobbling is enabled */ diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/BasicDaos.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/BasicDaos.kt index 3355efe0..54fdad68 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/BasicDaos.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/BasicDaos.kt @@ -91,7 +91,7 @@ interface GenericDao { */ @Insert(onConflict = OnConflictStrategy.IGNORE) @JvmSuppressWildcards - fun insertIgnoring(obj: List?): List + fun insertIgnoring(obj: List): List /** * Update an object from the database. @@ -109,7 +109,7 @@ interface GenericDao { */ @Update @JvmSuppressWildcards - fun update(obj: List?) + fun update(obj: List) /** * Delete an object from the database diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CachedDataSource.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/CachedDataSource.kt similarity index 98% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CachedDataSource.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/CachedDataSource.kt index b5954746..9f67f7f2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CachedDataSource.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/CachedDataSource.kt @@ -1,11 +1,11 @@ /* * CachedDataSource.kt - * Copyright (C) 2009-2022 Ultrasonic developers + * Copyright (C) 2009-2023 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ -package org.moire.ultrasonic.playback +package org.moire.ultrasonic.data import android.net.Uri import androidx.core.net.toUri diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt index 9bffd3f8..a0e31260 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -1,14 +1,15 @@ package org.moire.ultrasonic.di -import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider +import org.moire.ultrasonic.util.CacheCleaner /** * This Koin module contains the registration of general classes needed for Ultrasonic */ val applicationModule = module { single { ActiveServerProvider(get()) } - single { ImageLoaderProvider(androidContext()) } + single { ImageLoaderProvider() } + single { CacheCleaner() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index 823a84fa..d4799b98 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -1,19 +1,27 @@ package org.moire.ultrasonic.di import org.koin.dsl.module +import org.moire.ultrasonic.activity.NavigationActivity import org.moire.ultrasonic.service.ExternalStorageMonitor import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.PlaybackStateSerializer +import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker +import org.moire.ultrasonic.subsonic.ShareHandler /** * This Koin module contains the registration of classes related to the media player */ val mediaPlayerModule = module { - single { MediaPlayerLifecycleSupport() } + + // These are dependency-free single { PlaybackStateSerializer() } single { ExternalStorageMonitor() } + single { NetworkAndStorageChecker() } + single { ShareHandler() } - // TODO Ideally this can be cleaned up when all circular references are removed. - single { MediaPlayerManager(get(), get(), get()) } + scope { + scoped { MediaPlayerManager(get(), get()) } + scoped { MediaPlayerLifecycleSupport(get(), get(), get(), get()) } + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt index 5dd8fb25..6f723c89 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MusicServiceModule.kt @@ -3,7 +3,6 @@ package org.moire.ultrasonic.di import kotlin.math.abs import okhttp3.logging.HttpLoggingInterceptor -import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named import org.koin.dsl.module import org.moire.ultrasonic.BuildConfig @@ -16,9 +15,6 @@ import org.moire.ultrasonic.service.CachedMusicService import org.moire.ultrasonic.service.MusicService import org.moire.ultrasonic.service.OfflineMusicService import org.moire.ultrasonic.service.RESTMusicService -import org.moire.ultrasonic.subsonic.DownloadHandler -import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker -import org.moire.ultrasonic.subsonic.ShareHandler import org.moire.ultrasonic.util.Constants /** @@ -68,8 +64,4 @@ val musicServiceModule = module { single(named(OFFLINE_MUSIC_SERVICE)) { OfflineMusicService() } - - single { DownloadHandler(get(), get()) } - single { NetworkAndStorageChecker(androidContext()) } - single { ShareHandler(androidContext()) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AboutFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AboutFragment.kt index 1e1ed54a..c7846cdd 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AboutFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AboutFragment.kt @@ -18,7 +18,6 @@ import android.widget.TextView import androidx.fragment.app.Fragment import java.util.Locale import org.moire.ultrasonic.R -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.util.Util.applyTheme import org.moire.ultrasonic.util.Util.getVersionName @@ -56,7 +55,7 @@ class AboutFragment : Fragment() { versionName ) - setTitle(this@AboutFragment, getString(R.string.menu_about)) + FragmentTitle.setTitle(this@AboutFragment, getString(R.string.menu_about)) titleText?.text = title webPageButton?.setOnClickListener { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt index 9e7cb6c5..2a8c29c3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListFragment.kt @@ -5,8 +5,6 @@ * Distributed under terms of the GNU GPLv3 license. */ -@file:Suppress("NAME_SHADOWING") - package org.moire.ultrasonic.fragment import android.os.Bundle @@ -32,6 +30,7 @@ import org.moire.ultrasonic.domain.Album import org.moire.ultrasonic.model.AlbumListModel import org.moire.ultrasonic.util.LayoutType import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.toastingExceptionHandler import org.moire.ultrasonic.view.FilterButtonBar import org.moire.ultrasonic.view.SortOrder import org.moire.ultrasonic.view.ViewCapabilities @@ -76,9 +75,10 @@ class AlbumListFragment( } private fun fetchAlbums(refresh: Boolean = navArgs.refresh, append: Boolean = navArgs.append) { - - listModel.viewModelScope.launch(handler) { - refreshListView?.isRefreshing = true + listModel.viewModelScope.launch( + toastingExceptionHandler() + ) { + swipeRefresh?.isRefreshing = true if (navArgs.byArtist) { listModel.getAlbumsOfArtist( @@ -95,7 +95,7 @@ class AlbumListFragment( refresh = refresh or append ) } - refreshListView?.isRefreshing = false + swipeRefresh?.isRefreshing = false } } @@ -185,8 +185,8 @@ class AlbumListFragment( super.onViewCreated(view, savedInstanceState) // Setup refresh handler - refreshListView = view.findViewById(refreshListId) - refreshListView?.setOnRefreshListener { + swipeRefresh = view.findViewById(refreshListId) + swipeRefresh?.setOnRefreshListener { fetchAlbums(refresh = true) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt index 87a0ac8d..d4b873f3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListFragment.kt @@ -43,7 +43,7 @@ class ArtistListFragment : EntryListFragment() { * The central function to pass a query to the model and return a LiveData object */ override fun getLiveData(refresh: Boolean, append: Boolean): LiveData> { - return listModel.getItems(navArgs.refresh || refresh, refreshListView!!) + return listModel.getItems(navArgs.refresh || refresh, swipeRefresh!!) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt index b959742e..7c235e5e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt @@ -16,8 +16,9 @@ import org.moire.ultrasonic.R import org.moire.ultrasonic.adapters.BaseAdapter import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle -import org.moire.ultrasonic.service.PlaybackState +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle +import org.moire.ultrasonic.service.MediaPlayerManager +import org.moire.ultrasonic.util.toastingExceptionHandler /** * Lists the Bookmarks available on the server @@ -40,10 +41,12 @@ class BookmarksFragment : TrackCollectionFragment() { refresh: Boolean, append: Boolean ): LiveData> { - listModel.viewModelScope.launch(handler) { - refreshListView?.isRefreshing = true + listModel.viewModelScope.launch( + toastingExceptionHandler() + ) { + swipeRefresh?.isRefreshing = true listModel.getBookmarks() - refreshListView?.isRefreshing = false + swipeRefresh?.isRefreshing = false } return listModel.currentList } @@ -61,22 +64,21 @@ class BookmarksFragment : TrackCollectionFragment() { } /** - * Custom playback function which uses the restore functionality. A bit of a hack.. + * Play the selected tracks at the bookmarked position */ private fun playNow(songs: List) { if (songs.isNotEmpty()) { - val state = PlaybackState( + mediaPlayerManager.addToPlaylist( songs = songs, - currentPlayingIndex = 0, - currentPlayingPosition = songs[0].bookmarkPosition + autoPlay = false, + shuffle = false, + insertionMode = MediaPlayerManager.InsertionMode.CLEAR ) - mediaPlayerManager.restore( - state = state, - autoPlay = true, - newPlaylist = true - ) + mediaPlayerManager.seekTo(0, songs[0].bookmarkPosition) + mediaPlayerManager.prepare() + mediaPlayerManager.play() } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt index 075b3e31..4b1eed22 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt @@ -11,10 +11,10 @@ import android.os.Bundle import android.view.MenuItem import android.view.View import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import io.reactivex.rxjava3.disposables.CompositeDisposable -import org.moire.ultrasonic.R +import org.koin.core.component.KoinScopeComponent +import org.koin.core.component.inject import org.moire.ultrasonic.adapters.FolderSelectorBinder import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Artist @@ -23,17 +23,17 @@ import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign -import org.moire.ultrasonic.subsonic.DownloadAction -import org.moire.ultrasonic.subsonic.DownloadHandler +import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenu /** * An extension of the MultiListFragment, with a few helper functions geared * towards the display of MusicDirectory.Entries. * @param T: The type of data which will be used (must extend GenericEntry) */ -abstract class EntryListFragment : MultiListFragment() { +abstract class EntryListFragment : MultiListFragment(), KoinScopeComponent { private var rxBusSubscription: CompositeDisposable = CompositeDisposable() + private val mediaPlayerManager: MediaPlayerManager by inject() /** * Whether to show the folder selector @@ -46,7 +46,7 @@ abstract class EntryListFragment : MultiListFragment() { override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean { val isArtist = (item is Artist) - return handleContextMenu(menuItem, item, isArtist, downloadHandler, this) + return handleContextMenu(menuItem, item, isArtist, mediaPlayerManager, this) } override fun onItemClick(item: T) { @@ -73,7 +73,7 @@ abstract class EntryListFragment : MultiListFragment() { currentSetting.musicFolderId = it.id serverSettingsModel.updateItem(currentSetting) } - listModel.refresh(refreshListView!!) + listModel.refresh(swipeRefresh!!) } viewAdapter.register( @@ -90,7 +90,7 @@ abstract class EntryListFragment : MultiListFragment() { * What to do when the list has changed */ override val defaultObserver: (List) -> Unit = { - emptyView.isVisible = it.isEmpty() && !(refreshListView?.isRefreshing ?: false) + emptyView.isVisible = it.isEmpty() && !(swipeRefresh?.isRefreshing ?: false) if (showFolderHeader()) { val list = mutableListOf(folderHeader) @@ -119,65 +119,4 @@ abstract class EntryListFragment : MultiListFragment() { header } - - companion object { - @Suppress("LongMethod") - internal fun handleContextMenu( - menuItem: MenuItem, - item: Identifiable, - isArtist: Boolean, - downloadHandler: DownloadHandler, - fragment: Fragment - ): Boolean { - when (menuItem.itemId) { - R.id.menu_play_now -> - downloadHandler.fetchTracksAndAddToController( - fragment, - item.id, - insertionMode = MediaPlayerManager.InsertionMode.CLEAR, - autoPlay = true, - isArtist = isArtist - ) - R.id.menu_play_next -> - downloadHandler.fetchTracksAndAddToController( - fragment, - item.id, - insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT, - autoPlay = true, - isArtist = isArtist - ) - R.id.menu_play_last -> - downloadHandler.fetchTracksAndAddToController( - fragment, - item.id, - insertionMode = MediaPlayerManager.InsertionMode.APPEND, - autoPlay = false, - isArtist = isArtist - ) - R.id.menu_pin -> - downloadHandler.justDownload( - action = DownloadAction.PIN, - fragment, - item.id, - isArtist = isArtist - ) - R.id.menu_unpin -> - downloadHandler.justDownload( - action = DownloadAction.UNPIN, - fragment, - item.id, - isArtist = isArtist - ) - R.id.menu_download -> - downloadHandler.justDownload( - action = DownloadAction.DOWNLOAD, - fragment, - item.id, - isArtist = isArtist - ) - else -> return false - } - return true - } - } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EqualizerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EqualizerFragment.kt index 6e729045..047ed65d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EqualizerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EqualizerFragment.kt @@ -26,7 +26,7 @@ import java.util.HashMap import java.util.Locale import org.moire.ultrasonic.R import org.moire.ultrasonic.audiofx.EqualizerController -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.util.Util.applyTheme import timber.log.Timber diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/FragmentTitle.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/FragmentTitle.kt index 01386265..81b79cca 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/FragmentTitle.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/FragmentTitle.kt @@ -7,24 +7,22 @@ import androidx.navigation.fragment.NavHostFragment /** * Contains utility functions related to Fragment title handling */ -class FragmentTitle { - companion object { - fun setTitle(fragment: Fragment, title: CharSequence?) { - // Only set the title if our fragment is a direct child of the NavHostFragment... - if (fragment.parentFragment is NavHostFragment) { - (fragment.activity as AppCompatActivity).supportActionBar?.title = title - } - } - - fun setTitle(fragment: Fragment, id: Int) { - // Only set the title if our fragment is a direct child of the NavHostFragment... - if (fragment.parentFragment is NavHostFragment) { - (fragment.activity as AppCompatActivity).supportActionBar?.setTitle(id) - } - } - - fun getTitle(fragment: Fragment): CharSequence? { - return (fragment.activity as AppCompatActivity).supportActionBar?.title +object FragmentTitle { + fun setTitle(fragment: Fragment, title: CharSequence?) { + // Only set the title if our fragment is a direct child of the NavHostFragment... + if (fragment.parentFragment is NavHostFragment) { + (fragment.activity as AppCompatActivity).supportActionBar?.title = title } } + + fun setTitle(fragment: Fragment, id: Int) { + // Only set the title if our fragment is a direct child of the NavHostFragment... + if (fragment.parentFragment is NavHostFragment) { + (fragment.activity as AppCompatActivity).supportActionBar?.setTitle(id) + } + } + + fun getTitle(fragment: Fragment): CharSequence? { + return (fragment.activity as AppCompatActivity).supportActionBar?.title + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt index e64d1e80..6ef9e248 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MainFragment.kt @@ -20,7 +20,11 @@ import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import java.lang.ref.SoftReference -import org.koin.core.component.KoinComponent +import kotlin.collections.HashMap +import kotlin.collections.hashMapOf +import kotlin.collections.set +import org.koin.androidx.scope.ScopeFragment +import org.koin.core.component.KoinScopeComponent import org.moire.ultrasonic.NavigationGraphDirections import org.moire.ultrasonic.R import org.moire.ultrasonic.api.subsonic.models.AlbumListType @@ -34,7 +38,7 @@ import org.moire.ultrasonic.view.SortOrder import org.moire.ultrasonic.view.ViewCapabilities import timber.log.Timber -class MainFragment : Fragment(), KoinComponent { +class MainFragment : ScopeFragment(), KoinScopeComponent { private var filterButtonBar: FilterButtonBar? = null private var layoutType: LayoutType = LayoutType.COVER diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt index 6cc94b1d..43cc9b2a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt @@ -8,8 +8,6 @@ package org.moire.ultrasonic.fragment import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.view.LayoutInflater import android.view.MenuItem import android.view.View @@ -17,15 +15,14 @@ import android.view.ViewGroup import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import kotlinx.coroutines.CoroutineExceptionHandler import org.koin.android.ext.android.inject +import org.koin.androidx.scope.ScopeFragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.moire.ultrasonic.R import org.moire.ultrasonic.adapters.BaseAdapter @@ -33,21 +30,19 @@ import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.model.GenericListModel import org.moire.ultrasonic.model.ServerSettingsModel -import org.moire.ultrasonic.subsonic.DownloadHandler import org.moire.ultrasonic.subsonic.ImageLoaderProvider -import org.moire.ultrasonic.util.CommunicationError +import org.moire.ultrasonic.util.RefreshableFragment import org.moire.ultrasonic.util.Util /** * An abstract Model, which can be extended to display a list of items of type T from the API * @param T: The type of data which will be used (must extend GenericEntry) */ -abstract class MultiListFragment : Fragment() { +abstract class MultiListFragment : ScopeFragment(), RefreshableFragment { internal val activeServerProvider: ActiveServerProvider by inject() internal val serverSettingsModel: ServerSettingsModel by viewModel() internal val imageLoaderProvider: ImageLoaderProvider by inject() - protected val downloadHandler: DownloadHandler by inject() - protected var refreshListView: SwipeRefreshLayout? = null + override var swipeRefresh: SwipeRefreshLayout? = null internal var listView: RecyclerView? = null internal lateinit var viewManager: LinearLayoutManager internal lateinit var emptyView: ConstraintLayout @@ -97,16 +92,6 @@ abstract class MultiListFragment : Fragment() { */ open val refreshOnCreation: Boolean = true - /** - * The default Exception Handler for Coroutines - */ - val handler = CoroutineExceptionHandler { _, exception -> - Handler(Looper.getMainLooper()).post { - CommunicationError.handleError(exception, context) - } - refreshListView?.isRefreshing = false - } - open fun setTitle(title: String?) { if (title == null) { FragmentTitle.setTitle( @@ -124,7 +109,7 @@ abstract class MultiListFragment : Fragment() { * What to do when the list has changed */ internal open val defaultObserver: ((List) -> Unit) = { - emptyView.isVisible = it.isEmpty() && !(refreshListView?.isRefreshing?:false) + emptyView.isVisible = it.isEmpty() && !(swipeRefresh?.isRefreshing?:false) viewAdapter.submitList(it) } @@ -132,9 +117,9 @@ abstract class MultiListFragment : Fragment() { super.onViewCreated(view, savedInstanceState) // Setup refresh handler - refreshListView = view.findViewById(refreshListId) - refreshListView?.setOnRefreshListener { - listModel.refresh(refreshListView!!) + swipeRefresh = view.findViewById(refreshListId) + swipeRefresh?.setOnRefreshListener { + listModel.refresh(swipeRefresh!!) } // Populate the LiveData. This starts an API request in most cases diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt index 70eaf028..28cdb042 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt @@ -15,7 +15,6 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView -import androidx.fragment.app.Fragment import androidx.navigation.Navigation import androidx.navigation.fragment.findNavController import com.google.android.material.button.MaterialButton @@ -23,6 +22,7 @@ import io.reactivex.rxjava3.disposables.Disposable import java.lang.Exception import kotlin.math.abs import org.koin.android.ext.android.inject +import org.koin.androidx.scope.ScopeFragment import org.moire.ultrasonic.NavigationGraphDirections import org.moire.ultrasonic.R import org.moire.ultrasonic.service.MediaPlayerManager @@ -37,7 +37,7 @@ import timber.log.Timber /** * Contains the mini-now playing information box displayed at the bottom of the screen */ -class NowPlayingFragment : Fragment() { +class NowPlayingFragment : ScopeFragment() { private var downX = 0f private var downY = 0f diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 5c108710..32023c46 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -40,7 +40,6 @@ import androidx.core.content.res.ResourcesCompat import androidx.core.view.MenuHost import androidx.core.view.MenuProvider import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem @@ -74,12 +73,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import org.koin.android.ext.android.inject -import org.koin.core.component.KoinComponent +import org.koin.androidx.scope.ScopeFragment +import org.koin.core.component.KoinScopeComponent import org.moire.ultrasonic.R import org.moire.ultrasonic.adapters.BaseAdapter import org.moire.ultrasonic.adapters.TrackViewBinder import org.moire.ultrasonic.api.subsonic.models.AlbumListType -import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.audiofx.EqualizerController import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags @@ -88,7 +87,7 @@ import org.moire.ultrasonic.databinding.CurrentPlayingBinding import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.RxBus @@ -102,6 +101,7 @@ import org.moire.ultrasonic.util.ConfirmationDialog import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util.themeColor +import org.moire.ultrasonic.util.Util.toast import org.moire.ultrasonic.util.toTrack import org.moire.ultrasonic.view.AutoRepeatButton import timber.log.Timber @@ -112,9 +112,9 @@ import timber.log.Timber */ @Suppress("LargeClass", "TooManyFunctions", "MagicNumber") class PlayerFragment : - Fragment(), + ScopeFragment(), GestureDetector.OnGestureListener, - KoinComponent, + KoinScopeComponent, CoroutineScope by CoroutineScope(Dispatchers.Main) { // Settings @@ -362,14 +362,14 @@ class PlayerFragment : onPlaylistChanged() when (newRepeat) { - 0 -> Util.toast( - context, R.string.download_repeat_off + 0 -> toast( + R.string.download_repeat_off ) - 1 -> Util.toast( - context, R.string.download_repeat_single + 1 -> toast( + R.string.download_repeat_single ) - 2 -> Util.toast( - context, R.string.download_repeat_all + 2 -> toast( + R.string.download_repeat_all ) else -> { } @@ -431,7 +431,7 @@ class PlayerFragment : // Query the Jukebox state in an IO Context ioScope.launch(CommunicationError.getHandler(context)) { try { - jukeboxAvailable = mediaPlayerManager.isJukeboxAvailable + jukeboxAvailable = getMusicService().isJukeboxAvailable() } catch (all: Exception) { Timber.e(all) } @@ -478,9 +478,9 @@ class PlayerFragment : val isEnabled = mediaPlayerManager.toggleShuffle() if (isEnabled) { - Util.toast(activity, R.string.download_menu_shuffle_on) + toast(R.string.download_menu_shuffle_on) } else { - Util.toast(activity, R.string.download_menu_shuffle_off) + toast(R.string.download_menu_shuffle_off) } updateShuffleButtonState(isEnabled) @@ -575,8 +575,7 @@ class PlayerFragment : equalizerMenuItem.isVisible = isEqualizerAvailable } - val mediaPlayerController = mediaPlayerManager - val track = mediaPlayerController.currentMediaItem?.toTrack() + val track = mediaPlayerManager.currentMediaItem?.toTrack() if (track != null) { currentSong = track @@ -592,7 +591,7 @@ class PlayerFragment : goToArtist.isVisible = false } - if (mediaPlayerController.keepScreenOn) { + if (mediaPlayerManager.keepScreenOn) { screenOption?.setTitle(R.string.download_menu_screen_off) } else { screenOption?.setTitle(R.string.download_menu_screen_on) @@ -601,7 +600,7 @@ class PlayerFragment : if (jukeboxOption != null) { jukeboxOption.isEnabled = jukeboxAvailable jukeboxOption.isVisible = jukeboxAvailable - if (mediaPlayerController.isJukeboxEnabled) { + if (mediaPlayerManager.isJukeboxEnabled) { jukeboxOption.setTitle(R.string.download_menu_jukebox_off) } else { jukeboxOption.setTitle(R.string.download_menu_jukebox_on) @@ -699,8 +698,7 @@ class PlayerFragment : R.id.menu_item_jukebox -> { val jukeboxEnabled = !mediaPlayerManager.isJukeboxEnabled mediaPlayerManager.isJukeboxEnabled = jukeboxEnabled - Util.toast( - context, + toast( if (jukeboxEnabled) R.string.download_jukebox_on else R.string.download_jukebox_off, false @@ -742,7 +740,7 @@ class PlayerFragment : R.string.download_bookmark_set_at_position, bookmarkTime ) - Util.toast(context, msg) + toast(msg) return true } R.id.menu_item_bookmark_delete -> { @@ -758,36 +756,25 @@ class PlayerFragment : Timber.e(all) } }.start() - Util.toast(context, R.string.download_bookmark_removed) + toast(R.string.download_bookmark_removed) return true } R.id.menu_item_share -> { - val mediaPlayerController = mediaPlayerManager - val tracks: MutableList = ArrayList() - val playlist = mediaPlayerController.playlist - for (item in playlist) { - val playlistEntry = item.toTrack() - tracks.add(playlistEntry) + val tracks = mediaPlayerManager.playlist.map { + it.toTrack() } shareHandler.createShare( this, tracks = tracks, - swipe = null, - cancellationToken = cancellationToken, ) return true } R.id.menu_item_share_song -> { if (track == null) return true - val tracks: MutableList = ArrayList() - tracks.add(track) - shareHandler.createShare( this, - tracks, - swipe = null, - cancellationToken = cancellationToken + listOf(track), ) return true } @@ -804,7 +791,7 @@ class PlayerFragment : } private fun savePlaylistInBackground(playlistName: String) { - Util.toast(context, resources.getString(R.string.download_playlist_saving, playlistName)) + toast(resources.getString(R.string.download_playlist_saving, playlistName)) mediaPlayerManager.suggestedPlaylistName = playlistName // The playlist can be acquired only from the main thread @@ -817,7 +804,7 @@ class PlayerFragment : musicService.createPlaylist(null, playlistName, entries) }.invokeOnCompletion { if (it == null || it is CancellationException) { - Util.toast(UApp.applicationContext(), R.string.download_playlist_done) + toast(R.string.download_playlist_done) } else { Timber.e(it, "Exception has occurred in savePlaylistInBackground") val msg = String.format( @@ -826,7 +813,7 @@ class PlayerFragment : resources.getString(R.string.download_playlist_error), CommunicationError.getErrorMessage(it) ) - Util.toast(UApp.applicationContext(), msg) + toast(msg) } } } @@ -940,7 +927,7 @@ class PlayerFragment : item?.mediaMetadata?.title ) - Util.toast(context, songRemoved) + toast(songRemoved) // Remove the item from the playlist mediaPlayerManager.removeFromPlaylist(pos) @@ -1041,15 +1028,14 @@ class PlayerFragment : } private fun onPlaylistChanged() { - val mediaPlayerController = mediaPlayerManager // Try to display playlist in play order - val list = mediaPlayerController.playlistInPlayOrder + val list = mediaPlayerManager.playlistInPlayOrder emptyTextView.setText(R.string.playlist_empty) viewAdapter.submitList(list.map(MediaItem::toTrack)) progressIndicator.isVisible = false emptyView.isVisible = list.isEmpty() - updateRepeatButtonState(mediaPlayerController.repeatMode) + updateRepeatButtonState(mediaPlayerManager.repeatMode) } private fun onTrackChanged() { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt index 40d71cf3..9074fe16 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -17,7 +17,7 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent +import org.koin.core.component.KoinScopeComponent import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.adapters.AlbumRowDelegate @@ -34,43 +34,32 @@ import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.Index import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.model.SearchListModel -import org.moire.ultrasonic.service.DownloadService import org.moire.ultrasonic.service.MediaPlayerManager -import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker -import org.moire.ultrasonic.subsonic.ShareHandler import org.moire.ultrasonic.subsonic.VideoPlayer.Companion.playVideo -import org.moire.ultrasonic.util.CancellationToken -import org.moire.ultrasonic.util.CommunicationError +import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenu +import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenuTracks +import org.moire.ultrasonic.util.RefreshableFragment import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util.toast +import org.moire.ultrasonic.util.toastingExceptionHandler /** * Initiates a search on the media library and displays the results */ -class SearchFragment : MultiListFragment(), KoinComponent { +class SearchFragment : MultiListFragment(), KoinScopeComponent, RefreshableFragment { private var searchResult: SearchResult? = null - private var searchRefresh: SwipeRefreshLayout? = null - + override var swipeRefresh: SwipeRefreshLayout? = null private val mediaPlayerManager: MediaPlayerManager by inject() - - private val shareHandler: ShareHandler by inject() - private val networkAndStorageChecker: NetworkAndStorageChecker by inject() - - private var cancellationToken: CancellationToken? = null - private val navArgs by navArgs() - override val listModel: SearchListModel by viewModels() - override val mainLayout: Int = R.layout.search override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - cancellationToken = CancellationToken() setTitle(this, R.string.search_title) listModel.searchResult.observe( @@ -83,8 +72,8 @@ class SearchFragment : MultiListFragment(), KoinComponent { } } - searchRefresh = view.findViewById(R.id.swipe_refresh_view) - searchRefresh!!.isEnabled = false + swipeRefresh = view.findViewById(R.id.swipe_refresh_view) + swipeRefresh!!.isEnabled = false registerForContextMenu(listView!!) @@ -133,29 +122,17 @@ class SearchFragment : MultiListFragment(), KoinComponent { override fun onDestroyView() { Util.hideKeyboard(activity) - cancellationToken?.cancel() super.onDestroyView() } - private fun downloadBackground(save: Boolean, songs: List) { - val onValid = Runnable { - networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - DownloadService.download( - songs.filterNotNull(), - save = save, - updateSaveFlag = true - ) - } - onValid.run() - } - private fun search(query: String, autoplay: Boolean) { - listModel.viewModelScope.launch(CommunicationError.getHandler(context)) { - refreshListView?.isRefreshing = true - listModel.search(query) - refreshListView?.isRefreshing = false - }.invokeOnCompletion { - if (it == null && autoplay) { + listModel.viewModelScope.launch( + toastingExceptionHandler() + ) { + swipeRefresh?.isRefreshing = true + val result = listModel.search(query) + swipeRefresh?.isRefreshing = false + if (result != null && autoplay) { autoplay() } } @@ -253,7 +230,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { insertionMode = MediaPlayerManager.InsertionMode.APPEND ) mediaPlayerManager.play(mediaPlayerManager.mediaItemCount - 1) - toast(context, resources.getQuantityString(R.plurals.n_songs_added_to_end, 1, 1)) + toast(resources.getQuantityString(R.plurals.n_songs_added_to_end, 1, 1)) } private fun onVideoSelected(track: Track) { @@ -288,103 +265,23 @@ class SearchFragment : MultiListFragment(), KoinComponent { @Suppress("LongMethod") override fun onContextMenuItemSelected(menuItem: MenuItem, item: Identifiable): Boolean { - val isArtist = (item is Artist) - - val found = EntryListFragment.handleContextMenu( - menuItem, - item, - isArtist, - downloadHandler, - this - ) - - if (found || item !is Track) return true - - val songs = mutableListOf() - - when (menuItem.itemId) { - R.id.song_menu_play_now -> { - songs.add(item) - downloadHandler.addTracksToMediaController( - songs = songs, - insertionMode = MediaPlayerManager.InsertionMode.CLEAR, - autoPlay = true, - shuffle = false, - fragment = this, - playlistName = null - ) - } - R.id.song_menu_play_next -> { - songs.add(item) - downloadHandler.addTracksToMediaController( - songs = songs, - insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT, - autoPlay = false, - shuffle = false, - fragment = this, - playlistName = null - ) - } - R.id.song_menu_play_last -> { - songs.add(item) - downloadHandler.addTracksToMediaController( - songs = songs, - insertionMode = MediaPlayerManager.InsertionMode.APPEND, - autoPlay = false, - shuffle = false, - fragment = this, - playlistName = null - ) - } - R.id.song_menu_pin -> { - songs.add(item) - toast( - context, - resources.getQuantityString( - R.plurals.n_songs_pinned, - songs.size, - songs.size - ) - ) - downloadBackground(true, songs) - } - R.id.song_menu_download -> { - songs.add(item) - toast( - context, - resources.getQuantityString( - R.plurals.n_songs_to_be_downloaded, - songs.size, - songs.size - ) - ) - downloadBackground(false, songs) - } - R.id.song_menu_unpin -> { - songs.add(item) - toast( - context, - resources.getQuantityString( - R.plurals.n_songs_unpinned, - songs.size, - songs.size - ) - ) - DownloadService.unpin(songs) - } - R.id.song_menu_share -> { - songs.add(item) - shareHandler.createShare( - fragment = this, - tracks = songs, - swipe = searchRefresh, - cancellationToken = cancellationToken!!, - additionalId = null - ) - } + // Here the Item could be a track or an album or an artist + if (item is Track) { + return handleContextMenuTracks( + menuItem = menuItem, + tracks = listOf(item), + mediaPlayerManager = mediaPlayerManager, + fragment = this + ) + } else { + return handleContextMenu( + menuItem = menuItem, + item = item, + isArtist = item is Artist, + mediaPlayerManager = mediaPlayerManager, + fragment = this + ) } - - return true } companion object { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt index 5f79b53f..50b6399d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt @@ -64,7 +64,7 @@ class ServerSelectorFragment : Fragment() { listView?.onItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ -> val server = parent.getItemAtPosition(position) as ServerSetting - ActiveServerProvider.setActiveServerById(server.id) + activeServerProvider.setActiveServerById(server.id) findNavController().popBackStack(R.id.mainFragment, false) } @@ -99,7 +99,7 @@ class ServerSelectorFragment : Fragment() { val activeServerId = ActiveServerProvider.getActiveServerId() // If the currently active server is deleted, go offline - if (id == activeServerId) ActiveServerProvider.setActiveServerById(OFFLINE_DB_ID) + if (id == activeServerId) activeServerProvider.setActiveServerById(OFFLINE_DB_ID) serverSettingsModel.deleteItemById(id) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index 9e654186..8d697581 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -20,7 +20,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.log.FileLoggerTree import org.moire.ultrasonic.log.FileLoggerTree.Companion.deleteLogFiles import org.moire.ultrasonic.log.FileLoggerTree.Companion.getLogFileNumber @@ -300,7 +300,7 @@ class SettingsFragment : SearchSuggestionProvider.MODE ) suggestions.clearHistory() - toast(activity, R.string.settings_search_history_cleared) + toast(R.string.settings_search_history_cleared) false } } @@ -332,7 +332,7 @@ class SettingsFragment : Timber.w("Failed to delete %s", nomediaDir) } } - toast(activity, R.string.settings_hide_media_toast, false) + toast(R.string.settings_hide_media_toast, false) } private fun setCacheLocation(path: String) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index 108016dc..7f7543f0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -41,19 +41,22 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.model.TrackCollectionModel import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign -import org.moire.ultrasonic.subsonic.DownloadAction import org.moire.ultrasonic.subsonic.ShareHandler import org.moire.ultrasonic.subsonic.VideoPlayer -import org.moire.ultrasonic.util.CancellationToken import org.moire.ultrasonic.util.ConfirmationDialog +import org.moire.ultrasonic.util.ContextMenuUtil +import org.moire.ultrasonic.util.DownloadAction +import org.moire.ultrasonic.util.DownloadUtil import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.Util.navigateToCurrent +import org.moire.ultrasonic.util.Util.toast +import org.moire.ultrasonic.util.toastingExceptionHandler import org.moire.ultrasonic.view.SortOrder import org.moire.ultrasonic.view.ViewCapabilities import timber.log.Timber @@ -86,7 +89,6 @@ open class TrackCollectionFragment( internal val mediaPlayerManager: MediaPlayerManager by inject() private val shareHandler: ShareHandler by inject() - internal var cancellationToken: CancellationToken? = null override val listModel: TrackCollectionModel by viewModels() private val rxBusSubscription: CompositeDisposable = CompositeDisposable() @@ -102,13 +104,12 @@ open class TrackCollectionFragment( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - cancellationToken = CancellationToken() albumButtons = view.findViewById(R.id.menu_album) // Setup refresh handler - refreshListView = view.findViewById(refreshListId) - refreshListView?.setOnRefreshListener { + swipeRefresh = view.findViewById(refreshListId) + swipeRefresh?.setOnRefreshListener { handleRefresh() } @@ -211,19 +212,23 @@ open class TrackCollectionFragment( } playNowButton?.setOnClickListener { - playNow(MediaPlayerManager.InsertionMode.CLEAR, toast = true) + playSelectedOrAllTracks(MediaPlayerManager.InsertionMode.CLEAR) } playNextButton?.setOnClickListener { - playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, toast = true) + playSelectedOrAllTracks(MediaPlayerManager.InsertionMode.AFTER_CURRENT) } playLastButton!!.setOnClickListener { - playNow(MediaPlayerManager.InsertionMode.APPEND, toast = true) + playSelectedOrAllTracks(MediaPlayerManager.InsertionMode.APPEND) } pinButton?.setOnClickListener { - downloadBackground(true) + downloadSelectedOrAllTracks(true) + } + + downloadButton?.setOnClickListener { + downloadSelectedOrAllTracks(false) } unpinButton?.setOnClickListener { @@ -231,26 +236,22 @@ open class TrackCollectionFragment( ConfirmationDialog.Builder(requireContext()) .setMessage(R.string.common_unpin_selection_confirmation) .setPositiveButton(R.string.common_unpin) { _, _ -> - unpin() + unpinSelectedTracks() }.show() } else { - unpin() + unpinSelectedTracks() } } - downloadButton?.setOnClickListener { - downloadBackground(false) - } - deleteButton?.setOnClickListener { if (Settings.showConfirmationDialog) { ConfirmationDialog.Builder(requireContext()) .setMessage(R.string.common_delete_selection_confirmation) .setPositiveButton(R.string.common_delete) { _, _ -> - delete() + deleteSelectedTracks() }.show() } else { - delete() + deleteSelectedTracks() } } } @@ -283,9 +284,9 @@ open class TrackCollectionFragment( return true } else if (item.itemId == R.id.menu_item_share) { shareHandler.createShare( - this@TrackCollectionFragment, getSelectedTracks(), - refreshListView, cancellationToken!!, - navArgs.id + fragment = this@TrackCollectionFragment, + tracks = getSelectedOrAllTracks(), + additionalId = navArgs.id ) return true } @@ -294,46 +295,10 @@ open class TrackCollectionFragment( } override fun onDestroyView() { - cancellationToken!!.cancel() rxBusSubscription.dispose() super.onDestroyView() } - private fun playNow( - insertionMode: MediaPlayerManager.InsertionMode, - selectedTracks: List = getSelectedTracks(), - toast: Boolean = false - ) { - if (selectedTracks.isNotEmpty()) { - downloadHandler.addTracksToMediaController( - songs = selectedTracks, - insertionMode = insertionMode, - autoPlay = (insertionMode == MediaPlayerManager.InsertionMode.CLEAR), - playlistName = null, - fragment = this - ) - } else { - playAll(false, insertionMode) - } - - if (toast) { - val stringInt = when (insertionMode) { - MediaPlayerManager.InsertionMode.CLEAR -> - R.plurals.n_songs_added_play_now - MediaPlayerManager.InsertionMode.AFTER_CURRENT -> - R.plurals.n_songs_added_after_current - MediaPlayerManager.InsertionMode.APPEND -> - R.plurals.n_songs_added_to_end - } - val msg = resources.getQuantityString( - stringInt, - selectedTracks.size, - selectedTracks.size - ) - Util.toast(requireContext(), msg) - } - } - /** * Get the size of the underlying list */ @@ -364,31 +329,58 @@ open class TrackCollectionFragment( // Need a valid id to recurse sub directories stuff if (hasSubFolders && navArgs.id != null) { - downloadHandler.fetchTracksAndAddToController( + mediaPlayerManager.playTracksAndToast( fragment = this, - id = navArgs.id!!, insertionMode = insertionMode, - autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND), + id = navArgs.id!!, shuffle = shuffle, isArtist = isArtist ) } else { - downloadHandler.addTracksToMediaController( - songs = getAllSongs(), + mediaPlayerManager.suggestedPlaylistName = navArgs.playlistName + mediaPlayerManager.addToPlaylist( + songs = getAllTracks(), insertionMode = insertionMode, autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND), - shuffle = shuffle, - playlistName = navArgs.playlistName, - fragment = this + shuffle = shuffle ) + if (insertionMode == MediaPlayerManager.InsertionMode.CLEAR) { + navigateToCurrent() + } } } + private fun unpinSelectedTracks() { + DownloadUtil.justDownload( + action = DownloadAction.UNPIN, + fragment = this, + tracks = getSelectedTracks() + ) + } - @Suppress("UNCHECKED_CAST") - private fun getAllSongs(): List { - return viewAdapter.getCurrentList().filter { - it is Track && !it.isDirectory - } as List + private fun downloadSelectedOrAllTracks(save: Boolean) { + DownloadUtil.justDownload( + action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD, + fragment = this, + tracks = getSelectedOrAllTracks() + ) + } + + private fun playSelectedOrAllTracks( + insertionMode: MediaPlayerManager.InsertionMode + ) { + mediaPlayerManager.playTracksAndToast( + fragment = this, + insertionMode = insertionMode, + tracks = getSelectedOrAllTracks() + ) + } + + private fun deleteSelectedTracks() { + DownloadUtil.justDownload( + action = DownloadAction.DELETE, + fragment = this, + tracks = getSelectedTracks() + ) } private fun selectAllOrNone() { @@ -403,7 +395,7 @@ open class TrackCollectionFragment( // Display toast: N tracks selected val toastResId = R.string.select_album_n_selected - Util.toast(activity, getString(toastResId, selectedCount.coerceAtLeast(0))) + toast(getString(toastResId, selectedCount.coerceAtLeast(0))) } @Synchronized @@ -431,37 +423,6 @@ open class TrackCollectionFragment( } } - private fun downloadBackground(save: Boolean, tracks: List = getSelectedTracks()) { - var songs = tracks - - if (songs.isEmpty()) { - songs = getAllSongs() - } - - val action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD - downloadHandler.justDownload( - action = action, - fragment = this, - tracks = songs - ) - } - - internal fun delete(songs: List = getSelectedTracks()) { - downloadHandler.justDownload( - action = DownloadAction.DELETE, - fragment = this, - tracks = songs - ) - } - - internal fun unpin(songs: List = getSelectedTracks()) { - downloadHandler.justDownload( - action = DownloadAction.UNPIN, - fragment = this, - tracks = songs - ) - } - override val defaultObserver: (List) -> Unit = { Timber.i("Received list") @@ -530,6 +491,19 @@ open class TrackCollectionFragment( } } + @Suppress("UNCHECKED_CAST") + private fun getAllTracks(): List { + return viewAdapter.getCurrentList().filter { + it is Track && !it.isDirectory + } as List + } + + fun getSelectedOrAllTracks(): List { + return getSelectedTracks().ifEmpty { + getAllTracks() + } + } + override fun setTitle(title: String?) { setTitle(this@TrackCollectionFragment, title) } @@ -561,8 +535,10 @@ open class TrackCollectionFragment( val offset = navArgs.offset val refresh2 = navArgs.refresh || refresh - listModel.viewModelScope.launch(handler) { - refreshListView?.isRefreshing = true + listModel.viewModelScope.launch( + toastingExceptionHandler() + ) { + swipeRefresh?.isRefreshing = true if (playlistId != null) { setTitle(playlistName!!) @@ -597,7 +573,7 @@ open class TrackCollectionFragment( } } - refreshListView?.isRefreshing = false + swipeRefresh?.isRefreshing = false } return listModel.currentList } @@ -606,48 +582,19 @@ open class TrackCollectionFragment( private fun displayRandom() = (sortOrder == SortOrder.RANDOM) || navArgs.getRandom - @Suppress("LongMethod") override fun onContextMenuItemSelected( menuItem: MenuItem, item: MusicDirectory.Child ): Boolean { - val songs = getClickedSong(item) - when (menuItem.itemId) { - R.id.song_menu_play_now -> { - playNow(MediaPlayerManager.InsertionMode.CLEAR, songs, true) - } - R.id.song_menu_play_next -> { - playNow(MediaPlayerManager.InsertionMode.AFTER_CURRENT, songs, true) - } - R.id.song_menu_play_last -> { - playNow(MediaPlayerManager.InsertionMode.APPEND, songs, true) - } - R.id.song_menu_pin -> { - downloadBackground(true, songs) - } - R.id.song_menu_unpin -> { - unpin(songs) - } - R.id.song_menu_download -> { - downloadBackground(false, songs) - } - R.id.song_menu_share -> { - if (item is Track) { - shareHandler.createShare( - this, - tracks = listOf(item), - swipe = refreshListView, - cancellationToken = cancellationToken!!, - additionalId = navArgs.id - ) - } - } - else -> { - return super.onContextItemSelected(menuItem) - } - } - return true + val tracks = getClickedSong(item) + + return ContextMenuUtil.handleContextMenuTracks( + menuItem = menuItem, + tracks = tracks, + mediaPlayerManager = mediaPlayerManager, + fragment = this + ) } private fun getClickedSong(item: MusicDirectory.Child): List { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/ChatFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/ChatFragment.kt new file mode 100644 index 00000000..9f7e4744 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/ChatFragment.kt @@ -0,0 +1,214 @@ +/* + * ChatFragment.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.fragment.legacy + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.ListAdapter +import android.widget.ListView +import android.widget.TextView +import android.widget.TextView.OnEditorActionListener +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.button.MaterialButton +import java.util.Locale +import java.util.Timer +import java.util.TimerTask +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle +import org.moire.ultrasonic.model.ChatViewModel +import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService +import org.moire.ultrasonic.util.RefreshableFragment +import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.Util.applyTheme +import org.moire.ultrasonic.util.Util.isNullOrWhiteSpace +import org.moire.ultrasonic.util.toastingExceptionHandler +import org.moire.ultrasonic.view.ChatAdapter + +class ChatFragment : Fragment(), KoinComponent, RefreshableFragment { + private lateinit var chatListView: ListView + private lateinit var messageEditText: EditText + private lateinit var sendButton: MaterialButton + private var timer: Timer? = null + override var swipeRefresh: SwipeRefreshLayout? = null + private val activeServerProvider: ActiveServerProvider by inject() + + private val chatViewModel: ChatViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + applyTheme(requireContext()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.chat, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Add the ChatMenuProvider for creating the menu + (requireActivity() as MenuHost).addMenuProvider( + menuProvider, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + + swipeRefresh = view.findViewById(R.id.chat_refresh) + swipeRefresh?.isEnabled = false + messageEditText = view.findViewById(R.id.chat_edittext) + sendButton = view.findViewById(R.id.chat_send) + sendButton.setOnClickListener { sendMessage() } + chatListView = view.findViewById(R.id.chat_entries_list) + chatListView.transcriptMode = ListView.TRANSCRIPT_MODE_ALWAYS_SCROLL + chatListView.isStackFromBottom = true + val serverName = activeServerProvider.getActiveServer().name + val userName = activeServerProvider.getActiveServer().userName + val title = String.format( + Locale.ROOT, + "%s [%s@%s]", + resources.getString(R.string.button_bar_chat), + userName, + serverName + ) + setTitle(this, title) + messageEditText.imeOptions = EditorInfo.IME_ACTION_SEND + messageEditText.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {} + override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {} + override fun afterTextChanged(editable: Editable) { + sendButton.isEnabled = !isNullOrWhiteSpace(editable.toString()) + } + }) + messageEditText.setOnEditorActionListener( + OnEditorActionListener { + _: TextView?, + actionId: Int, + event: KeyEvent -> + if (actionId == EditorInfo.IME_ACTION_SEND || + (actionId == EditorInfo.IME_NULL && event.action == KeyEvent.ACTION_DOWN) + ) { + sendMessage() + return@OnEditorActionListener true + } + false + } + ) + load() + timerMethod() + } + + private val menuProvider: MenuProvider = object : MenuProvider { + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.chat, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (menuItem.itemId == R.id.menu_refresh) { + load() + return true + } + return false + } + } + override fun onResume() { + super.onResume() + chatViewModel.chatMessages.observe(viewLifecycleOwner) { messages -> + if (!messages.isNullOrEmpty()) { + val chatAdapter: ListAdapter = ChatAdapter(requireContext(), messages) + chatListView.adapter = chatAdapter + } + } + if (timer == null) { + timerMethod() + } + } + + override fun onPause() { + super.onPause() + timer?.cancel() + timer = null + } + + private fun timerMethod() { + val refreshInterval = Settings.chatRefreshInterval + if (refreshInterval > 0) { + timer = Timer() + timer?.schedule( + object : TimerTask() { + override fun run() { + requireActivity().runOnUiThread { load() } + } + }, + refreshInterval.toLong(), refreshInterval.toLong() + ) + } + } + + private fun sendMessage() { + val text = messageEditText.text ?: return + val message = text.toString() + if (!isNullOrWhiteSpace(message)) { + messageEditText.setText("") + viewLifecycleOwner.lifecycleScope.launch( + toastingExceptionHandler() + ) { + withContext(Dispatchers.IO) { + val musicService = getMusicService() + musicService.addChatMessage(message) + } + load() + } + } + } + + fun load() { + viewLifecycleOwner.lifecycleScope.launch( + toastingExceptionHandler() + ) { + val result = withContext(Dispatchers.IO) { + val musicService = getMusicService() + musicService.getChatMessages(chatViewModel.lastChatMessageTime)?.filterNotNull() + } + swipeRefresh?.isRefreshing = false + if (!result.isNullOrEmpty()) { + for (message in result) { + if (message.time > chatViewModel.lastChatMessageTime) { + chatViewModel.lastChatMessageTime = message.time + } + } + chatViewModel.updateChatMessages(result.reversed()) + } + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/LyricsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/LyricsFragment.kt index 05d0f05c..747a4b98 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/LyricsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/LyricsFragment.kt @@ -13,35 +13,34 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.moire.ultrasonic.R -import org.moire.ultrasonic.domain.Lyrics -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.BackgroundTask -import org.moire.ultrasonic.util.CancellationToken -import org.moire.ultrasonic.util.FragmentBackgroundTask +import org.moire.ultrasonic.util.RefreshableFragment import org.moire.ultrasonic.util.Util.applyTheme +import org.moire.ultrasonic.util.toastingExceptionHandler import timber.log.Timber /** * Displays the lyrics of a song - * - * TODO: This file has been converted from Java, but not modernized yet. */ -class LyricsFragment : Fragment() { +class LyricsFragment : Fragment(), RefreshableFragment { private var artistView: TextView? = null private var titleView: TextView? = null private var textView: TextView? = null - private var swipe: SwipeRefreshLayout? = null - private var cancellationToken: CancellationToken? = null + override var swipeRefresh: SwipeRefreshLayout? = null private val navArgs by navArgs() override fun onCreate(savedInstanceState: Bundle?) { - applyTheme(this.context) super.onCreate(savedInstanceState) + applyTheme(requireContext()) } override fun onCreateView( @@ -53,42 +52,34 @@ class LyricsFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - cancellationToken = CancellationToken() + super.onViewCreated(view, savedInstanceState) Timber.d("Lyrics set title") setTitle(this, R.string.download_menu_lyrics) - swipe = view.findViewById(R.id.lyrics_refresh) - swipe?.isEnabled = false + swipeRefresh = view.findViewById(R.id.lyrics_refresh) + swipeRefresh?.isEnabled = false artistView = view.findViewById(R.id.lyrics_artist) titleView = view.findViewById(R.id.lyrics_title) textView = view.findViewById(R.id.lyrics_text) load() } - - override fun onDestroyView() { - cancellationToken!!.cancel() - super.onDestroyView() - } - private fun load() { - val task: BackgroundTask = object : FragmentBackgroundTask( - activity, true, swipe, cancellationToken + viewLifecycleOwner.lifecycleScope.launch( + toastingExceptionHandler() ) { - @Throws(Throwable::class) - override fun doInBackground(): Lyrics { + val result = withContext(Dispatchers.IO) { val musicService = getMusicService() - return musicService.getLyrics(navArgs.artist, navArgs.title)!! + musicService.getLyrics(navArgs.artist, navArgs.title)!! } - - override fun done(result: Lyrics) { + swipeRefresh?.isRefreshing = false + withContext(Dispatchers.Main) { if (result.artist != null) { - artistView!!.text = result.artist - titleView!!.text = result.title - textView!!.text = result.text + artistView?.text = result.artist + titleView?.text = result.title + textView?.text = result.text } else { - artistView!!.setText(R.string.lyrics_nomatch) + artistView?.setText(R.string.lyrics_nomatch) } } } - task.execute() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt index ebffc3cc..d8a51c02 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt @@ -25,50 +25,46 @@ import android.widget.CheckBox import android.widget.EditText import android.widget.ListView import android.widget.TextView -import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import java.util.Locale -import org.koin.core.component.KoinComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.androidx.scope.ScopeFragment +import org.koin.core.component.KoinScopeComponent import org.koin.core.component.inject import org.moire.ultrasonic.NavigationGraphDirections import org.moire.ultrasonic.R -import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.domain.Playlist -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.service.OfflineException -import org.moire.ultrasonic.subsonic.DownloadAction -import org.moire.ultrasonic.subsonic.DownloadHandler -import org.moire.ultrasonic.util.BackgroundTask import org.moire.ultrasonic.util.CacheCleaner -import org.moire.ultrasonic.util.CancellationToken import org.moire.ultrasonic.util.ConfirmationDialog -import org.moire.ultrasonic.util.FragmentBackgroundTask +import org.moire.ultrasonic.util.DownloadAction +import org.moire.ultrasonic.util.DownloadUtil import org.moire.ultrasonic.util.InfoDialog -import org.moire.ultrasonic.util.LoadingTask +import org.moire.ultrasonic.util.RefreshableFragment import org.moire.ultrasonic.util.Util.applyTheme import org.moire.ultrasonic.util.Util.toast +import org.moire.ultrasonic.util.toastingExceptionHandler /** * Displays the playlists stored on the server * * TODO: This file has been converted from Java, but not modernized yet. */ -class PlaylistsFragment : Fragment(), KoinComponent { - private var refreshPlaylistsListView: SwipeRefreshLayout? = null +@Suppress("InstanceOfCheckForException") +class PlaylistsFragment : ScopeFragment(), KoinScopeComponent, RefreshableFragment { + override var swipeRefresh: SwipeRefreshLayout? = null private var playlistsListView: ListView? = null private var emptyTextView: View? = null private var playlistAdapter: ArrayAdapter? = null - private val downloadHandler by inject() - - private var cancellationToken: CancellationToken? = null - override fun onCreate(savedInstanceState: Bundle?) { - applyTheme(this.context) super.onCreate(savedInstanceState) + applyTheme(requireContext()) } override fun onCreateView( @@ -80,17 +76,16 @@ class PlaylistsFragment : Fragment(), KoinComponent { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - cancellationToken = CancellationToken() - refreshPlaylistsListView = view.findViewById(R.id.select_playlist_refresh) + swipeRefresh = view.findViewById(R.id.select_playlist_refresh) playlistsListView = view.findViewById(R.id.select_playlist_list) - refreshPlaylistsListView!!.setOnRefreshListener { load(true) } + swipeRefresh?.setOnRefreshListener { load(true) } emptyTextView = view.findViewById(R.id.select_playlist_empty) - playlistsListView!!.setOnItemClickListener { parent, _, position, _ -> - val (id1, name) = parent.getItemAtPosition(position) as Playlist + playlistsListView?.setOnItemClickListener { parent, _, position, _ -> + val (id, name) = parent.getItemAtPosition(position) as Playlist val action = NavigationGraphDirections.toTrackCollection( - id = id1, - playlistId = id1, + id = id, + playlistId = id, name = name, playlistName = name, ) @@ -101,32 +96,25 @@ class PlaylistsFragment : Fragment(), KoinComponent { load(false) } - override fun onDestroyView() { - cancellationToken!!.cancel() - super.onDestroyView() - } - private fun load(refresh: Boolean) { - val task: BackgroundTask> = - object : FragmentBackgroundTask>( - activity, true, refreshPlaylistsListView, cancellationToken - ) { - @Throws(Throwable::class) - override fun doInBackground(): List { - val musicService = getMusicService() - val playlists = musicService.getPlaylists(refresh) - if (!isOffline()) CacheCleaner().cleanPlaylists(playlists) - return playlists - } - - override fun done(result: List) { - playlistAdapter = - ArrayAdapter(requireContext(), R.layout.list_item_generic, result) - playlistsListView!!.adapter = playlistAdapter - emptyTextView!!.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE - } + val cacheCleaner: CacheCleaner by inject() + viewLifecycleOwner.lifecycleScope.launch( + toastingExceptionHandler() + ) { + val result = withContext(Dispatchers.IO) { + val musicService = getMusicService() + val playlists = musicService.getPlaylists(refresh) + playlists } - task.execute() + swipeRefresh?.isRefreshing = false + withContext(Dispatchers.Main) { + playlistAdapter = + ArrayAdapter(requireContext(), R.layout.list_item_generic, result) + playlistsListView?.adapter = playlistAdapter + emptyTextView?.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE + } + if (!isOffline()) cacheCleaner.cleanPlaylists(result) + } } override fun onCreateContextMenu(menu: ContextMenu, view: View, menuInfo: ContextMenuInfo?) { @@ -144,11 +132,11 @@ class PlaylistsFragment : Fragment(), KoinComponent { override fun onContextItemSelected(menuItem: MenuItem): Boolean { val info = menuItem.menuInfo as AdapterContextMenuInfo - val playlist = playlistsListView!!.getItemAtPosition(info.position) as Playlist + val playlist = playlistsListView?.getItemAtPosition(info.position) as Playlist when (menuItem.itemId) { R.id.playlist_menu_pin -> { - downloadHandler.justDownload( - DownloadAction.PIN, + DownloadUtil.justDownload( + action = DownloadAction.PIN, fragment = this, id = playlist.id, name = playlist.name, @@ -157,8 +145,8 @@ class PlaylistsFragment : Fragment(), KoinComponent { ) } R.id.playlist_menu_unpin -> { - downloadHandler.justDownload( - DownloadAction.UNPIN, + DownloadUtil.justDownload( + action = DownloadAction.UNPIN, fragment = this, id = playlist.id, name = playlist.name, @@ -167,8 +155,8 @@ class PlaylistsFragment : Fragment(), KoinComponent { ) } R.id.playlist_menu_download -> { - downloadHandler.justDownload( - DownloadAction.DOWNLOAD, + DownloadUtil.justDownload( + action = DownloadAction.DOWNLOAD, fragment = this, id = playlist.id, name = playlist.name, @@ -215,58 +203,45 @@ class PlaylistsFragment : Fragment(), KoinComponent { .setTitle(R.string.common_confirm).setMessage( resources.getString(R.string.delete_playlist, playlist.name) ).setPositiveButton(R.string.common_ok) { _, _ -> - object : LoadingTask(activity, refreshPlaylistsListView, cancellationToken) { - @Throws(Throwable::class) - override fun doInBackground(): Any? { + viewLifecycleOwner.lifecycleScope.launch( + toastingExceptionHandler( + resources.getString( + R.string.menu_deleted_playlist_error, + playlist.name + ) + ) + ) { + withContext(Dispatchers.IO) { val musicService = getMusicService() musicService.deletePlaylist(playlist.id) - return null } - override fun done(result: Any?) { - playlistAdapter!!.remove(playlist) - playlistAdapter!!.notifyDataSetChanged() + withContext(Dispatchers.Main) { + playlistAdapter?.remove(playlist) + playlistAdapter?.notifyDataSetChanged() toast( - context, resources.getString(R.string.menu_deleted_playlist, playlist.name) ) } - - override fun error(error: Throwable) { - val msg: String = - if (error is OfflineException || error is ApiNotSupportedException) - getErrorMessage( - error - ) else String.format( - Locale.ROOT, - "%s %s", - resources.getString( - R.string.menu_deleted_playlist_error, - playlist.name - ), - getErrorMessage(error) - ) - toast(context, msg, false) - } - }.execute() + } }.setNegativeButton(R.string.common_cancel, null).show() } private fun displayPlaylistInfo(playlist: Playlist) { - val textView = TextView(context) + val textView = TextView(requireContext()) textView.setPadding(5, 5, 5, 5) val message: Spannable = SpannableString( """ - Owner: ${playlist.owner} - Comments: ${playlist.comment} - Song Count: ${playlist.songCount} + Owner: ${playlist.owner} + Comments: ${playlist.comment} + Song Count: ${playlist.songCount} """.trimIndent() + if (playlist.public == null) "" else """ - - Public: ${playlist.public} + + Public: ${playlist.public} """.trimIndent() + """ - - Creation Date: ${playlist.created.replace('T', ' ')} + + Creation Date: ${playlist.created.replace('T', ' ')} """.trimIndent() ) Linkify.addLinks(message, Linkify.WEB_URLS) @@ -295,43 +270,31 @@ class PlaylistsFragment : Fragment(), KoinComponent { alertDialog.setTitle(R.string.playlist_update_info) alertDialog.setView(dialogView) alertDialog.setPositiveButton(R.string.common_ok) { _, _ -> - object : LoadingTask(activity, refreshPlaylistsListView, cancellationToken) { - @Throws(Throwable::class) - override fun doInBackground(): Any? { - val nameBoxText = nameBox.text - val commentBoxText = commentBox.text - val name = nameBoxText?.toString() - val comment = commentBoxText?.toString() - val musicService = getMusicService() + viewLifecycleOwner.lifecycleScope.launch( + toastingExceptionHandler( + resources.getString( + R.string.playlist_updated_info_error, + playlist.name + ) + ) + ) { + val nameBoxText = nameBox.text + val commentBoxText = commentBox.text + val name = nameBoxText?.toString() + val comment = commentBoxText?.toString() + val musicService = getMusicService() + + withContext(Dispatchers.IO) { musicService.updatePlaylist(playlist.id, name, comment, publicBox.isChecked) - return null } - override fun done(result: Any?) { + withContext(Dispatchers.Main) { load(true) toast( - context, resources.getString(R.string.playlist_updated_info, playlist.name) ) } - - override fun error(error: Throwable) { - val msg: String = - if (error is OfflineException || error is ApiNotSupportedException) - getErrorMessage( - error - ) else String.format( - Locale.ROOT, - "%s %s", - resources.getString( - R.string.playlist_updated_info_error, - playlist.name - ), - getErrorMessage(error) - ) - toast(context, msg, false) - } - }.execute() + } } alertDialog.setNegativeButton(R.string.common_cancel, null) alertDialog.show() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PodcastFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PodcastFragment.kt index 2ab59b00..4c550fa1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PodcastFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PodcastFragment.kt @@ -13,35 +13,33 @@ import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.ListView -import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.androidx.scope.ScopeFragment import org.moire.ultrasonic.NavigationGraphDirections import org.moire.ultrasonic.R import org.moire.ultrasonic.domain.PodcastsChannel -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.BackgroundTask -import org.moire.ultrasonic.util.CancellationToken -import org.moire.ultrasonic.util.FragmentBackgroundTask +import org.moire.ultrasonic.util.RefreshableFragment import org.moire.ultrasonic.util.Util.applyTheme +import org.moire.ultrasonic.util.toastingExceptionHandler /** * Displays the podcasts available on the server - * - * TODO: This file has been converted from Java, but not modernized yet. - * TODO: Use Coroutines */ -class PodcastFragment : Fragment() { - +class PodcastFragment : ScopeFragment(), RefreshableFragment { private var emptyTextView: View? = null - var channelItemsListView: ListView? = null - private var cancellationToken: CancellationToken? = null - private var swipeRefresh: SwipeRefreshLayout? = null + private var channelItemsListView: ListView? = null + override var swipeRefresh: SwipeRefreshLayout? = null override fun onCreate(savedInstanceState: Bundle?) { - applyTheme(this.context) super.onCreate(savedInstanceState) + applyTheme(requireContext()) } override fun onCreateView( @@ -54,45 +52,33 @@ class PodcastFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - cancellationToken = CancellationToken() swipeRefresh = view.findViewById(R.id.podcasts_refresh) - swipeRefresh!!.setOnRefreshListener { load(true) } + swipeRefresh?.setOnRefreshListener { load(true) } setTitle(this, R.string.podcasts_label) emptyTextView = view.findViewById(R.id.select_podcasts_empty) channelItemsListView = view.findViewById(R.id.podcasts_channels_items_list) - channelItemsListView!!.setOnItemClickListener { parent, _, position, _ -> - val (id) = parent.getItemAtPosition(position) as PodcastsChannel - val action = NavigationGraphDirections.toTrackCollection( - podcastChannelId = id - ) - + channelItemsListView?.setOnItemClickListener { parent, _, position, _ -> + val id = (parent.getItemAtPosition(position) as PodcastsChannel).id + val action = NavigationGraphDirections.toTrackCollection(podcastChannelId = id) findNavController().navigate(action) } load(false) } - override fun onDestroyView() { - cancellationToken!!.cancel() - super.onDestroyView() - } - private fun load(refresh: Boolean) { - val task: BackgroundTask> = - object : FragmentBackgroundTask>( - activity, true, swipeRefresh, cancellationToken - ) { - @Throws(Throwable::class) - override fun doInBackground(): List { - val musicService = getMusicService() - return musicService.getPodcastsChannels(refresh) - } - - override fun done(result: List) { - channelItemsListView!!.adapter = - ArrayAdapter(requireContext(), R.layout.list_item_generic, result) - emptyTextView!!.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE - } + viewLifecycleOwner.lifecycleScope.launch( + toastingExceptionHandler() + ) { + val result = withContext(Dispatchers.IO) { + val musicService = getMusicService() + musicService.getPodcastsChannels(refresh) } - task.execute() + swipeRefresh?.isRefreshing = false + withContext(Dispatchers.Main) { + channelItemsListView?.adapter = + ArrayAdapter(requireContext(), R.layout.list_item_generic, result) + emptyTextView?.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE + } + } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SelectGenreFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SelectGenreFragment.kt index 0a876b45..879dafe2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SelectGenreFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SelectGenreFragment.kt @@ -1,10 +1,3 @@ -/* - * SelectGenreFragment.kt - * Copyright (C) 2009-2022 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - package org.moire.ultrasonic.fragment.legacy import android.os.Bundle @@ -15,35 +8,34 @@ import android.widget.AdapterView import android.widget.ListView import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.moire.ultrasonic.NavigationGraphDirections import org.moire.ultrasonic.R import org.moire.ultrasonic.domain.Genre -import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle +import org.moire.ultrasonic.fragment.FragmentTitle.setTitle import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.BackgroundTask -import org.moire.ultrasonic.util.CancellationToken -import org.moire.ultrasonic.util.FragmentBackgroundTask +import org.moire.ultrasonic.util.RefreshableFragment import org.moire.ultrasonic.util.Settings.maxSongs import org.moire.ultrasonic.util.Util.applyTheme +import org.moire.ultrasonic.util.toastingExceptionHandler import org.moire.ultrasonic.view.GenreAdapter -import timber.log.Timber /** * Displays the available genres in the media library - * - * TODO: This file has been converted from Java, but not modernized yet. */ -class SelectGenreFragment : Fragment() { - private var refreshGenreListView: SwipeRefreshLayout? = null +class SelectGenreFragment : Fragment(), RefreshableFragment { + override var swipeRefresh: SwipeRefreshLayout? = null private var genreListView: ListView? = null private var emptyView: View? = null - private var cancellationToken: CancellationToken? = null override fun onCreate(savedInstanceState: Bundle?) { - applyTheme(this.context) super.onCreate(savedInstanceState) + applyTheme(requireContext()) } override fun onCreateView( @@ -55,15 +47,15 @@ class SelectGenreFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - cancellationToken = CancellationToken() - refreshGenreListView = view.findViewById(R.id.select_genre_refresh) + super.onViewCreated(view, savedInstanceState) + swipeRefresh = view.findViewById(R.id.select_genre_refresh) genreListView = view.findViewById(R.id.select_genre_list) - refreshGenreListView!!.setOnRefreshListener { load(true) } + swipeRefresh?.setOnRefreshListener { load(true) } - genreListView!!.setOnItemClickListener { parent: AdapterView<*>, - _: View?, - position: Int, - _: Long -> + genreListView?.setOnItemClickListener { + parent: AdapterView<*>, _: View?, + position: Int, _: Long + -> val genre = parent.getItemAtPosition(position) as Genre val action = NavigationGraphDirections.toTrackCollection( @@ -79,34 +71,19 @@ class SelectGenreFragment : Fragment() { load(false) } - override fun onDestroyView() { - cancellationToken!!.cancel() - super.onDestroyView() - } - - // TODO: Migrate to Coroutines private fun load(refresh: Boolean) { - val task: BackgroundTask> = object : FragmentBackgroundTask>( - activity, true, refreshGenreListView, cancellationToken + viewLifecycleOwner.lifecycleScope.launch( + toastingExceptionHandler() ) { - override fun doInBackground(): List { + val result = withContext(Dispatchers.IO) { val musicService = getMusicService() - var genres: List = ArrayList() - try { - genres = musicService.getGenres(refresh) - } catch (all: Exception) { - Timber.e(all, "Failed to load genres") - } - return genres + musicService.getGenres(refresh) } - - override fun done(result: List) { - emptyView!!.isVisible = result.isEmpty() - if (context != null) { - genreListView!!.adapter = GenreAdapter(context!!, result) - } + swipeRefresh?.isRefreshing = false + withContext(Dispatchers.Main) { + emptyView?.isVisible = result.isEmpty() + genreListView?.adapter = GenreAdapter(requireContext(), result) } } - task.execute() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt index 32bb6176..a3336730 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/SharesFragment.kt @@ -24,28 +24,29 @@ import android.widget.CheckBox import android.widget.EditText import android.widget.ListView import android.widget.TextView -import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import java.util.Locale -import org.koin.core.component.KoinComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.androidx.scope.ScopeFragment +import org.koin.core.component.KoinScopeComponent import org.koin.core.component.inject import org.moire.ultrasonic.NavigationGraphDirections import org.moire.ultrasonic.R -import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException import org.moire.ultrasonic.domain.Share import org.moire.ultrasonic.fragment.FragmentTitle import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.MusicServiceFactory -import org.moire.ultrasonic.service.OfflineException -import org.moire.ultrasonic.subsonic.DownloadAction -import org.moire.ultrasonic.subsonic.DownloadHandler -import org.moire.ultrasonic.util.BackgroundTask -import org.moire.ultrasonic.util.CancellationToken -import org.moire.ultrasonic.util.FragmentBackgroundTask -import org.moire.ultrasonic.util.LoadingTask +import org.moire.ultrasonic.util.DownloadAction +import org.moire.ultrasonic.util.DownloadUtil +import org.moire.ultrasonic.util.RefreshableFragment import org.moire.ultrasonic.util.TimeSpanPicker import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.Util.toast +import org.moire.ultrasonic.util.launchWithToast +import org.moire.ultrasonic.util.toastingExceptionHandler import org.moire.ultrasonic.view.ShareAdapter /** @@ -53,16 +54,16 @@ import org.moire.ultrasonic.view.ShareAdapter * * TODO: This file has been converted from Java, but not modernized yet. */ -class SharesFragment : Fragment(), KoinComponent { - private var refreshSharesListView: SwipeRefreshLayout? = null +class SharesFragment : ScopeFragment(), KoinScopeComponent, RefreshableFragment { + override var swipeRefresh: SwipeRefreshLayout? = null private var sharesListView: ListView? = null private var emptyTextView: View? = null private var shareAdapter: ShareAdapter? = null - private val downloadHandler: DownloadHandler by inject() - private var cancellationToken: CancellationToken? = null + private val mediaPlayerManager: MediaPlayerManager by inject() + override fun onCreate(savedInstanceState: Bundle?) { - Util.applyTheme(this.context) super.onCreate(savedInstanceState) + Util.applyTheme(requireContext()) } override fun onCreateView( @@ -74,48 +75,41 @@ class SharesFragment : Fragment(), KoinComponent { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - cancellationToken = CancellationToken() - refreshSharesListView = view.findViewById(R.id.select_share_refresh) + swipeRefresh = view.findViewById(R.id.select_share_refresh) sharesListView = view.findViewById(R.id.select_share_list) - refreshSharesListView!!.setOnRefreshListener { load(true) } + swipeRefresh!!.setOnRefreshListener { load(true) } emptyTextView = view.findViewById(R.id.select_share_empty) - sharesListView!!.onItemClickListener = - AdapterView.OnItemClickListener { parent, _, position, _ -> - val share = parent.getItemAtPosition(position) as Share + sharesListView!!.onItemClickListener = AdapterView.OnItemClickListener { + parent, _, + position, _ -> + val share = parent.getItemAtPosition(position) as Share - val action = NavigationGraphDirections.toTrackCollection( - shareId = share.id, - shareName = share.name - ) - findNavController().navigate(action) - } + val action = NavigationGraphDirections.toTrackCollection( + shareId = share.id, + shareName = share.name + ) + findNavController().navigate(action) + } registerForContextMenu(sharesListView!!) FragmentTitle.setTitle(this, R.string.button_bar_shares) load(false) } - override fun onDestroyView() { - cancellationToken!!.cancel() - super.onDestroyView() - } - private fun load(refresh: Boolean) { - val task: BackgroundTask> = object : FragmentBackgroundTask>( - activity, true, refreshSharesListView, cancellationToken + viewLifecycleOwner.lifecycleScope.launch( + toastingExceptionHandler() ) { - @Throws(Throwable::class) - override fun doInBackground(): List { + val result = withContext(Dispatchers.IO) { val musicService = MusicServiceFactory.getMusicService() - return musicService.getShares(refresh) + musicService.getShares(refresh) } - - override fun done(result: List) { + swipeRefresh?.isRefreshing = false + withContext(Dispatchers.Main) { shareAdapter = ShareAdapter(requireContext(), result) sharesListView?.adapter = shareAdapter emptyTextView?.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE } } - task.execute() } override fun onCreateContextMenu( @@ -133,8 +127,8 @@ class SharesFragment : Fragment(), KoinComponent { val share = sharesListView!!.getItemAtPosition(info.position) as Share when (menuItem.itemId) { R.id.share_menu_pin -> { - downloadHandler.justDownload( - DownloadAction.PIN, + DownloadUtil.justDownload( + action = DownloadAction.PIN, fragment = this, id = share.id, name = share.name, @@ -143,8 +137,8 @@ class SharesFragment : Fragment(), KoinComponent { ) } R.id.share_menu_unpin -> { - downloadHandler.justDownload( - DownloadAction.UNPIN, + DownloadUtil.justDownload( + action = DownloadAction.UNPIN, fragment = this, id = share.id, name = share.name, @@ -153,8 +147,8 @@ class SharesFragment : Fragment(), KoinComponent { ) } R.id.share_menu_download -> { - downloadHandler.justDownload( - DownloadAction.DOWNLOAD, + DownloadUtil.justDownload( + action = DownloadAction.DOWNLOAD, fragment = this, id = share.id, name = share.name, @@ -163,22 +157,20 @@ class SharesFragment : Fragment(), KoinComponent { ) } R.id.share_menu_play_now -> { - downloadHandler.fetchTracksAndAddToController( + mediaPlayerManager.playTracksAndToast( this, - share.id, - share.name, insertionMode = MediaPlayerManager.InsertionMode.CLEAR, - autoPlay = true, + id = share.id, + name = share.name, shuffle = false ) } R.id.share_menu_play_shuffled -> { - downloadHandler.fetchTracksAndAddToController( + mediaPlayerManager.playTracksAndToast( this, - share.id, - share.name, insertionMode = MediaPlayerManager.InsertionMode.CLEAR, - autoPlay = true, + id = share.id, + name = share.name, shuffle = true, ) } @@ -203,46 +195,34 @@ class SharesFragment : Fragment(), KoinComponent { .setTitle(R.string.common_confirm).setMessage( resources.getString(R.string.delete_playlist, share.name) ).setPositiveButton(R.string.common_ok) { _, _ -> - object : LoadingTask(activity, refreshSharesListView, cancellationToken) { - @Throws(Throwable::class) - override fun doInBackground(): Any? { - val musicService = MusicServiceFactory.getMusicService() - musicService.deleteShare(share.id) - return null - } - - override fun done(result: Any?) { - shareAdapter!!.remove(share) - shareAdapter!!.notifyDataSetChanged() - Util.toast( - context, - resources.getString(R.string.menu_deleted_share, share.name) - ) - } - - override fun error(error: Throwable) { - val msg: String = - if (error is OfflineException || error is ApiNotSupportedException) { - getErrorMessage( - error - ) - } else { - String.format( - Locale.ROOT, - "%s %s", - resources.getString( - R.string.menu_deleted_share_error, - share.name - ), - getErrorMessage(error) - ) - } - Util.toast(context, msg, false) - } - }.execute() + deleteShareOnServer(share) }.setNegativeButton(R.string.common_cancel, null).show() } + private fun deleteShareOnServer(share: Share) { + viewLifecycleOwner.lifecycleScope.launch( + toastingExceptionHandler( + resources.getString( + R.string.menu_deleted_share_error, + share.name + ) + ) + ) { + withContext(Dispatchers.IO) { + val musicService = MusicServiceFactory.getMusicService() + musicService.deleteShare(share.id) + } + + withContext(Dispatchers.Main) { + shareAdapter?.remove(share) + shareAdapter?.notifyDataSetChanged() + toast( + resources.getString(R.string.menu_deleted_share, share.name) + ) + } + } + } + private fun displayShareInfo(share: Share) { val textView = TextView(context) textView.setPadding(5, 5, 5, 5) @@ -257,18 +237,18 @@ class SharesFragment : Fragment(), KoinComponent { ( if (share.created == null) "" else """ - Creation Date: ${share.created!!.replace('T', ' ')} + Creation Date: ${share.created!!.replace('T', ' ')} """.trimIndent() ) + ( if (share.lastVisited == null) "" else """ - Last Visited Date: ${share.lastVisited!!.replace('T', ' ')} + Last Visited Date: ${share.lastVisited!!.replace('T', ' ')} """.trimIndent() ) + if (share.expires == null) "" else """ - Expiration Date: ${share.expires!!.replace('T', ' ')} + Expiration Date: ${share.expires!!.replace('T', ' ')} """.trimIndent() ) Linkify.addLinks(message, Linkify.WEB_URLS) @@ -299,50 +279,31 @@ class SharesFragment : Fragment(), KoinComponent { alertDialog.setTitle(R.string.playlist_update_info) alertDialog.setView(dialogView) alertDialog.setPositiveButton(R.string.common_ok) { _, _ -> - object : LoadingTask(activity, refreshSharesListView, cancellationToken) { - @Throws(Throwable::class) - override fun doInBackground(): Any? { - var millis = timeSpanPicker.getTimeSpan() - if (millis > 0) { - millis += System.currentTimeMillis() - } - val shareDescriptionText = shareDescription.text - val description = shareDescriptionText?.toString() - val musicService = MusicServiceFactory.getMusicService() - musicService.updateShare(share.id, description, millis) - return null - } - - override fun done(result: Any?) { - load(true) - Util.toast( - context, - resources.getString(R.string.playlist_updated_info, share.name) - ) - } - - override fun error(error: Throwable) { - val msg: String = - if (error is OfflineException || error is ApiNotSupportedException) { - getErrorMessage( - error - ) - } else { - String.format( - Locale.ROOT, - "%s %s", - resources.getString( - R.string.playlist_updated_info_error, - share.name - ), - getErrorMessage(error) - ) - } - Util.toast(context, msg, false) - } - }.execute() + var millis = timeSpanPicker.getTimeSpan() + if (millis > 0) { + millis += System.currentTimeMillis() + } + updateShareOnServer(millis, shareDescription.text.toString(), share) } alertDialog.setNegativeButton(R.string.common_cancel, null) alertDialog.show() } + + private fun updateShareOnServer( + millis: Long, + description: String, + share: Share + ) { + launchWithToast { + withContext(Dispatchers.IO) { + val musicService = MusicServiceFactory.getMusicService() + musicService.updateShare(share.id, description, millis) + } + + withContext(Dispatchers.Main) { + load(true) + resources.getString(R.string.playlist_updated_info, share.name) + } + } + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt index 06bc30ff..bb7a76c0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt @@ -190,17 +190,18 @@ class ImageLoader( if (artist.coverArt == null) return val key = FileUtil.getArtistArtKey(artist.name, false) val file = FileUtil.getAlbumArtFile(key) - cacheCoverArt(artist.coverArt!!, file) + downloadCoverArt(artist.coverArt!!, file) } /** * Download a cover art file of a Track and cache it on disk */ - fun cacheCoverArt(track: Track) { - cacheCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track)) + fun downloadCoverArt(track: Track) { + if (track.coverArt == null) return + downloadCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track)) } - fun cacheCoverArt(id: String, file: String) = launch { + fun downloadCoverArt(id: String, file: String) = launch { if (id.isBlank()) return@launch withContext(Dispatchers.IO) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ChatViewModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ChatViewModel.kt new file mode 100644 index 00000000..6f285062 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ChatViewModel.kt @@ -0,0 +1,31 @@ +/* + * ChatViewModel.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.model +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import org.moire.ultrasonic.domain.ChatMessage + +class ChatViewModel : ViewModel() { + + // MutableLiveData to store chat messages + private val _chatMessages = MutableLiveData>() + + // LiveData to observe chat messages + val chatMessages: LiveData> + get() = _chatMessages + + // Last chat message time + var lastChatMessageTime: Long = 0 + + // Function to update chat messages + fun updateChatMessages(messages: List) { + val updatedMessages = _chatMessages.value.orEmpty() + messages + _chatMessages.postValue(updatedMessages) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt index 84087b86..b0492475 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/SearchListModel.kt @@ -14,17 +14,18 @@ class SearchListModel(application: Application) : GenericListModel(application) var searchResult: MutableLiveData = MutableLiveData() - suspend fun search(query: String) { + suspend fun search(query: String): SearchResult? { val maxArtists = Settings.maxArtists val maxAlbums = Settings.maxAlbums val maxSongs = Settings.maxSongs - withContext(Dispatchers.IO) { + return withContext(Dispatchers.IO) { val criteria = SearchCriteria(query, maxArtists, maxAlbums, maxSongs) val service = MusicServiceFactory.getMusicService() val result = service.search(criteria) if (result != null) searchResult.postValue(result) + result } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/AlbumArtContentProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/AlbumArtContentProvider.kt index 51b3ed52..d55de716 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/AlbumArtContentProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/AlbumArtContentProvider.kt @@ -56,8 +56,11 @@ class AlbumArtContentProvider : ContentProvider(), KoinComponent { val albumArtFile = FileUtil.getAlbumArtFile(parts[1]) Timber.d("AlbumArtContentProvider openFile id: %s; file: %s", parts[0], albumArtFile) + + // TODO: Check if the dependency on the image loader could be removed. + // TODO: This method can be called outside of our regular lifecycle, where Koin might not exist yet imageLoaderProvider.executeOn { - it.cacheCoverArt(parts[0], albumArtFile) + it.downloadCoverArt(parts[0], albumArtFile) } val file = File(albumArtFile) if (!file.exists()) return null diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.kt index 3a422a2b..193bbe52 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.kt @@ -23,7 +23,7 @@ import android.widget.RemoteViews import org.moire.ultrasonic.R import org.moire.ultrasonic.activity.NavigationActivity import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver +import org.moire.ultrasonic.receiver.UltrasonicIntentReceiver import org.moire.ultrasonic.util.Constants import timber.log.Timber @@ -233,7 +233,7 @@ open class UltrasonicAppWidgetProvider : AppWidgetProvider() { // Emulate media button clicks. intent = Intent(Constants.CMD_PROCESS_KEYCODE) - intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java) + intent.component = ComponentName(context, UltrasonicIntentReceiver::class.java) intent.putExtra( Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) @@ -241,12 +241,12 @@ open class UltrasonicAppWidgetProvider : AppWidgetProvider() { flags = 0 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // needed starting Android 12 (S = 31) - flags = flags or PendingIntent.FLAG_IMMUTABLE + flags = PendingIntent.FLAG_IMMUTABLE } pendingIntent = PendingIntent.getBroadcast(context, 11, intent, flags) views.setOnClickPendingIntent(R.id.control_play, pendingIntent) intent = Intent(Constants.CMD_PROCESS_KEYCODE) - intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java) + intent.component = ComponentName(context, UltrasonicIntentReceiver::class.java) intent.putExtra( Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT) @@ -254,7 +254,7 @@ open class UltrasonicAppWidgetProvider : AppWidgetProvider() { pendingIntent = PendingIntent.getBroadcast(context, 12, intent, flags) views.setOnClickPendingIntent(R.id.control_next, pendingIntent) intent = Intent(Constants.CMD_PROCESS_KEYCODE) - intent.component = ComponentName(context, MediaButtonIntentReceiver::class.java) + intent.component = ComponentName(context, UltrasonicIntentReceiver::class.java) intent.putExtra( Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/MediaButtonIntentReceiver.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/MediaButtonIntentReceiver.kt deleted file mode 100644 index 6e40062f..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/MediaButtonIntentReceiver.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * MediaButtonIntentReceiver.kt - * Copyright (C) 2009-2022 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.receiver - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Parcelable -import java.lang.Exception -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport -import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.Settings -import timber.log.Timber - -/** - * This class is used to receive commands from the widget - */ -class MediaButtonIntentReceiver : BroadcastReceiver(), KoinComponent { - private val lifecycleSupport: MediaPlayerLifecycleSupport by inject() - - override fun onReceive(context: Context, intent: Intent) { - val intentAction = intent.action - - // If media button are turned off and we received a media button, exit - if (!Settings.mediaButtonsEnabled && Intent.ACTION_MEDIA_BUTTON == intentAction) return - - // Only process media buttons and CMD_PROCESS_KEYCODE, which is received from the widgets - if (Intent.ACTION_MEDIA_BUTTON != intentAction && - Constants.CMD_PROCESS_KEYCODE != intentAction - ) return - val extras = intent.extras ?: return - - val event = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - extras.getParcelable(Intent.EXTRA_KEY_EVENT, Parcelable::class.java) - } else { - @Suppress("DEPRECATION") - extras.get(Intent.EXTRA_KEY_EVENT) as Parcelable? - } - - Timber.i("Got MEDIA_BUTTON key event: %s", event) - try { - val serviceIntent = Intent(Constants.CMD_PROCESS_KEYCODE) - serviceIntent.putExtra(Intent.EXTRA_KEY_EVENT, event) - lifecycleSupport.receiveIntent(serviceIntent) - if (isOrderedBroadcast) { - abortBroadcast() - } - } catch (ignored: Exception) { - // Ignored. - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.kt index 9f75fdfd..86929b26 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.kt @@ -10,20 +10,19 @@ package org.moire.ultrasonic.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import org.koin.java.KoinJavaComponent.inject +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport import timber.log.Timber -class UltrasonicIntentReceiver : BroadcastReceiver() { - private val lifecycleSupport = inject( - MediaPlayerLifecycleSupport::class.java - ) +class UltrasonicIntentReceiver : BroadcastReceiver(), KoinComponent { + private val lifecycleSupport by inject() override fun onReceive(context: Context, intent: Intent) { val intentAction = intent.action Timber.i("Received Ultrasonic Intent: %s", intentAction) try { - lifecycleSupport.value.receiveIntent(intent) + lifecycleSupport.receiveIntent(intent) if (isOrderedBroadcast) { abortBroadcast() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt index d6312be7..965f8b6b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt @@ -315,8 +315,12 @@ class CachedMusicService(private val musicService: MusicService) : MusicService, return musicService.getStreamUrl(id, maxBitRate, format) } + override fun isJukeboxAvailable(): Boolean { + return musicService.isJukeboxAvailable() + } + @Throws(Exception::class) - override fun updateJukeboxPlaylist(ids: List?): JukeboxStatus { + override fun updateJukeboxPlaylist(ids: List): JukeboxStatus { return musicService.updateJukeboxPlaylist(ids) } @@ -330,6 +334,11 @@ class CachedMusicService(private val musicService: MusicService) : MusicService, return musicService.stopJukebox() } + @Throws(Exception::class) + override fun clearJukebox(): JukeboxStatus { + return musicService.clearJukebox() + } + @Throws(Exception::class) override fun startJukebox(): JukeboxStatus { return musicService.startJukebox() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt index dff3d5b3..0bf444a5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt @@ -29,8 +29,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.koin.java.KoinJavaComponent.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.domain.Track @@ -61,6 +63,7 @@ private const val CHECK_INTERVAL = 5000L class DownloadService : Service(), KoinComponent { private var scope: CoroutineScope? = null private val storageMonitor: ExternalStorageMonitor by inject() + private val cacheCleaner: CacheCleaner by inject() private val binder: IBinder = SimpleServiceBinder(this) private var isInForeground = false @@ -153,7 +156,7 @@ class DownloadService : Service(), KoinComponent { // Stop Executor service when done downloading if (activeDownloads.isEmpty()) { - CacheCleaner().cleanSpace() + cacheCleaner.cleanSpace() stopSelf() } @@ -279,7 +282,17 @@ class DownloadService : Service(), KoinComponent { updateSaveFlag: Boolean = false ) { CoroutineScope(Dispatchers.IO).launch { + downloadAsync(tracks, save, isHighPriority, updateSaveFlag) + } + } + suspend fun downloadAsync( + tracks: List, + save: Boolean = false, + isHighPriority: Boolean = false, + updateSaveFlag: Boolean = false + ) { + withContext(Dispatchers.IO) { // Remove tracks which are already downloaded and update the save flag // if needed var filteredTracks = if (updateSaveFlag) { @@ -384,8 +397,14 @@ class DownloadService : Service(), KoinComponent { failedList.clear() } - fun delete(track: Track) { + private fun delete(track: Track) { CoroutineScope(Dispatchers.IO).launch { + deleteAsync(track) + } + } + + private suspend fun deleteAsync(track: Track) { + withContext(Dispatchers.IO) { downloadQueue.get(track.id)?.let { downloadQueue.remove(it) } failedList[track.id]?.let { downloadQueue.remove(it) } cancelDownload(track) @@ -394,7 +413,8 @@ class DownloadService : Service(), KoinComponent { Storage.delete(track.getCompleteFile()) Storage.delete(track.getPinnedFile()) postState(track, DownloadState.IDLE) - CacheCleaner().cleanDatabaseSelective(track) + val cacheCleaner: CacheCleaner by inject(CacheCleaner::class.java) + cacheCleaner.cleanDatabaseSelective(track) Util.scanMedia(track.getPinnedFile()) } } @@ -409,22 +429,38 @@ class DownloadService : Service(), KoinComponent { tracks.forEach(::delete) } - fun unpin(track: Track) { - // Update Pinned flag of items in progress - downloadQueue.get(track.id)?.pinned = false - activeDownloads[track.id]?.downloadTrack?.pinned = false - failedList[track.id]?.pinned = false + suspend fun unpinAsync(tracks: List) { + tracks.forEach { unpinAsync(it) } + } - val pinnedFile = track.getPinnedFile() - if (!Storage.isPathExists(pinnedFile)) return - val file = Storage.getFromPath(track.getPinnedFile()) ?: return - try { - Storage.rename(file, track.getCompleteFile()) - } catch (ignored: FileAlreadyExistsException) { - // Play console has revealed a crash when for some reason both files exist - Storage.delete(file.path) + suspend fun deleteAsync(tracks: List) { + tracks.forEach { deleteAsync(it) } + } + + private fun unpin(track: Track) { + CoroutineScope(Dispatchers.IO).launch { + unpinAsync(track) + } + } + + private suspend fun unpinAsync(track: Track) { + withContext(Dispatchers.IO) { + // Update Pinned flag of items in progress + downloadQueue.get(track.id)?.pinned = false + activeDownloads[track.id]?.downloadTrack?.pinned = false + failedList[track.id]?.pinned = false + + val pinnedFile = track.getPinnedFile() + if (!Storage.isPathExists(pinnedFile)) return@withContext + val file = Storage.getFromPath(track.getPinnedFile()) ?: return@withContext + try { + Storage.rename(file, track.getCompleteFile()) + } catch (ignored: FileAlreadyExistsException) { + // Play console has revealed a crash when for some reason both files exist + Storage.delete(file.path) + } + postState(track, DownloadState.DONE) } - postState(track, DownloadState.DONE) } @Suppress("ReturnCount") @@ -474,12 +510,16 @@ class DownloadService : Service(), KoinComponent { } private fun startService() { - val context = UApp.applicationContext() - val intent = Intent(context, DownloadService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) + try { + val context = UApp.applicationContext() + val intent = Intent(context, DownloadService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (e: IllegalStateException) { + Timber.w(e, "Failed to start download service: the app is in the background") } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt index e36975b8..443defb5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt @@ -256,7 +256,7 @@ class DownloadTask( // Download the largest size that we can display in the UI imageLoaderProvider.executeOn { imageLoader -> - imageLoader.cacheCoverArt(this) + imageLoader.downloadCoverArt(this@cacheMetadataAndArtwork) // Cache small copies of the Artist picture directArtist?.let { imageLoader.cacheArtistPicture(it) } compilationArtist?.let { imageLoader.cacheArtistPicture(it) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt index 4be7ed45..ebbd6cba 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -53,7 +53,7 @@ private const val QUEUE_POLL_INTERVAL_SECONDS = 1L * TODO: Persist RC state? * TODO: Minimize status updates. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "DeprecatedCallableAddReplaceWith") @SuppressLint("UnsafeOptInUsageError") class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { private val tasks = TaskQueue() @@ -314,6 +314,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { override fun increaseDeviceVolume(flags: Int) { gain = (gain + 1).coerceAtMost(MAX_GAIN) + @Suppress("DEPRECATION") deviceVolume = gain } @@ -324,6 +325,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { override fun decreaseDeviceVolume(flags: Int) { gain = (gain - 1).coerceAtLeast(0) + @Suppress("DEPRECATION") deviceVolume = gain } @@ -334,6 +336,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { override fun setDeviceMuted(muted: Boolean, flags: Int) { gain = 0 + @Suppress("DEPRECATION") deviceVolume = gain } @@ -583,7 +586,13 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { for (item in playlist) { ids.add(item.mediaId) } - tasks.add(SetPlaylist(ids)) + + if (ids.isNotEmpty()) { + tasks.add(SetPlaylist(ids)) + } else { + tasks.add(ClearPlaylist()) + } + Handler(Looper.getMainLooper()).post { listeners.sendEvent( Player.EVENT_TIMELINE_CHANGED @@ -684,6 +693,13 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { } } + private inner class ClearPlaylist : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.clearJukebox() + } + } + private inner class Start : JukeboxTask() { @Throws(Exception::class) override fun execute(): JukeboxStatus { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt index d32b2ca6..a879d4af 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt @@ -23,7 +23,7 @@ import androidx.media3.common.Tracks * This class helps to hide the unused (thus unimplemented) functions * of the crowded Player interface, so the JukeboxMediaPlayer class can be a bit clearer. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "DeprecatedCallableAddReplaceWith") @SuppressLint("UnsafeOptInUsageError") abstract class JukeboxUnimplementedFunctions : Player { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt similarity index 97% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt index 7acb1122..f96fe6ff 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt @@ -1,13 +1,12 @@ /* - * AutoMediaBrowserCallback.kt - * Copyright (C) 2009-2022 Ultrasonic developers + * MediaLibrarySessionCallback.kt + * Copyright (C) 2009-2023 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ -package org.moire.ultrasonic.playback +package org.moire.ultrasonic.service -import android.content.Context import android.os.Build import android.os.Bundle import androidx.car.app.connection.CarConnection @@ -32,11 +31,14 @@ import androidx.media3.session.SessionResult.RESULT_SUCCESS import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.future +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.R @@ -48,8 +50,6 @@ import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.SearchCriteria import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.MusicServiceFactory -import org.moire.ultrasonic.service.RatingManager import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.buildMediaItem import org.moire.ultrasonic.util.toMediaItem @@ -98,10 +98,12 @@ const val PLAY_COMMAND = "play " * MediaBrowserService implementation for e.g. Android Auto */ @Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember") -class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callback, KoinComponent { +class MediaLibrarySessionCallback : + MediaLibraryService.MediaLibrarySession.Callback, + KoinComponent { - private val applicationContext: Context by inject() private val activeServerProvider: ActiveServerProvider by inject() + private val playbackStateSerializer: PlaybackStateSerializer by inject() private val serviceJob = SupervisorJob() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) @@ -243,6 +245,25 @@ class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callbac ) } + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + override fun onPlaybackResumption( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo + ): ListenableFuture { + val result = SettableFuture.create() + serviceScope.launch { + val state = playbackStateSerializer.deserializeNow() + if (state != null) { + result.set(state.toMediaItemsWithStartPosition()) + withContext(Dispatchers.Main) { + mediaSession.player.shuffleModeEnabled = state.shufflePlay + mediaSession.player.repeatMode = state.repeatMode + } + } + } + return result + } + private fun configureRepeatMode(player: Player) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Timber.d("Car app library available, observing CarConnection") @@ -251,7 +272,7 @@ class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callbac var lastCarConnectionType = -1 - CarConnection(applicationContext).type.observeForever { + CarConnection(UApp.applicationContext()).type.observeForever { if (lastCarConnectionType == it) return@observeForever diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt index 8c64d9a0..939c51ee 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -16,8 +16,6 @@ import android.os.Build import android.view.KeyEvent import io.reactivex.rxjava3.disposables.CompositeDisposable import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.app.UApp.Companion.applicationContext import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.util.CacheCleaner @@ -29,11 +27,13 @@ import timber.log.Timber /** * This class is responsible for handling received events for the Media Player implementation */ -class MediaPlayerLifecycleSupport : KoinComponent { +class MediaPlayerLifecycleSupport( + val mediaPlayerManager: MediaPlayerManager, + private val playbackStateSerializer: PlaybackStateSerializer, + val imageLoaderProvider: ImageLoaderProvider, + private val cacheCleaner: CacheCleaner +) : KoinComponent { private lateinit var ratingManager: RatingManager - private val playbackStateSerializer by inject() - private val mediaPlayerManager by inject() - private val imageLoaderProvider: ImageLoaderProvider by inject() private var created = false private var headsetEventReceiver: BroadcastReceiver? = null @@ -70,7 +70,7 @@ class MediaPlayerLifecycleSupport : KoinComponent { registerHeadsetReceiver() - CacheCleaner().clean() + cacheCleaner.clean() created = true ratingManager = RatingManager.instance Timber.i("LifecycleSupport created") @@ -78,15 +78,10 @@ class MediaPlayerLifecycleSupport : KoinComponent { private fun restoreLastSession(autoPlay: Boolean, afterRestore: Runnable?) { playbackStateSerializer.deserialize { + if (it == null) return@deserialize null + Timber.i("Restoring %s songs", it.songs.size) - Timber.i("Restoring %s songs", it!!.songs.size) - - mediaPlayerManager.restore( - it, - autoPlay, - false - ) - + mediaPlayerManager.restore(it, autoPlay) afterRestore?.run() } } @@ -99,7 +94,6 @@ class MediaPlayerLifecycleSupport : KoinComponent { applicationContext().unregisterReceiver(headsetEventReceiver) imageLoaderProvider.clearImageLoader() - UApp.instance!!.shutdownKoin() created = false Timber.i("LifecycleSupport destroyed") diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt index af24061b..bbe3901a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt @@ -7,10 +7,10 @@ package org.moire.ultrasonic.service import android.content.ComponentName -import android.content.Context import android.os.Handler import android.os.Looper import androidx.annotation.IntRange +import androidx.fragment.app.Fragment import androidx.media3.common.C import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem @@ -29,18 +29,21 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent -import org.koin.core.component.inject +import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp -import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID import org.moire.ultrasonic.data.RatingUpdate import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.playback.PlaybackService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService +import org.moire.ultrasonic.util.DownloadUtil import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.Util.navigateToCurrent +import org.moire.ultrasonic.util.Util.toast import org.moire.ultrasonic.util.getTrackId +import org.moire.ultrasonic.util.launchWithToast import org.moire.ultrasonic.util.setRating import org.moire.ultrasonic.util.setStarred import org.moire.ultrasonic.util.toMediaItem @@ -59,12 +62,9 @@ private const val VOLUME_DELTA = 0.05f @Suppress("TooManyFunctions") class MediaPlayerManager( private val playbackStateSerializer: PlaybackStateSerializer, - private val externalStorageMonitor: ExternalStorageMonitor, - val context: Context + private val externalStorageMonitor: ExternalStorageMonitor ) : KoinComponent { - private val activeServerProvider: ActiveServerProvider by inject() - private var created = false var suggestedPlaylistName: String? = null var keepScreenOn = false @@ -76,8 +76,10 @@ class MediaPlayerManager( private var mainScope = CoroutineScope(Dispatchers.Main) - private var sessionToken = - SessionToken(context, ComponentName(context, PlaybackService::class.java)) + private var sessionToken = SessionToken( + UApp.applicationContext(), + ComponentName(UApp.applicationContext(), PlaybackService::class.java) + ) private var mediaControllerFuture: ListenableFuture? = null @@ -148,12 +150,11 @@ class MediaPlayerManager( Timber.w(error.toString()) if (!isJukeboxEnabled) return - val context = UApp.applicationContext() mainScope.launch { - Util.toast( - context, + toast( error.errorCode, - false + false, + UApp.applicationContext() ) } isJukeboxEnabled = false @@ -202,7 +203,7 @@ class MediaPlayerManager( } rxBusSubscription += RxBus.activeServerChangedObservable.subscribe { - val jukebox = activeServerProvider.getActiveServer().jukeboxByDefault + val jukebox = it.jukeboxByDefault // Remove all songs when changing servers before turning on Jukebox. // Jukebox wouldn't find the songs on the new server. if (jukebox) controller?.clearMediaItems() @@ -254,10 +255,10 @@ class MediaPlayerManager( private fun createMediaController(onCreated: () -> Unit) { mediaControllerFuture = MediaController.Builder( - context, + UApp.applicationContext(), sessionToken ) - // Specify mainThread explicitely + // Specify mainThread explicitly .setApplicationLooper(Looper.getMainLooper()) .buildAsync() @@ -328,17 +329,15 @@ class MediaPlayerManager( externalStorageMonitor.onDestroy() DownloadService.requestStop() created = false - Timber.i("MediaPlayerController destroyed") + Timber.i("MediaPlayerManager destroyed") } @Synchronized fun restore( state: PlaybackState, - autoPlay: Boolean, - newPlaylist: Boolean + autoPlay: Boolean ) { - val insertionMode = if (newPlaylist) InsertionMode.CLEAR - else InsertionMode.APPEND + val insertionMode = InsertionMode.APPEND addToPlaylist( state.songs, @@ -482,6 +481,79 @@ class MediaPlayerManager( } } + private suspend fun addToPlaylistAsync( + songs: List, + autoPlay: Boolean, + shuffle: Boolean, + insertionMode: InsertionMode + ) { + withContext(Dispatchers.Main) { + addToPlaylist( + songs = songs, + autoPlay = autoPlay, + shuffle = shuffle, + insertionMode = insertionMode + ) + } + } + + @Suppress("LongParameterList") + fun playTracksAndToast( + fragment: Fragment, + insertionMode: InsertionMode, + tracks: List = listOf(), + id: String? = null, + name: String? = "", + isShare: Boolean = false, + isDirectory: Boolean = true, + shuffle: Boolean = false, + isArtist: Boolean = false + ) { + + fragment.launchWithToast { + + val list: List = + tracks.ifEmpty { + requireNotNull(id) + DownloadUtil.getTracksFromServerAsync(isArtist, id, isDirectory, name, isShare) + } + + addToPlaylistAsync( + songs = list, + insertionMode = insertionMode, + autoPlay = (insertionMode == InsertionMode.CLEAR), + shuffle = shuffle, + ) + + if (insertionMode == InsertionMode.CLEAR) { + fragment.navigateToCurrent() + } + + when (insertionMode) { + InsertionMode.AFTER_CURRENT -> + quantize(R.plurals.n_songs_added_after_current, list) + + InsertionMode.APPEND -> + quantize(R.plurals.n_songs_added_to_end, list) + + InsertionMode.CLEAR -> { + if (Settings.shouldTransitionOnPlayback) + null + else + quantize(R.plurals.n_songs_added_play_now, list) + } + } + } + } + + private fun quantize(resId: Int, tracks: List): String { + return UApp.applicationContext().resources.getQuantityString( + resId, + tracks.size, + tracks.size + ) + } + @set:Synchronized var isShufflePlayEnabled: Boolean get() = controller?.shuffleModeEnabled == true @@ -657,21 +729,6 @@ class MediaPlayerManager( Timber.i("MediaPlayerController released") } - /** - * This function calls the music service directly and - * therefore can't be called from the main thread - */ - val isJukeboxAvailable: Boolean - get() { - try { - val username = activeServerProvider.getActiveServer().userName - return getMusicService().getUser(username).jukeboxRole - } catch (all: Exception) { - Timber.w(all, "Error getting user information") - } - return false - } - fun adjustVolume(up: Boolean) { val delta = if (up) VOLUME_DELTA else -VOLUME_DELTA var gain = controller?.volume ?: return @@ -684,7 +741,7 @@ class MediaPlayerManager( /* * Sets the rating of the current track */ - fun setRating(rating: Rating) { + private fun setRating(rating: Rating) { if (controller is MediaController) { (controller as MediaController).setRating(rating) } @@ -732,7 +789,7 @@ class MediaPlayerManager( val currentMediaItemIndex: Int get() = controller?.currentMediaItemIndex ?: -1 - fun getCurrentShuffleIndex(): Int { + private fun getCurrentShuffleIndex(): Int { val currentMediaItemIndex = controller?.currentMediaItemIndex ?: return -1 return getShuffledIndexOf(currentMediaItemIndex) } @@ -776,9 +833,8 @@ class MediaPlayerManager( * in the shuffled timeline. * @return The index of the item in the shuffled timeline, or [C.INDEX_UNSET] if not found. */ - fun getShuffledIndexOf(searchPosition: Int): Int { - return getWindowIndexWhere(false) { - _, windowIndex -> + private fun getShuffledIndexOf(searchPosition: Int): Int { + return getWindowIndexWhere(false) { _, windowIndex -> windowIndex == searchPosition } } @@ -792,8 +848,7 @@ class MediaPlayerManager( * @return the index of the item in the unshuffled timeline, or [C.INDEX_UNSET] if not found. */ fun getUnshuffledIndexOf(shufflePosition: Int): Int { - return getWindowIndexWhere(true) { - count, _ -> + return getWindowIndexWhere(true) { count, _ -> count == shufflePosition } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt index 2d37a648..73f292f0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt @@ -138,8 +138,10 @@ interface MusicService { @Throws(Exception::class) fun getStreamUrl(id: String, maxBitRate: Int?, format: String?): String? + fun isJukeboxAvailable(): Boolean + @Throws(Exception::class) - fun updateJukeboxPlaylist(ids: List?): JukeboxStatus + fun updateJukeboxPlaylist(ids: List): JukeboxStatus @Throws(Exception::class) fun skipJukebox(index: Int, offsetSeconds: Int): JukeboxStatus @@ -147,6 +149,9 @@ interface MusicService { @Throws(Exception::class) fun stopJukebox(): JukeboxStatus + @Throws(Exception::class) + fun clearJukebox(): JukeboxStatus + @Throws(Exception::class) fun startJukebox(): JukeboxStatus diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt index 039e1de1..504cf4a0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt @@ -335,8 +335,12 @@ class OfflineMusicService : MusicService, KoinComponent { } } + override fun isJukeboxAvailable(): Boolean { + return false + } + @Throws(Exception::class) - override fun updateJukeboxPlaylist(ids: List?): JukeboxStatus { + override fun updateJukeboxPlaylist(ids: List): JukeboxStatus { throw OfflineException("Jukebox not available in offline mode") } @@ -349,6 +353,10 @@ class OfflineMusicService : MusicService, KoinComponent { override fun stopJukebox(): JukeboxStatus { throw OfflineException("Jukebox not available in offline mode") } + @Throws(Exception::class) + override fun clearJukebox(): JukeboxStatus { + throw OfflineException("Jukebox not available in offline mode") + } @Throws(Exception::class) override fun startJukebox(): JukeboxStatus { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt similarity index 97% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt index e9047381..2b347d9d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt @@ -1,10 +1,10 @@ /* * PlaybackService.kt - * Copyright (C) 2009-2022 Ultrasonic developers + * Copyright (C) 2009-2023 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ -package org.moire.ultrasonic.playback +package org.moire.ultrasonic.service import android.annotation.SuppressLint import android.app.PendingIntent @@ -42,15 +42,11 @@ import org.moire.ultrasonic.activity.NavigationActivity import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.audiofx.EqualizerController import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.data.CachedDataSource import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider -import org.moire.ultrasonic.service.DownloadService -import org.moire.ultrasonic.service.JukeboxMediaPlayer -import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.service.RxBus -import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util @@ -68,7 +64,7 @@ class PlaybackService : private var equalizer: EqualizerController? = null private val activeServerProvider: ActiveServerProvider by inject() - private lateinit var librarySessionCallback: AutoMediaBrowserCallback + private lateinit var librarySessionCallback: MediaLibrarySessionCallback private var rxBusSubscription = CompositeDisposable() @@ -115,6 +111,7 @@ class PlaybackService : isStarted = false stopForegroundRemoveNotification() stopSelf() + instance = null } private val resolver: ResolvingDataSource.Resolver = ResolvingDataSource.Resolver { @@ -142,7 +139,7 @@ class PlaybackService : actualBackend = desiredBackend // Create browser interface - librarySessionCallback = AutoMediaBrowserCallback() + librarySessionCallback = MediaLibrarySessionCallback() // This will need to use the AutoCalls mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackState.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackState.kt index d57a5b86..3d81d21b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackState.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackState.kt @@ -1,7 +1,9 @@ package org.moire.ultrasonic.service +import androidx.media3.session.MediaSession import java.io.Serializable import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.util.toMediaItem /** * Represents the state of the Media Player implementation @@ -17,3 +19,12 @@ data class PlaybackState( private const val serialVersionUID = -293487987L } } + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +fun PlaybackState.toMediaItemsWithStartPosition(): MediaSession.MediaItemsWithStartPosition { + return MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + currentPlayingIndex, + currentPlayingPosition.toLong(), + ) +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt index bad9dc2b..c1a7fa85 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt @@ -24,6 +24,8 @@ import timber.log.Timber * This class is responsible for the serialization / deserialization * of the playlist and the player state (e.g. current playing number and play position) * to the filesystem. + * + * TODO: Should use: MediaItemsWithStartPosition */ class PlaybackStateSerializer : KoinComponent { @@ -56,7 +58,7 @@ class PlaybackStateSerializer : KoinComponent { } } - fun serializeNow( + private fun serializeNow( tracks: Iterable, currentPlayingIndex: Int, currentPlayingPosition: Int, @@ -85,7 +87,10 @@ class PlaybackStateSerializer : KoinComponent { if (isDeserializing.get()) return ioScope.launch { try { - deserializeNow(afterDeserialized) + val state = deserializeNow() + mainScope.launch { + afterDeserialized(state) + } isSetup.set(true) } catch (all: Exception) { Timber.e(all, "Had a problem deserializing:") @@ -95,11 +100,11 @@ class PlaybackStateSerializer : KoinComponent { } } - private fun deserializeNow(afterDeserialized: (PlaybackState?) -> Unit?) { + fun deserializeNow(): PlaybackState? { val state = FileUtil.deserialize( context, Constants.FILENAME_PLAYLIST_SER - ) ?: return + ) ?: return null Timber.i( "Deserialized currentPlayingIndex: %d, currentPlayingPosition: %d, shuffle: %b", @@ -108,9 +113,7 @@ class PlaybackStateSerializer : KoinComponent { state.shufflePlay ) - mainScope.launch { - afterDeserialized(state) - } + return state } companion object { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt index a31ea7c2..bdf9d5ef 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt @@ -51,7 +51,7 @@ import timber.log.Timber */ @Suppress("LargeClass") open class RESTMusicService( - val subsonicAPIClient: SubsonicAPIClient, + private val subsonicAPIClient: SubsonicAPIClient, private val activeServerProvider: ActiveServerProvider ) : MusicService { @@ -504,9 +504,14 @@ open class RESTMusicService( builder.build() } + override fun isJukeboxAvailable(): Boolean { + val username = activeServerProvider.getActiveServer().userName + return getUser(username).jukeboxRole + } + @Throws(Exception::class) override fun updateJukeboxPlaylist( - ids: List? + ids: List ): JukeboxStatus { val response = API.jukeboxControl(JukeboxAction.SET, null, null, ids, null) .execute().throwOnFailure() @@ -533,6 +538,14 @@ open class RESTMusicService( return response.body()!!.jukebox.toDomainEntity() } + @Throws(Exception::class) + override fun clearJukebox(): JukeboxStatus { + val response = API.jukeboxControl(JukeboxAction.CLEAR, null, null, null, null) + .execute().throwOnFailure() + + return response.body()!!.jukebox.toDomainEntity() + } + @Throws(Exception::class) override fun startJukebox(): JukeboxStatus { val response = API.jukeboxControl(JukeboxAction.START, null, null, null, null) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt index 8524fdff..ceadd14e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt @@ -8,6 +8,7 @@ import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.subjects.PublishSubject import java.util.concurrent.TimeUnit import org.moire.ultrasonic.data.RatingUpdate +import org.moire.ultrasonic.data.ServerSetting import org.moire.ultrasonic.domain.Track class RxBus { @@ -32,9 +33,9 @@ class RxBus { var activeServerChangingObservable: Observable = activeServerChangingPublisher - var activeServerChangedPublisher: PublishSubject = + var activeServerChangedPublisher: PublishSubject = PublishSubject.create() - var activeServerChangedObservable: Observable = + var activeServerChangedObservable: Observable = activeServerChangedPublisher.observeOn(mainThread()) val themeChangedEventPublisher: PublishSubject = diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt deleted file mode 100644 index a0b4c156..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt +++ /dev/null @@ -1,260 +0,0 @@ -/* - * DownloadHandler.kt - * Copyright (C) 2009-2022 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.subsonic - -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import java.util.LinkedList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.moire.ultrasonic.R -import org.moire.ultrasonic.data.ActiveServerProvider.Companion.shouldUseId3Tags -import org.moire.ultrasonic.domain.MusicDirectory -import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.DownloadService -import org.moire.ultrasonic.service.MediaPlayerManager -import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.executeTaskWithToast - -/** - * Retrieves a list of songs and adds them to the now playing list - */ -@Suppress("LongParameterList") -class DownloadHandler( - val mediaPlayerManager: MediaPlayerManager, - private val networkAndStorageChecker: NetworkAndStorageChecker -) : CoroutineScope by CoroutineScope(Dispatchers.IO) { - private val maxSongs = 500 - - fun justDownload( - action: DownloadAction, - fragment: Fragment, - id: String? = null, - name: String? = "", - isShare: Boolean = false, - isDirectory: Boolean = true, - isArtist: Boolean = false, - tracks: List? = null - ) { - var successString: String? = null - - // Launch the Job - executeTaskWithToast({ - val tracksToDownload: List = tracks - ?: getTracksFromServer(isArtist, id!!, isDirectory, name, isShare) - - withContext(Dispatchers.Main) { - // If we are just downloading tracks we don't need to add them to the controller - when (action) { - DownloadAction.DOWNLOAD -> DownloadService.download( - tracksToDownload, - save = false, - updateSaveFlag = true - ) - DownloadAction.PIN -> DownloadService.download( - tracksToDownload, - save = true, - updateSaveFlag = true - ) - DownloadAction.UNPIN -> DownloadService.unpin(tracksToDownload) - DownloadAction.DELETE -> DownloadService.delete(tracksToDownload) - } - successString = when (action) { - DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString( - R.plurals.n_songs_to_be_downloaded, - tracksToDownload.size, - tracksToDownload.size - ) - DownloadAction.UNPIN -> { - fragment.resources.getQuantityString( - R.plurals.n_songs_unpinned, - tracksToDownload.size, - tracksToDownload.size - ) - } - DownloadAction.PIN -> { - fragment.resources.getQuantityString( - R.plurals.n_songs_pinned, - tracksToDownload.size, - tracksToDownload.size - ) - } - DownloadAction.DELETE -> { - fragment.resources.getQuantityString( - R.plurals.n_songs_deleted, - tracksToDownload.size, - tracksToDownload.size - ) - } - } - } - }) { successString } - } - - fun fetchTracksAndAddToController( - fragment: Fragment, - id: String, - name: String? = "", - isShare: Boolean = false, - isDirectory: Boolean = true, - insertionMode: MediaPlayerManager.InsertionMode, - autoPlay: Boolean, - shuffle: Boolean = false, - isArtist: Boolean = false - ) { - var successString: String? = null - // Launch the Job - executeTaskWithToast({ - val songs: MutableList = - getTracksFromServer(isArtist, id, isDirectory, name, isShare) - - withContext(Dispatchers.Main) { - addTracksToMediaController( - songs = songs, - insertionMode = insertionMode, - autoPlay = autoPlay, - shuffle = shuffle, - playlistName = null, - fragment = fragment - ) - - // Play Now doesn't get a Toast :) - successString = when (insertionMode) { - MediaPlayerManager.InsertionMode.AFTER_CURRENT -> - fragment.resources.getQuantityString( - R.plurals.n_songs_added_after_current, - songs.size, - songs.size - ) - MediaPlayerManager.InsertionMode.APPEND -> - fragment.resources.getQuantityString( - R.plurals.n_songs_added_to_end, - songs.size, - songs.size - ) - else -> null - } - } - }) { successString } - } - - fun addTracksToMediaController( - songs: List, - insertionMode: MediaPlayerManager.InsertionMode, - autoPlay: Boolean, - shuffle: Boolean = false, - playlistName: String? = null, - fragment: Fragment - ) { - if (songs.isEmpty()) return - - networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - - if (playlistName != null) { - mediaPlayerManager.suggestedPlaylistName = playlistName - } - - mediaPlayerManager.addToPlaylist( - songs, - autoPlay, - shuffle, - insertionMode - ) - - if (Settings.shouldTransitionOnPlayback && - insertionMode == MediaPlayerManager.InsertionMode.CLEAR - ) { - fragment.findNavController().popBackStack(R.id.playerFragment, true) - fragment.findNavController().navigate(R.id.playerFragment) - } - } - - private fun getTracksFromServer( - isArtist: Boolean, - id: String, - isDirectory: Boolean, - name: String?, - isShare: Boolean - ): MutableList { - val musicService = getMusicService() - val songs: MutableList = LinkedList() - val root: MusicDirectory - if (shouldUseId3Tags() && isArtist) { - return getSongsForArtist(id) - } else { - if (isDirectory) { - root = if (shouldUseId3Tags()) - musicService.getAlbumAsDir(id, name, false) - else - musicService.getMusicDirectory(id, name, false) - } else if (isShare) { - root = MusicDirectory() - val shares = musicService.getShares(true) - // Filter the received shares by the given id, and get their entries - val entries = shares.filter { it.id == id }.flatMap { it.getEntries() } - root.addAll(entries) - } else { - root = musicService.getPlaylist(id, name!!) - } - getSongsRecursively(root, songs) - } - return songs - } - - @Suppress("DestructuringDeclarationWithTooManyEntries") - @Throws(Exception::class) - private fun getSongsRecursively( - parent: MusicDirectory, - songs: MutableList - ) { - if (songs.size > maxSongs) { - return - } - for (song in parent.getTracks()) { - if (!song.isVideo) { - songs.add(song) - } - } - val musicService = getMusicService() - for ((id1, _, _, title) in parent.getAlbums()) { - val root: MusicDirectory = if (shouldUseId3Tags()) - musicService.getAlbumAsDir(id1, title, false) - else - musicService.getMusicDirectory(id1, title, false) - getSongsRecursively(root, songs) - } - } - - @Throws(Exception::class) - private fun getSongsForArtist( - id: String - ): MutableList { - val songs: MutableList = LinkedList() - val musicService = getMusicService() - val artist = musicService.getAlbumsOfArtist(id, "", false) - for ((id1) in artist) { - val albumDirectory = musicService.getAlbumAsDir( - id1, - "", - false - ) - for (song in albumDirectory.getTracks()) { - if (!song.isVideo) { - songs.add(song) - } - } - } - return songs - } -} - -enum class DownloadAction { - DOWNLOAD, PIN, UNPIN, DELETE -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt index 95a887a9..b70852f1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt @@ -1,6 +1,5 @@ package org.moire.ultrasonic.subsonic -import android.content.Context import androidx.core.content.res.ResourcesCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -13,6 +12,7 @@ import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.imageloader.ImageLoader import org.moire.ultrasonic.imageloader.ImageLoaderConfig +import org.moire.ultrasonic.util.CoroutinePatterns import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.Util import timber.log.Timber @@ -20,8 +20,7 @@ import timber.log.Timber /** * Handles the lifetime of the Image Loader */ -class -ImageLoaderProvider(val context: Context) : +class ImageLoaderProvider : KoinComponent, CoroutineScope by CoroutineScope(Dispatchers.IO) { private var imageLoader: ImageLoader? = null @@ -57,7 +56,7 @@ ImageLoaderProvider(val context: Context) : } fun executeOn(cb: (iL: ImageLoader) -> Unit) { - launch { + launch(CoroutinePatterns.loggingExceptionHandler) { val iL = getImageLoader() withContext(Dispatchers.Main) { cb(iL) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/NetworkAndStorageChecker.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/NetworkAndStorageChecker.kt index 22e73d33..016f4921 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/NetworkAndStorageChecker.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/NetworkAndStorageChecker.kt @@ -1,19 +1,19 @@ package org.moire.ultrasonic.subsonic -import android.content.Context import org.moire.ultrasonic.R +import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.util.Util /** * Utility class for checking the availability of the network and storage */ -class NetworkAndStorageChecker(val context: Context) { +class NetworkAndStorageChecker { fun warnIfNetworkOrStorageUnavailable() { if (!Util.isExternalStoragePresent()) { - Util.toast(context, R.string.select_album_no_sdcard) + Util.toast(R.string.select_album_no_sdcard, true, UApp.applicationContext()) } else if (!isOffline() && !Util.hasUsableNetwork()) { - Util.toast(context, R.string.select_album_no_network) + Util.toast(R.string.select_album_no_network, true, UApp.applicationContext()) } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt index c9855d89..ce2f0eaa 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt @@ -8,7 +8,6 @@ package org.moire.ultrasonic.subsonic import android.annotation.SuppressLint -import android.content.Context import android.content.Intent import android.view.LayoutInflater import android.view.View @@ -17,27 +16,27 @@ import android.widget.EditText import android.widget.TextView import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.lifecycle.lifecycleScope import java.util.Locale import java.util.regex.Pattern -import kotlin.collections.ArrayList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.moire.ultrasonic.R import org.moire.ultrasonic.domain.Share import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.BackgroundTask -import org.moire.ultrasonic.util.CancellationToken import org.moire.ultrasonic.util.ConfirmationDialog -import org.moire.ultrasonic.util.FragmentBackgroundTask import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.ShareDetails import org.moire.ultrasonic.util.TimeSpanPicker +import org.moire.ultrasonic.util.Util.getString import org.moire.ultrasonic.util.Util.ifNotNull /** * This class handles sharing items in the media library */ -class ShareHandler(val context: Context) { +class ShareHandler { private var shareDescription: EditText? = null private var timeSpanPicker: TimeSpanPicker? = null private var shareOnServerCheckBox: CheckBox? = null @@ -51,27 +50,32 @@ class ShareHandler(val context: Context) { fun share( fragment: Fragment, shareDetails: ShareDetails, - swipe: SwipeRefreshLayout?, - cancellationToken: CancellationToken, additionalId: String? ) { - val task: BackgroundTask = object : FragmentBackgroundTask( - fragment.requireActivity(), - true, - swipe, - cancellationToken - ) { - @Throws(Throwable::class) - override fun doInBackground(): Share? { + val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope + scope.launch { + val share = createShareOnServer(shareDetails, additionalId) + startActivityForShare(share, shareDetails, fragment) + } + } + + private suspend fun createShareOnServer( + shareDetails: ShareDetails, + additionalId: String? + ): Share? { + return withContext(Dispatchers.IO) { + return@withContext try { + val ids: MutableList = ArrayList() - if (!shareDetails.ShareOnServer && shareDetails.Entries.size == 1) return null - if (shareDetails.Entries.isEmpty()) { + if (!shareDetails.shareOnServer && shareDetails.entries.size == 1) + return@withContext null + if (shareDetails.entries.isEmpty()) { additionalId.ifNotNull { ids.add(it) } } else { - for ((id) in shareDetails.Entries) { + for ((id) in shareDetails.entries) { ids.add(id) } } @@ -79,85 +83,93 @@ class ShareHandler(val context: Context) { val musicService = getMusicService() var timeInMillis: Long = 0 - if (shareDetails.Expiration != 0L) { - timeInMillis = shareDetails.Expiration + if (shareDetails.expiration != 0L) { + timeInMillis = shareDetails.expiration } - val shares = - musicService.createShare(ids, shareDetails.Description, timeInMillis) - - return shares[0] - } - - override fun done(result: Share?) { - - val intent = Intent(Intent.ACTION_SEND) - intent.type = "text/plain" - - if (result != null) { - // Created a share, send the URL - intent.putExtra( - Intent.EXTRA_TEXT, - String.format( - Locale.ROOT, "%s\n\n%s", Settings.shareGreeting, result.url - ) - ) - } else { - // Sending only text details - val textBuilder = StringBuilder() - textBuilder.appendLine(Settings.shareGreeting) - - if (!shareDetails.Entries[0].title.isNullOrEmpty()) - textBuilder.append(context.resources.getString(R.string.common_title)) - .append(": ").appendLine(shareDetails.Entries[0].title) - if (!shareDetails.Entries[0].artist.isNullOrEmpty()) - textBuilder.append(context.resources.getString(R.string.common_artist)) - .append(": ").appendLine(shareDetails.Entries[0].artist) - if (!shareDetails.Entries[0].album.isNullOrEmpty()) - textBuilder.append(context.resources.getString(R.string.common_album)) - .append(": ").append(shareDetails.Entries[0].album) - - intent.putExtra(Intent.EXTRA_TEXT, textBuilder.toString()) - } - - fragment.activity?.startActivity( - Intent.createChooser( - intent, - context.resources.getString(R.string.share_via) - ) + val shares = musicService.createShare( + ids = ids, + description = shareDetails.description, + expires = timeInMillis ) + + // Return the share + if (shares.isNotEmpty()) + shares[0] + else + null + } catch (ignored: Exception) { + null } } - task.execute() + } + + private suspend fun startActivityForShare( + result: Share?, + shareDetails: ShareDetails, + fragment: Fragment + ) { + return withContext(Dispatchers.Main) { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "text/plain" + + if (result != null) { + // Created a share, send the URL + intent.putExtra( + Intent.EXTRA_TEXT, + String.format( + Locale.ROOT, "%s\n\n%s", Settings.shareGreeting, result.url + ) + ) + } else { + // Sending only text details + val textBuilder = StringBuilder() + textBuilder.appendLine(Settings.shareGreeting) + + if (!shareDetails.entries[0].title.isNullOrEmpty()) + textBuilder.append(getString(R.string.common_title)) + .append(": ").appendLine(shareDetails.entries[0].title) + if (!shareDetails.entries[0].artist.isNullOrEmpty()) + textBuilder.append(getString(R.string.common_artist)) + .append(": ").appendLine(shareDetails.entries[0].artist) + if (!shareDetails.entries[0].album.isNullOrEmpty()) + textBuilder.append(getString(R.string.common_album)) + .append(": ").append(shareDetails.entries[0].album) + + intent.putExtra(Intent.EXTRA_TEXT, textBuilder.toString()) + } + + fragment.activity?.startActivity( + Intent.createChooser( + intent, + getString(R.string.share_via) + ) + ) + } } fun createShare( fragment: Fragment, - tracks: List?, - swipe: SwipeRefreshLayout?, - cancellationToken: CancellationToken, + tracks: List, additionalId: String? = null ) { + if (tracks.isEmpty()) return val askForDetails = Settings.shouldAskForShareDetails - val shareDetails = ShareDetails() - shareDetails.Entries = tracks + val shareDetails = ShareDetails(tracks) if (askForDetails) { - showDialog(fragment, shareDetails, swipe, cancellationToken, additionalId) + showDialog(fragment, shareDetails, additionalId) } else { - shareDetails.Description = Settings.defaultShareDescription - shareDetails.Expiration = System.currentTimeMillis() + + shareDetails.description = Settings.defaultShareDescription + shareDetails.expiration = System.currentTimeMillis() + Settings.defaultShareExpirationInMillis - share(fragment, shareDetails, swipe, cancellationToken, additionalId) + share(fragment, shareDetails, additionalId) } } - @Suppress("LongMethod") @SuppressLint("InflateParams") private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, - swipe: SwipeRefreshLayout?, - cancellationToken: CancellationToken, additionalId: String? ) { val layout = LayoutInflater.from(fragment.context).inflate(R.layout.share_details, null) @@ -171,67 +183,35 @@ class ShareHandler(val context: Context) { noExpirationCheckBox = timeSpanPicker!!.findViewById( R.id.timeSpanDisableCheckBox ) as CheckBox - textViewComment = layout.findViewById(R.id.textViewComment) as TextView - textViewExpiration = layout.findViewById(R.id.textViewExpiration) as TextView + textViewComment = layout.findViewById(R.id.commentHeading) as TextView + textViewExpiration = layout.findViewById(R.id.expirationHeading) as TextView } - if (shareDetails.Entries.size == 1) { - // For single songs the sharing may be done by text only + // Handle the visibility based on shareDetails.Entries size + if (shareDetails.entries.size == 1) { shareOnServerCheckBox?.setOnCheckedChangeListener { _, _ -> updateVisibility() } - shareOnServerCheckBox?.isChecked = Settings.shareOnServer } else { shareOnServerCheckBox?.isVisible = false } + updateVisibility() - val builder = ConfirmationDialog.Builder(fragment.requireContext()) - builder.setTitle(R.string.share_set_share_options) + // Set up the dialog builder + val builder = makeDialogBuilder(fragment, shareDetails, additionalId, layout) - builder.setPositiveButton(R.string.menu_share) { _, _ -> - if (!noExpirationCheckBox!!.isChecked) { - val timeSpan: Long = timeSpanPicker!!.getTimeSpan() - shareDetails.Expiration = System.currentTimeMillis() + timeSpan - } + // Initialize UI components with default values + setupDefaultValues() - shareDetails.Description = shareDescription!!.text.toString() - shareDetails.ShareOnServer = shareOnServerCheckBox!!.isChecked - - if (hideDialogCheckBox!!.isChecked) { - Settings.shouldAskForShareDetails = false - } - - if (saveAsDefaultsCheckBox!!.isChecked) { - val timeSpanType: String = timeSpanPicker!!.timeSpanType!! - val timeSpanAmount: Int = timeSpanPicker!!.timeSpanAmount - Settings.defaultShareExpiration = - if (!noExpirationCheckBox!!.isChecked && timeSpanAmount > 0) - String.format("%d:%s", timeSpanAmount, timeSpanType) else "" - - Settings.defaultShareDescription = shareDetails.Description - Settings.shareOnServer = shareDetails.ShareOnServer - } - - share(fragment, shareDetails, swipe, cancellationToken, additionalId) - } - - builder.setNegativeButton(R.string.common_cancel) { dialog, _ -> - dialog.cancel() - } - - builder.setView(layout) - builder.setCancelable(true) - - timeSpanPicker!!.setTimeSpanDisableText(context.resources.getString(R.string.no_expiration)) - noExpirationCheckBox!!.setOnCheckedChangeListener { _, b -> - timeSpanPicker!!.isEnabled = !b - } + builder.create() + builder.show() + } + private fun setupDefaultValues() { val defaultDescription = Settings.defaultShareDescription val timeSpan = Settings.defaultShareExpiration - val split = pattern.split(timeSpan) if (split.size == 2) { val timeSpanAmount = split[0].toInt() @@ -249,10 +229,58 @@ class ShareHandler(val context: Context) { noExpirationCheckBox!!.isChecked = true timeSpanPicker!!.isEnabled = false } - shareDescription!!.setText(defaultDescription) - builder.create() - builder.show() + } + + private fun makeDialogBuilder( + fragment: Fragment, + shareDetails: ShareDetails, + additionalId: String?, + layout: View? + ): ConfirmationDialog.Builder { + val builder = ConfirmationDialog.Builder(fragment.requireContext()) + builder.setTitle(R.string.share_set_share_options) + + builder.setPositiveButton(R.string.menu_share) { _, _ -> + if (!noExpirationCheckBox!!.isChecked) { + val timeSpan: Long = timeSpanPicker!!.getTimeSpan() + shareDetails.expiration = System.currentTimeMillis() + timeSpan + } + + shareDetails.description = shareDescription!!.text.toString() + shareDetails.shareOnServer = shareOnServerCheckBox!!.isChecked + + if (hideDialogCheckBox!!.isChecked) { + Settings.shouldAskForShareDetails = false + } + + if (saveAsDefaultsCheckBox!!.isChecked) { + val timeSpanType: String = timeSpanPicker!!.timeSpanType!! + val timeSpanAmount: Int = timeSpanPicker!!.timeSpanAmount + Settings.defaultShareExpiration = + if (!noExpirationCheckBox!!.isChecked && timeSpanAmount > 0) + String.format("%d:%s", timeSpanAmount, timeSpanType) else "" + + Settings.defaultShareDescription = shareDetails.description!! + Settings.shareOnServer = shareDetails.shareOnServer + } + + share(fragment, shareDetails, additionalId) + } + + builder.setNegativeButton(R.string.common_cancel) { dialog, _ -> + dialog.cancel() + } + + builder.setView(layout) + builder.setCancelable(true) + + // Set up the timeSpanPicker + timeSpanPicker!!.setTimeSpanDisableText(getString(R.string.no_expiration)) + noExpirationCheckBox!!.setOnCheckedChangeListener { _, b -> + timeSpanPicker!!.isEnabled = !b + } + return builder } private fun updateVisibility() { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/VideoPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/VideoPlayer.kt index a91fcea9..fa97f749 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/VideoPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/VideoPlayer.kt @@ -16,7 +16,7 @@ class VideoPlayer { companion object { fun playVideo(context: Context, track: Track?) { if (!Util.hasUsableNetwork() || track == null) { - Util.toast(context, R.string.select_album_no_network) + Util.toast(R.string.select_album_no_network, true, context) return } try { @@ -32,7 +32,7 @@ class VideoPlayer { ) context.startActivity(intent) } catch (all: Exception) { - Util.toast(context, all.toString(), false) + Util.toast(all.toString(), false, context) } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt index 83939ab8..6f460fe3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -11,7 +11,6 @@ import android.system.Os import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -19,7 +18,7 @@ import org.koin.java.KoinJavaComponent.inject import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Playlist import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.MediaPlayerManager +import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile import org.moire.ultrasonic.util.FileUtil.getCompleteFile import org.moire.ultrasonic.util.FileUtil.getPartialFile @@ -38,7 +37,6 @@ import timber.log.Timber */ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinComponent { - private var mainScope = CoroutineScope(Dispatchers.Main) private val activeServerProvider by inject() private fun exceptionHandler(tag: String): CoroutineExceptionHandler { @@ -235,16 +233,14 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo private fun findFilesToNotDelete(): Set { val filesToNotDelete: MutableSet = HashSet(5) - val mediaPlayerManager: MediaPlayerManager by inject() - val playlist = mainScope.future { mediaPlayerManager.playlist }.get() - for (item in playlist) { - val track = item.toTrack() + // We just take the last published playlist from RX + val playlist = RxBus.playlistObservable.blockingLast() + for (track in playlist) { filesToNotDelete.add(track.getPartialFile()) filesToNotDelete.add(track.getCompleteFile()) filesToNotDelete.add(track.getPinnedFile()) } - filesToNotDelete.add(musicDirectory.path) return filesToNotDelete } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CancellationToken.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CancellationToken.kt index 0318f70c..6dbb142d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CancellationToken.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CancellationToken.kt @@ -8,7 +8,9 @@ package org.moire.ultrasonic.util /** * This class contains a very simple implementation of a CancellationToken - */ + * TODO: Remove this class and refactor all user to coroutines + **/ + class CancellationToken { var isCancellationRequested: Boolean = false diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt index fceee797..051ca4f4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt @@ -37,4 +37,5 @@ object Constants { const val FILENAME_PLAYLIST_SER = "downloadstate.ser" const val ALBUM_ART_FILE = "folder.jpeg" const val RESULT_CLOSE_ALL = 1337 + const val MAX_SONGS_RECURSIVE = 500 } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ContextMenuUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ContextMenuUtil.kt new file mode 100644 index 00000000..420b4c90 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ContextMenuUtil.kt @@ -0,0 +1,140 @@ +/* + * ContextMenuUtil.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.util + +import android.view.MenuItem +import androidx.fragment.app.Fragment +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.domain.Identifiable +import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.service.MediaPlayerManager +import org.moire.ultrasonic.subsonic.ShareHandler + +object ContextMenuUtil : KoinComponent { + + /* + * Callback for menu items of collections (albums, artists etc) + */ + fun handleContextMenu( + menuItem: MenuItem, + item: Identifiable, + isArtist: Boolean, + mediaPlayerManager: MediaPlayerManager, + fragment: Fragment + ): Boolean { + when (menuItem.itemId) { + R.id.menu_play_now -> + mediaPlayerManager.playTracksAndToast( + fragment = fragment, + insertionMode = MediaPlayerManager.InsertionMode.CLEAR, + id = item.id, + isArtist = isArtist + ) + R.id.menu_play_next -> + mediaPlayerManager.playTracksAndToast( + fragment = fragment, + insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT, + id = item.id, + isArtist = isArtist + ) + R.id.menu_play_last -> + mediaPlayerManager.playTracksAndToast( + fragment = fragment, + insertionMode = MediaPlayerManager.InsertionMode.APPEND, + id = item.id, + isArtist = isArtist + ) + R.id.menu_pin -> + DownloadUtil.justDownload( + action = DownloadAction.PIN, + fragment = fragment, + id = item.id, + isArtist = isArtist + ) + R.id.menu_unpin -> + DownloadUtil.justDownload( + action = DownloadAction.UNPIN, + fragment = fragment, + id = item.id, + isArtist = isArtist + ) + R.id.menu_download -> + DownloadUtil.justDownload( + action = DownloadAction.DOWNLOAD, + fragment = fragment, + id = item.id, + isArtist = isArtist + ) + else -> return false + } + return true + } + + fun handleContextMenuTracks( + menuItem: MenuItem, + tracks: List, + mediaPlayerManager: MediaPlayerManager, + fragment: Fragment + ): Boolean { + when (menuItem.itemId) { + R.id.song_menu_play_now -> { + mediaPlayerManager.playTracksAndToast( + fragment = fragment, + insertionMode = MediaPlayerManager.InsertionMode.CLEAR, + tracks = tracks + ) + } + R.id.song_menu_play_next -> { + mediaPlayerManager.playTracksAndToast( + fragment = fragment, + insertionMode = MediaPlayerManager.InsertionMode.AFTER_CURRENT, + tracks = tracks + ) + } + R.id.song_menu_play_last -> { + mediaPlayerManager.playTracksAndToast( + fragment = fragment, + insertionMode = MediaPlayerManager.InsertionMode.APPEND, + tracks = tracks + ) + } + R.id.song_menu_pin -> { + DownloadUtil.justDownload( + action = DownloadAction.PIN, + fragment = fragment, + tracks = tracks + ) + } + R.id.song_menu_unpin -> { + DownloadUtil.justDownload( + action = DownloadAction.UNPIN, + fragment = fragment, + tracks = tracks + ) + } + R.id.song_menu_download -> { + DownloadUtil.justDownload( + action = DownloadAction.DOWNLOAD, + fragment = fragment, + tracks = tracks + ) + } + R.id.song_menu_share -> { + val shareHandler: ShareHandler by inject() + shareHandler.createShare( + fragment = fragment, + tracks = tracks + ) + } + else -> return false + } + return true + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt index 8e0d1154..dad0ae39 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt @@ -10,14 +10,15 @@ package org.moire.ultrasonic.util import android.os.Handler import android.os.Looper import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.launch -import org.moire.ultrasonic.R -import org.moire.ultrasonic.app.UApp +import org.moire.ultrasonic.util.CommunicationError.getErrorMessage +import org.moire.ultrasonic.util.Util.toast import timber.log.Timber object CoroutinePatterns { @@ -30,53 +31,69 @@ object CoroutinePatterns { } } -fun CoroutineScope.executeTaskWithToast( - task: suspend CoroutineScope.() -> Unit, - successString: () -> String? -): Job { +fun Fragment.toastingExceptionHandler( + prefix: String = "" +): CoroutineExceptionHandler { + return CoroutineExceptionHandler { _, exception -> + // Stop the spinner if applicable + if (this is RefreshableFragment) { + this.swipeRefresh?.isRefreshing = false + } + toast("$prefix ${getErrorMessage(exception)}", shortDuration = false) + } +} + +/* +* Launch a coroutine with a toast +* This extension can be only started from a fragment +* because it needs the fragments scope to create the toast + */ +fun Fragment.launchWithToast( + block: suspend CoroutineScope.() -> String? +) { + // Get the scope + val scope = activity?.lifecycleScope ?: lifecycleScope + // Launch the Job - val job = launch(CoroutinePatterns.loggingExceptionHandler, block = task) + val deferred = scope.async(block = block) // Setup a handler when the job is done - job.invokeOnCompletion { + deferred.invokeOnCompletion { val toastString = if (it != null && it !is CancellationException) { - CommunicationError.getErrorMessage(it) + getErrorMessage(it) } else { - successString() + null } - - // Return early if nothing to post - if (toastString == null) return@invokeOnCompletion - - launch(Dispatchers.Main) { - Util.toast(UApp.applicationContext(), toastString) - } - } - - return job -} - -fun CoroutineScope.executeTaskWithModalDialog( - fragment: Fragment, - task: suspend CoroutineScope.() -> Unit, - successString: () -> String -) { - // Create the job - val job = executeTaskWithToast(task, successString) - - // Create the dialog - val builder = InfoDialog.Builder(fragment.requireContext()) - builder.setTitle(R.string.background_task_wait) - builder.setMessage(R.string.background_task_loading) - builder.setOnCancelListener { job.cancel() } - builder.setPositiveButton(R.string.common_cancel) { _, _ -> job.cancel() } - val dialog = builder.create() - dialog.show() - - // Add additional handler to close the dialog - job.invokeOnCompletion { - launch(Dispatchers.Main) { - dialog.dismiss() + scope.launch(Dispatchers.Main) { + val successString = toastString ?: deferred.await() + if (successString != null) { + this@launchWithToast.toast(successString) + } } } } + +// Unused, kept commented for eventual later use +// fun CoroutineScope.executeTaskWithModalDialog( +// fragment: Fragment, +// task: suspend CoroutineScope.() -> String? +// ) { +// // Create the job +// val job = launchWithToast(task) +// +// // Create the dialog +// val builder = InfoDialog.Builder(fragment.requireContext()) +// builder.setTitle(R.string.background_task_wait) +// builder.setMessage(R.string.background_task_loading) +// builder.setOnCancelListener { job.cancel() } +// builder.setPositiveButton(R.string.common_cancel) { _, _ -> job.cancel() } +// val dialog = builder.create() +// dialog.show() +// +// // Add additional handler to close the dialog +// job.invokeOnCompletion { +// launch(Dispatchers.Main) { +// dialog.dismiss() +// } +// } +// } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/DownloadUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/DownloadUtil.kt new file mode 100644 index 00000000..00ce9acd --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/DownloadUtil.kt @@ -0,0 +1,195 @@ +/* + * DownloadUtil.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.util + +import androidx.fragment.app.Fragment +import java.util.LinkedList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.moire.ultrasonic.R +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.service.DownloadService +import org.moire.ultrasonic.service.MusicServiceFactory + +/** + * Retrieves a list of songs and adds them to the now playing list + */ +@Suppress("LongParameterList") +object DownloadUtil { + + fun justDownload( + action: DownloadAction, + fragment: Fragment, + id: String? = null, + name: String? = "", + isShare: Boolean = false, + isDirectory: Boolean = true, + isArtist: Boolean = false, + tracks: List? = null + ) { + + // Launch the Job + fragment.launchWithToast { + val tracksToDownload: List = tracks + ?: getTracksFromServerAsync(isArtist, id!!, isDirectory, name, isShare) + + // If we are just downloading tracks we don't need to add them to the controller + when (action) { + DownloadAction.DOWNLOAD -> DownloadService.downloadAsync( + tracksToDownload, + save = false, + updateSaveFlag = true + ) + DownloadAction.PIN -> DownloadService.downloadAsync( + tracksToDownload, + save = true, + updateSaveFlag = true + ) + DownloadAction.UNPIN -> DownloadService.unpinAsync(tracksToDownload) + DownloadAction.DELETE -> DownloadService.deleteAsync(tracksToDownload) + } + + // Return the string which should be displayed + getToastString(action, fragment, tracksToDownload) + } + } + + suspend fun getTracksFromServerAsync( + isArtist: Boolean, + id: String, + isDirectory: Boolean, + name: String?, + isShare: Boolean + ): MutableList { + return withContext(Dispatchers.IO) { + getTracksFromServer(isArtist, id, isDirectory, name, isShare) + } + } + + fun getTracksFromServer( + isArtist: Boolean, + id: String, + isDirectory: Boolean, + name: String?, + isShare: Boolean + ): MutableList { + val musicService = MusicServiceFactory.getMusicService() + val songs: MutableList = LinkedList() + val root: MusicDirectory + if (ActiveServerProvider.shouldUseId3Tags() && isArtist) { + return getSongsForArtist(id) + } else { + if (isDirectory) { + root = if (ActiveServerProvider.shouldUseId3Tags()) + musicService.getAlbumAsDir(id, name, false) + else + musicService.getMusicDirectory(id, name, false) + } else if (isShare) { + root = MusicDirectory() + val shares = musicService.getShares(true) + // Filter the received shares by the given id, and get their entries + val entries = shares.filter { it.id == id }.flatMap { it.getEntries() } + root.addAll(entries) + } else { + root = musicService.getPlaylist(id, name!!) + } + getSongsRecursively(root, songs) + } + return songs + } + + @Suppress("DestructuringDeclarationWithTooManyEntries") + @Throws(Exception::class) + private fun getSongsRecursively( + parent: MusicDirectory, + songs: MutableList + ) { + if (songs.size > Constants.MAX_SONGS_RECURSIVE) { + return + } + for (song in parent.getTracks()) { + if (!song.isVideo) { + songs.add(song) + } + } + val musicService = MusicServiceFactory.getMusicService() + for ((id1, _, _, title) in parent.getAlbums()) { + val root: MusicDirectory = if (ActiveServerProvider.shouldUseId3Tags()) + musicService.getAlbumAsDir(id1, title, false) + else + musicService.getMusicDirectory(id1, title, false) + getSongsRecursively(root, songs) + } + } + + @Throws(Exception::class) + private fun getSongsForArtist( + id: String + ): MutableList { + val songs: MutableList = LinkedList() + val musicService = MusicServiceFactory.getMusicService() + val artist = musicService.getAlbumsOfArtist(id, "", false) + for ((id1) in artist) { + val albumDirectory = musicService.getAlbumAsDir( + id1, + "", + false + ) + for (song in albumDirectory.getTracks()) { + if (!song.isVideo) { + songs.add(song) + } + } + } + return songs + } + + private fun getToastString( + action: DownloadAction, + fragment: Fragment, + tracksToDownload: List + ): String { + return when (action) { + DownloadAction.DOWNLOAD -> fragment.resources.getQuantityString( + R.plurals.n_songs_to_be_downloaded, + tracksToDownload.size, + tracksToDownload.size + ) + + DownloadAction.UNPIN -> { + fragment.resources.getQuantityString( + R.plurals.n_songs_unpinned, + tracksToDownload.size, + tracksToDownload.size + ) + } + + DownloadAction.PIN -> { + fragment.resources.getQuantityString( + R.plurals.n_songs_pinned, + tracksToDownload.size, + tracksToDownload.size + ) + } + + DownloadAction.DELETE -> { + fragment.resources.getQuantityString( + R.plurals.n_songs_deleted, + tracksToDownload.size, + tracksToDownload.size + ) + } + } + } +} + +enum class DownloadAction { + DOWNLOAD, PIN, UNPIN, DELETE +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/RefreshableFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/RefreshableFragment.kt new file mode 100644 index 00000000..749c4d61 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/RefreshableFragment.kt @@ -0,0 +1,14 @@ +/* + * RefreshableFragment.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.util + +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout + +interface RefreshableFragment { + var swipeRefresh: SwipeRefreshLayout? +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index 1919b386..67950e96 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -136,10 +136,6 @@ object Settings { val seekIntervalMillis: Long get() = (seekInterval / 1000).toLong() - @JvmStatic - var mediaButtonsEnabled - by BooleanSetting(getKey(R.string.setting_key_media_buttons), true) - var resumePlayOnHeadphonePlug by BooleanSetting(R.string.setting_key_resume_play_on_headphones_plug, true) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ShareDetails.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ShareDetails.kt new file mode 100644 index 00000000..bf0ba606 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ShareDetails.kt @@ -0,0 +1,12 @@ +package org.moire.ultrasonic.util + +import org.moire.ultrasonic.domain.Track + +/** + * Created by Josh on 12/17/13. + */ +data class ShareDetails(val entries: List) { + var description: String? = null + var shareOnServer = false + var expiration: Long = 0 +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt index 933730f4..a777605d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt @@ -41,7 +41,7 @@ object Storage { if (rootNotFoundError) { Settings.customCacheLocation = false Settings.cacheLocationUri = "" - Util.toast(UApp.applicationContext(), R.string.settings_cache_location_error) + Util.toast(R.string.settings_cache_location_error, true, UApp.applicationContext()) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPicker.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPicker.kt index 63566313..33b66540 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPicker.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPicker.kt @@ -49,7 +49,7 @@ class TimeSpanPicker(private var mContext: Context, attrs: AttributeSet?, defSty dialog = inflater.inflate(R.layout.time_span_dialog, this, true) timeSpanEditText = dialog.findViewById(R.id.timeSpanEditText) as EditText timeSpanEditText.setText("0") - timeSpanSpinner = dialog.findViewById(R.id.timeSpanSpinner) as Spinner + timeSpanSpinner = dialog.findViewById(R.id.timeSpanUnitSelector) as Spinner timeSpanDisableCheckbox = dialog.findViewById(R.id.timeSpanDisableCheckBox) as CheckBox timeSpanDisableCheckbox.setOnCheckedChangeListener { _, b -> @@ -128,7 +128,7 @@ class TimeSpanPicker(private var mContext: Context, attrs: AttributeSet?, defSty companion object { fun getTimeSpanFromDialog(context: Context, dialog: View): Long { val timeSpanEditText = dialog.findViewById(R.id.timeSpanEditText) as EditText - val timeSpanSpinner = dialog.findViewById(R.id.timeSpanSpinner) as Spinner + val timeSpanSpinner = dialog.findViewById(R.id.timeSpanUnitSelector) as Spinner val timeSpanType = timeSpanSpinner.selectedItem as String Timber.i("SELECTED ITEM: %d", timeSpanSpinner.selectedItemId) val text = timeSpanEditText.text diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPreference.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPreference.kt new file mode 100644 index 00000000..cbb55ddd --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPreference.kt @@ -0,0 +1,27 @@ +package org.moire.ultrasonic.util + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.DialogPreference +import org.moire.ultrasonic.R + +/** + * Created by Joshua Bahnsen on 12/22/13. + */ +class TimeSpanPreference(mContext: Context, attrs: AttributeSet?) : DialogPreference( + mContext, attrs +) { + init { + setPositiveButtonText(android.R.string.ok) + setNegativeButtonText(android.R.string.cancel) + dialogIcon = null + } + + val text: String + get() { + val persisted = getPersistedString("") + return if ("" != persisted) { + persisted.replace(':', ' ') + } else context.resources.getString(R.string.time_span_disabled) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index 8f61c97b..84896771 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -8,7 +8,6 @@ package org.moire.ultrasonic.util import android.Manifest.permission.POST_NOTIFICATIONS -import android.annotation.SuppressLint import android.app.Activity import android.app.Notification import android.app.NotificationChannel @@ -32,7 +31,6 @@ import android.os.Environment import android.text.TextUtils import android.util.DisplayMetrics import android.util.TypedValue -import android.view.Gravity import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.ComponentActivity @@ -40,12 +38,15 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.AnyRes import androidx.annotation.AttrRes import androidx.annotation.ColorInt +import androidx.annotation.StringRes import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline +import androidx.navigation.fragment.findNavController import java.io.Closeable import java.io.UnsupportedEncodingException import java.security.MessageDigest @@ -88,7 +89,6 @@ object Util { // Used by hexEncode() private val HEX_DIGITS = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') - private var toast: Toast? = null // Retrieves an instance of the application Context fun appContext(): Context { @@ -110,52 +110,68 @@ object Util { context.getString(R.string.setting_key_theme_dark) -> { R.style.UltrasonicTheme_Dark } + context.getString(R.string.setting_key_theme_black) -> { R.style.UltrasonicTheme_Black } + context.getString(R.string.setting_key_theme_light) -> { R.style.UltrasonicTheme_Light } + else -> { R.style.UltrasonicTheme_DayNight } } } + fun getString(@StringRes resId: Int): String { + return applicationContext().resources.getString(resId) + } + @JvmStatic @JvmOverloads - fun toast(context: Context?, messageId: Int, shortDuration: Boolean = true) { - toast(context, context!!.getString(messageId), shortDuration) + fun toast(messageId: Int, shortDuration: Boolean = true, context: Context?) { + toast(applicationContext().getString(messageId), shortDuration, context) } @JvmStatic - fun toast(context: Context?, message: CharSequence?) { - toast(context, message, true) + fun toast(message: CharSequence, context: Context?) { + toast(message, true, context) } @JvmStatic - @SuppressLint("ShowToast") // Invalid warning - fun toast(context: Context?, message: CharSequence?, shortDuration: Boolean) { - // If called after doing some background processing, our context might have expired! + // Toast needs a real context or it will throw a IllegalAccessException + // We wrap it in a try-catch block, because if called after doing + // some background processing, our context might have expired! + fun toast(message: CharSequence, shortDuration: Boolean, context: Context?) { try { - if (toast == null) { - toast = Toast.makeText( - context, - message, - if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG - ) - toast!!.setGravity(Gravity.CENTER, 0, 0) - } else { - toast!!.setText(message) - toast!!.duration = - if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG - } - toast!!.show() + Toast.makeText( + context, + message, + if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG + ).show() } catch (all: Exception) { Timber.w(all) } } + fun Fragment.toast(message: CharSequence, shortDuration: Boolean = true) { + toast( + message, + shortDuration = shortDuration, + context = this.context + ) + } + + fun Fragment.toast(messageId: Int = 0, shortDuration: Boolean = true) { + toast( + messageId = messageId, + shortDuration = shortDuration, + context = this.context + ) + } + /** * Converts a byte-count to a formatted string suitable for display to the user. * For instance: @@ -254,15 +270,16 @@ object Util { * @param s The string to encode. * @return The encoded string. */ - @Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught") + @Suppress("TooGenericExceptionThrown") fun utf8HexEncode(s: String?): String? { if (s == null) { return null } val utf8: ByteArray = try { s.toByteArray(charset(Constants.UTF_8)) - } catch (x: UnsupportedEncodingException) { - throw RuntimeException(x) + } catch (all: UnsupportedEncodingException) { + // TODO: Why is it needed to change the exception type here? + throw RuntimeException(all) } return hexEncode(utf8) } @@ -276,7 +293,7 @@ object Util { * @return A string containing hexadecimal characters. */ @Suppress("MagicNumber") - fun hexEncode(data: ByteArray): String { + private fun hexEncode(data: ByteArray): String { val length = data.size val out = CharArray(length shl 1) var j = 0 @@ -296,15 +313,16 @@ object Util { * @return MD5 digest as a hex string. */ @JvmStatic - @Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught") + @Suppress("TooGenericExceptionThrown") fun md5Hex(s: String?): String? { return if (s == null) { null } else try { val md5 = MessageDigest.getInstance("MD5") hexEncode(md5.digest(s.toByteArray(charset(Constants.UTF_8)))) - } catch (x: Exception) { - throw RuntimeException(x.message, x) + } catch (all: Exception) { + // TODO: Why is it needed to change the exception type here? + throw RuntimeException(all.message, all) } } @@ -388,7 +406,12 @@ object Util { fun createWifiLock(tag: String?): WifiLock { val wm = appContext().applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - return wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, tag) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, tag) + } else { + @Suppress("DEPRECATION") + return wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, tag) + } } fun getScaledHeight(height: Double, width: Double, newWidth: Int): Int { @@ -482,7 +505,7 @@ object Util { @JvmStatic fun isNullOrWhiteSpace(string: String?): Boolean { - return string == null || string.isEmpty() || string.trim { it <= ' ' }.isEmpty() + return string.isNullOrEmpty() || string.trim { it <= ' ' }.isEmpty() } @JvmOverloads @@ -507,18 +530,22 @@ object Util { seconds ) } + hours > 0 -> { String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds) } + minutes >= DEGRADE_PRECISION_AFTER -> { String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) } + minutes > 0 -> String.format( Locale.getDefault(), "%d:%02d", minutes, seconds ) + else -> String.format(Locale.getDefault(), "0:%02d", seconds) } } @@ -560,7 +587,7 @@ object Util { val requestPermissionLauncher = fragment.registerForActivityResult(ActivityResultContracts.RequestPermission()) { if (!it) { - toast(applicationContext(), R.string.notification_permission_required) + toast(R.string.notification_permission_required, context = fragment) } } @@ -572,6 +599,7 @@ object Util { } } } + fun postNotificationIfPermitted( notificationManagerCompat: NotificationManagerCompat, id: Int, @@ -586,8 +614,8 @@ object Util { notificationManagerCompat.notify(id, notification) } } + @JvmStatic - @Suppress("DEPRECATION") fun getVersionName(context: Context): String? { var versionName: String? = null val pm = context.packageManager @@ -846,4 +874,11 @@ object Util { fun Context.themeColor(@AttrRes attrRes: Int): Int = TypedValue() .apply { theme.resolveAttribute(attrRes, this, true) } .data + + fun Fragment.navigateToCurrent() { + if (Settings.shouldTransitionOnPlayback) { + findNavController().popBackStack(R.id.playerFragment, true) + findNavController().navigate(R.id.playerFragment) + } + } } diff --git a/ultrasonic/src/main/res/layout/filter_button_bar.xml b/ultrasonic/src/main/res/layout/filter_button_bar.xml index 2a20c8c6..806652df 100644 --- a/ultrasonic/src/main/res/layout/filter_button_bar.xml +++ b/ultrasonic/src/main/res/layout/filter_button_bar.xml @@ -45,7 +45,7 @@ app:layout_constraintHorizontal_chainStyle="spread_inside" app:layout_constraintStart_toEndOf="@+id/chip_view_toggle" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintWidth_default="wrap"> + > \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/share_details.xml b/ultrasonic/src/main/res/layout/share_details.xml index 4e27ed2d..e717152e 100644 --- a/ultrasonic/src/main/res/layout/share_details.xml +++ b/ultrasonic/src/main/res/layout/share_details.xml @@ -1,70 +1,79 @@ - - + a:id="@+id/share_details" + a:layout_width="match_parent" + a:layout_height="wrap_content" + a:padding="8dp"> + a:padding="16dp"> - + a:text="@string/share_on_server" /> + a:theme="@style/Ultrasonic.AllCapsLabel" /> + a:layout_marginBottom="16dip" + a:inputType="text" + a:singleLine="false" /> + + a:theme="@style/Ultrasonic.AllCapsLabel" /> + + + + + a:layout_marginTop="16dip" + a:labelFor="@id/share_description" + a:text="@string/settings.share_options" + a:theme="@style/Ultrasonic.AllCapsLabel" /> - + a:layout_marginTop="0dp" + a:layout_marginBottom="0dp" + a:text="@string/do_not_show_dialog_again" /> - + a:layout_marginTop="0dp" + a:layout_marginBottom="8dp" + a:text="@string/save_as_defaults" /> diff --git a/ultrasonic/src/main/res/layout/time_span_dialog.xml b/ultrasonic/src/main/res/layout/time_span_dialog.xml index 65b95425..69106219 100644 --- a/ultrasonic/src/main/res/layout/time_span_dialog.xml +++ b/ultrasonic/src/main/res/layout/time_span_dialog.xml @@ -3,11 +3,11 @@ + android:orientation="horizontal"> diff --git a/ultrasonic/src/main/res/navigation/navigation_graph.xml b/ultrasonic/src/main/res/navigation/navigation_graph.xml index 906d8a6e..066933fd 100644 --- a/ultrasonic/src/main/res/navigation/navigation_graph.xml +++ b/ultrasonic/src/main/res/navigation/navigation_graph.xml @@ -232,7 +232,7 @@ android:name="org.moire.ultrasonic.fragment.BookmarksFragment" /> + android:name="org.moire.ultrasonic.fragment.legacy.ChatFragment" /> diff --git a/ultrasonic/src/main/res/values-cs/strings.xml b/ultrasonic/src/main/res/values-cs/strings.xml index b363d6aa..4876e878 100644 --- a/ultrasonic/src/main/res/values-cs/strings.xml +++ b/ultrasonic/src/main/res/values-cs/strings.xml @@ -1,7 +1,6 @@ - Načítám… Chyba sítě. Ověřte adresu serveru nebo zkuste později. API serveru v%1$s nepodporuje tuto funkci. Tento program vyžaduje síťové připojení. Zapněte Wi-Fi nebo mobilní připojení. @@ -9,7 +8,6 @@ Nesrozumitelná odpověď. Ověřte adresu serveru. Chyba HTTPS certifikátu: %1$s. Vyjímka SSL připojení. Ověřte certifikát serveru. - Chvilku strpení… Záložky Knihovna médií Chat @@ -188,8 +186,6 @@ Neomezené Max Bitrate - wi-fi Maximum skladeb - Odpovídat na tlačítka ovládání médií telefonu, sluchátek a bluetooth - Tlačítka médií Čas vypršení připojení 105 sekund 120 sekund diff --git a/ultrasonic/src/main/res/values-de/strings.xml b/ultrasonic/src/main/res/values-de/strings.xml index 4b34b015..64cc1219 100644 --- a/ultrasonic/src/main/res/values-de/strings.xml +++ b/ultrasonic/src/main/res/values-de/strings.xml @@ -1,6 +1,5 @@ - Lade… Ein Netzwerkfehler ist aufgetreten. Bitte die Serveradresse prüfen oder später noch einmal versuchen. Server API v%1$s unterstützt diese Funktion nicht. Dieses Programm benötigt eine Netzwerkverbindung. Bitte das WLAN oder Mobilfunk einschalten. @@ -8,7 +7,6 @@ Antwort nicht verstanden. Bitte die Serveradresse überprüfen. HTTPS Zertifikatsfehler: %1$s. SSL Verbindungsfehler. Bitte das Serverzertifikat überprüfen. - Bitte warten… Lesezeichen Medienbibliothek Chat @@ -230,8 +228,6 @@ Unbegrenzt Max. Bitrate - WLAN Max. Anzahl der Titel - Auf Telefon, Headset und Bluetooth-Media-Tasten reagieren - Medien Tasten Netzwerk Zeitüberschreitung 105 Sekunden 120 Sekunden diff --git a/ultrasonic/src/main/res/values-es/strings.xml b/ultrasonic/src/main/res/values-es/strings.xml index 84ac8bd3..d0227a15 100644 --- a/ultrasonic/src/main/res/values-es/strings.xml +++ b/ultrasonic/src/main/res/values-es/strings.xml @@ -1,6 +1,5 @@ - Cargando… Se ha producido un error de red. Por favor comprueba la dirección del servidor o reinténtalo mas tarde. La API del servidor v%1$s no admite esta función. Este programa requiere acceso a la red. Por favor enciende la Wi-Fi o la red móvil. @@ -8,7 +7,6 @@ No se entiende la respuesta. Por favor comprueba la dirección del servidor. Error del certificado HTTPS: %1$s. Excepción de conexión SSL. Compruebe el certificado del servidor. - Por favor espera… Marcadores Biblioteca Chat @@ -232,8 +230,6 @@ Ilimitado Bitrate máximo - Wi-Fi Máximo de Canciones - Responder a los botones multimedia del dispositivo, auriculares y Bluetooth - Botones multimedia Tiempo de espera de la red 105 segundos 120 segundos @@ -457,4 +453,5 @@ %d canciones añadidas a la cola de reproducción %d canciones añadidas a la cola de reproducción + Opciones \ No newline at end of file diff --git a/ultrasonic/src/main/res/values-fr/strings.xml b/ultrasonic/src/main/res/values-fr/strings.xml index d31a03af..836806c4 100644 --- a/ultrasonic/src/main/res/values-fr/strings.xml +++ b/ultrasonic/src/main/res/values-fr/strings.xml @@ -1,6 +1,5 @@ - Chargement… Une erreur réseau est survenue. Veuillez vérifier l\'adresse du serveur ou réessayer plus tard. L’API v%1$s du serveur ne supporte pas cette fonction. Cette application requiert un accès au réseau. Veuillez activer le Wi-Fi ou le réseau mobile. @@ -8,7 +7,6 @@ Réponse incorrecte. Veuillez vérifier l\'adresse du serveur. Erreur de certificat HTTPS : %1$s. Erreur de connexion SSL. Veuillez vérifier le certificat du serveur. - Veuillez patienter… Signets Bibliothèque musicale Salon de discussion @@ -225,8 +223,6 @@ Illimité Débit maximal - Wi-Fi Titres maximum - Répondre au boutons média de l\'appareil, du casque et du Bluetooth - Boutons média Délai d\'attente de connexion 105 secondes 120 secondes diff --git a/ultrasonic/src/main/res/values-gl/strings.xml b/ultrasonic/src/main/res/values-gl/strings.xml index 54b09230..4eee6eae 100644 --- a/ultrasonic/src/main/res/values-gl/strings.xml +++ b/ultrasonic/src/main/res/values-gl/strings.xml @@ -1,6 +1,5 @@ - Cargando… Ocorreu un erro de rede. Por favor comproba a dirección do servidor ou téntao de novo mais tarde. A API do servidor v%1$s non admite esta función. Este programa require acceso á rede. Por favor acende a Wi-Fi ou a rede móbil. @@ -8,7 +7,6 @@ Non se entende a resposta. Por favor comproba a dirección do servidor. Erro do certificado HTTPS: %1$s. Excepción de conexión SSL. Comprobe o certificado do servidor. - Por favor agarde… Biblioteca Chat Reproducindo agora diff --git a/ultrasonic/src/main/res/values-hu/strings.xml b/ultrasonic/src/main/res/values-hu/strings.xml index 5a09da73..2f93d2f4 100644 --- a/ultrasonic/src/main/res/values-hu/strings.xml +++ b/ultrasonic/src/main/res/values-hu/strings.xml @@ -1,7 +1,6 @@ - Betöltés… Hálózati hiba történt! Kérjük, ellenőrizze a kiszolgáló címét vagy próbálja később! A v%1$s verziójú Szerver api nem támogatja ezt a funkciót. Az alkalmazás hálózati hozzáférést igényel. Kérjük, kapcsolja be a Wi-Fi-t vagy a mobilhálózatot! @@ -9,7 +8,6 @@ Értelmezhetetlen válasz! Kérjük, ellenőrizze a kiszolgáló címét! HTTPS tanúsítványhiba: %1$s. SSL kapcsolat kivétel. Kérjük, ellenőrizze a szerver tanúsítványát. - Kérem várjon!… Könyvjelzők Médiakönyvtár Csevegés (Chat) @@ -194,8 +192,6 @@ Korlátlan Max. bitráta - Wi-Fi kapcsolat Dalok max. találati száma - Telefon irányítása a Bluetooth eszköz, vagy a fülhallgató vezérlőgombjaival. - Média vezérlőgombok Hálózati időtúllépés 105 másodperc 120 másodperc diff --git a/ultrasonic/src/main/res/values-it/strings.xml b/ultrasonic/src/main/res/values-it/strings.xml index b0f2faa9..77fed0ee 100644 --- a/ultrasonic/src/main/res/values-it/strings.xml +++ b/ultrasonic/src/main/res/values-it/strings.xml @@ -1,13 +1,11 @@ - Caricamento… Si è verificato un errore di rete. Si prega di controllare l\'indirizzo del server o riprovare più tardi. Questo programma richiede l\'accesso alla rete. Per favore, abilita la connessione Wi-FI o la rete mobile. Risorsa non trovata. Si prega di controllare l\'indirizzo del server. Risposta non comprensibile. Si prega di controllare l\'indirizzo del server. Errore certificato HTTPS. %1$s. Anomalia connessione SSL. Si prega di controllare il certificato del server. - Attendere per favore#8230; Segnalibri Libreria multimediale Chat @@ -184,8 +182,6 @@ Illimitato Bitrate Max - Wi-Fi N° Max Canzoni - Controlli per risposta alle chiamate, per cuffie e Bluetooth - Tasti Media Timeout Rete 105 seconds 120 seconds diff --git a/ultrasonic/src/main/res/values-ja/strings.xml b/ultrasonic/src/main/res/values-ja/strings.xml index 63b80db5..50c975b9 100644 --- a/ultrasonic/src/main/res/values-ja/strings.xml +++ b/ultrasonic/src/main/res/values-ja/strings.xml @@ -1,11 +1,9 @@ - 読み込み中… ネットワークエラーが発生しました。サーバーのアドレスを確認してやり直してください。 応答が確認できません。サーバーのアドレスを確認してください。 HTTPS証明書エラー: %1$s. SSL接続が異常です。サーバーの証明書を確認してください。 - お待ち下さい… ブックマーク メディアライブラリ チャット @@ -181,8 +179,6 @@ 64 Kbps 80 Kbps 96 Kbps - 端末本体、ヘッドセットやBluetoothの再生コントロールボタンに対応します - メディアボタン 15秒 75秒 90秒 diff --git a/ultrasonic/src/main/res/values-nb-rNO/strings.xml b/ultrasonic/src/main/res/values-nb-rNO/strings.xml index 39110c12..f9598905 100644 --- a/ultrasonic/src/main/res/values-nb-rNO/strings.xml +++ b/ultrasonic/src/main/res/values-nb-rNO/strings.xml @@ -37,7 +37,6 @@ Stopp Søk Send en melding - Laster inn … Ultrasonic Mediebibliotek Spill @@ -137,7 +136,6 @@ Fant ikke ressursen. Sjekk tjeneradressen. Forsto ikke svaret. Sjekk tjeneradressen. HTTPS-sertifikatsfeil: %1$s. - Vent … Bokmerker Spilles nå Ingen nettradioopptakskanaler registrert. @@ -219,7 +217,6 @@ Angi en gyldig nettadresse. Maks. artister Ubegrenset - Medieknapper Nettverkstidsavbrudd 90 sekunder 75 sekunder @@ -317,7 +314,6 @@ Klarte ikke å slette %s-spillelisten Bytt til «Spilles nå» etter at avspilling startes i medievisning Trer i effekt neste gang Android skanner enheten din for musikk. - Svarer på enhets-, hodesett, og Blåtannsmedieknapper Kun last ned på ubegrensede tilkoblinger Last ned medier i bakgrunnen …\? Merknader kreves for medieavspilling. Du kan innvilge tilgangen når som helst i Android-innstillingene. diff --git a/ultrasonic/src/main/res/values-nl/strings.xml b/ultrasonic/src/main/res/values-nl/strings.xml index 85a56771..2718115f 100644 --- a/ultrasonic/src/main/res/values-nl/strings.xml +++ b/ultrasonic/src/main/res/values-nl/strings.xml @@ -1,7 +1,6 @@ - Bezig met laden… Er is een netwerkfout opgetreden. Controleer het serveradres of probeer het later opnieuw. De server-api, v %1$s, heeft geen ondersteuning voor deze functie. Deze app vereist netwerktoegang. Schakel wifi of mobiel internet in. @@ -9,7 +8,6 @@ Het antwoord werd niet begrepen. Controleer het serveradres. HTTPS-certificaatfout: %1$s. SSL-verbindingsuitzondering. Controleer het servercertificaat. - Even geduld… Bladwijzers Mediabibliotheek Chat @@ -233,8 +231,6 @@ Ongelimiteerd Max. bitsnelheid via wifi Max. aantal nummers - Reageren op telefoon-, headset- en bluetooth-mediatoetsen. - Mediatoetsen Netwerktime-out 105 seconden 120 seconden diff --git a/ultrasonic/src/main/res/values-pl/strings.xml b/ultrasonic/src/main/res/values-pl/strings.xml index 5a9a33f1..dc151ef5 100644 --- a/ultrasonic/src/main/res/values-pl/strings.xml +++ b/ultrasonic/src/main/res/values-pl/strings.xml @@ -1,6 +1,5 @@ - Ładowanie… Wystąpił błąd sieci. Proszę sprawdzić adres serwera lub spróbować później. API serwera w wersji v%1$s nie wspiera tej funkcjonalności. Ta aplikacja wymaga dostępu do sieci. Proszę włączyć Wi-Fi lub dane komórkowe. @@ -8,7 +7,6 @@ Brak prawidłowej odpowiedzi. Proszę sprawdzić adres serwera. Błąd certyfikatu HTTPS: %1$s. Błąd połączenia SSL. Proszę sprawdzić certyfikat serwera. - Proszę czekać… Zakładki Biblioteka Czat @@ -187,8 +185,6 @@ Bez limitu Maksymalny bitrate dla połączenia Wi-Fi Maksymalna ilość wyników - utwory - Reaguj na przyciski multimedialne telefonu, słuchawek i urządzeń Bluetooth - Przyciski Przekroczenie limitu czasu sieci 105 sekund 120 sekund diff --git a/ultrasonic/src/main/res/values-pt-rBR/strings.xml b/ultrasonic/src/main/res/values-pt-rBR/strings.xml index 4f0e417f..63a5cace 100644 --- a/ultrasonic/src/main/res/values-pt-rBR/strings.xml +++ b/ultrasonic/src/main/res/values-pt-rBR/strings.xml @@ -1,7 +1,6 @@ - Carregando… Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde. O servidor api v%1$s não tem suporte para esta função. Este aplicativo requer acesso à rede. Ligue o Wi-Fi ou a rede de dados. @@ -9,7 +8,6 @@ Não entendi a resposta. Verifique o endereço do servidor. Erro de certificado HTTPS: %1$s. Exceção de conexão SSL. Verifique o certificado do servidor. - Por favor aguarde… Favoritos Biblioteca de Mídia Chat @@ -231,8 +229,6 @@ Ilimitado Taxa Máxima de Bits - Wi-Fi Máximo de Músicas - Obedecer aos botões do celular, fones e botões de mídia do Bluetooth - Botões de Mídia Timeout da Rede 105 segundos 120 segundos diff --git a/ultrasonic/src/main/res/values-pt/strings.xml b/ultrasonic/src/main/res/values-pt/strings.xml index cf4f3106..d7fa3bb4 100644 --- a/ultrasonic/src/main/res/values-pt/strings.xml +++ b/ultrasonic/src/main/res/values-pt/strings.xml @@ -1,6 +1,5 @@ - Carregando… Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde. Server api v%1$s does not support this function. Este aplicativo requer acesso à rede. Ligue o Wi-Fi ou a rede de dados. @@ -8,7 +7,6 @@ Não entendi a resposta. Verifique o endereço do servidor. Erro de certificado HTTPS: %1$s. Exceção de conexão SSL. Verifique o certificado do servidor. - Por favor aguarde… Favoritos Biblioteca de Mídia Chat @@ -187,8 +185,6 @@ Ilimitado Máx. de Taxa de Bits - Wi-Fi Máximo de Músicas - Obedecer aos botões do telemóvel, auricular e botões de mídia do Bluetooth - Botões de Mídia Timeout da Rede 105 segundos 120 segundos diff --git a/ultrasonic/src/main/res/values-ru/strings.xml b/ultrasonic/src/main/res/values-ru/strings.xml index 6d471c7e..1100fabd 100644 --- a/ultrasonic/src/main/res/values-ru/strings.xml +++ b/ultrasonic/src/main/res/values-ru/strings.xml @@ -1,7 +1,6 @@ - Загрузка… Произошла ошибка сети. Пожалуйста, проверьте адрес сервера или повторите попытку позже. Серверный api версии %1$s не поддерживает эту функцию. Эта программа требует доступа к сети. Пожалуйста, включите Wi-Fi или мобильную сеть. @@ -9,7 +8,6 @@ Не понятный ответ. Пожалуйста, проверьте адрес сервера. Ошибка сертификата HTTPS: %1$s. Исключение SSL-соединения. Пожалуйста, проверьте сертификат сервера. - Пожалуйста, подождите… Закладки Медиа библиотека Чат @@ -212,8 +210,6 @@ Неограниченный Максимальный битрейт - Wi-Fi подключение Максимум треков - Отвечать на телефон, гарнитуру и мультимедийные кнопки Bluetooth - Медиа кнопки Таймаут сети 105 секунд 120 секунд diff --git a/ultrasonic/src/main/res/values-zh-rCN/strings.xml b/ultrasonic/src/main/res/values-zh-rCN/strings.xml index e15766b4..d993dad5 100644 --- a/ultrasonic/src/main/res/values-zh-rCN/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rCN/strings.xml @@ -1,6 +1,5 @@ - 加载中… 发生网络错误。请检查服务器地址或稍后重试。 服务端 API v%1$s 不支持此功能。 此软件需要连接网络,请打开 Wi-Fi 或移动网络。 @@ -8,7 +7,6 @@ 未知回复内容,请检查服务器地址。 HTTPS 证书错误:%1$s. SSL 连接异常。请检查服务器证书。 - 请稍等… 书签 媒体库 聊天 @@ -218,8 +216,6 @@ 不限制 最大比特率 - WIFI 最大歌曲 - 响应手机、耳机和蓝牙设备的媒体按钮 - 媒体按钮 网络超时 105 秒 120 秒 diff --git a/ultrasonic/src/main/res/values-zh-rTW/strings.xml b/ultrasonic/src/main/res/values-zh-rTW/strings.xml index ced7be3b..6645fc95 100644 --- a/ultrasonic/src/main/res/values-zh-rTW/strings.xml +++ b/ultrasonic/src/main/res/values-zh-rTW/strings.xml @@ -1,6 +1,5 @@ - 載入中… 書籤 媒體庫 正在播放 @@ -146,7 +145,6 @@ 荷蘭語 已關閉遠端控制,音樂將在手機上播放。 德語 - 請稍候… 取消固定 輸入播放清單名稱: 依照時間排列 diff --git a/ultrasonic/src/main/res/values/setting_keys.xml b/ultrasonic/src/main/res/values/setting_keys.xml index 9dd581cf..26df67dd 100644 --- a/ultrasonic/src/main/res/values/setting_keys.xml +++ b/ultrasonic/src/main/res/values/setting_keys.xml @@ -19,7 +19,6 @@ preloadCount parallelDownloads hideMedia - mediaButtons scrobble serverScaling wifiRequiredForDownload diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index b286e8a0..7cdf9239 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -1,7 +1,6 @@ - Loading… A network error occurred. Please check the server address or try again later. Server API v%1$s does not support this function. This program requires network access. Please turn on Wi-Fi or mobile network. @@ -9,7 +8,6 @@ Didn\'t understand the reply. Please check the server address. HTTPS certificate error: %1$s. SSL connection exception. Please check server certificate. - Please wait… Bookmarks Media Library Chat @@ -240,8 +238,6 @@ Max Bitrate - Wi-Fi Max Bitrate - When pinning a song permanently Max Songs - Respond to phone, headset and Bluetooth media buttons - Media Buttons Network Timeout 105 seconds 120 seconds @@ -365,6 +361,7 @@ Save as default Comment Time To Expiration + Options \"%s\" was removed from playlist Share Playlist Share Current Song diff --git a/ultrasonic/src/main/res/xml/settings.xml b/ultrasonic/src/main/res/xml/settings.xml index 74e1807b..30ef87a0 100644 --- a/ultrasonic/src/main/res/xml/settings.xml +++ b/ultrasonic/src/main/res/xml/settings.xml @@ -72,12 +72,6 @@ a:summary="@string/settings.show_artist_picture_summary" a:title="@string/settings.show_artist_picture" app:iconSpaceReserved="false"/> -