From 410e4d9d963e250d231d9890939bc7dd62992c84 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Mon, 31 Jul 2023 16:56:07 +0000 Subject: [PATCH 01/31] Translated using Weblate (Spanish) Currently translated at 100.0% (429 of 429 strings) Translation: Ultrasonic/app Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/es/ --- ultrasonic/src/main/res/values-es/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ultrasonic/src/main/res/values-es/strings.xml b/ultrasonic/src/main/res/values-es/strings.xml index 6584faf7..6583680d 100644 --- a/ultrasonic/src/main/res/values-es/strings.xml +++ b/ultrasonic/src/main/res/values-es/strings.xml @@ -455,4 +455,9 @@ Canciones aleatorias Reproducir las canciones aleatoriamente No me gusta + + %d canción añadida a la cola de reproducción + %d canciones añadidas a la cola de reproducción + %d canciones añadidas a la cola de reproducción + \ No newline at end of file From 6b000bc90f685b977eb7345a906b4e83ab68d279 Mon Sep 17 00:00:00 2001 From: birdbird Date: Thu, 3 Aug 2023 11:18:26 +0000 Subject: [PATCH 02/31] Translated using Weblate (German) Currently translated at 99.3% (426 of 429 strings) Translation: Ultrasonic/app Translate-URL: https://hosted.weblate.org/projects/ultrasonic/app/de/ --- ultrasonic/src/main/res/values-de/strings.xml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ultrasonic/src/main/res/values-de/strings.xml b/ultrasonic/src/main/res/values-de/strings.xml index c73cc34b..bc954811 100644 --- a/ultrasonic/src/main/res/values-de/strings.xml +++ b/ultrasonic/src/main/res/values-de/strings.xml @@ -8,7 +8,7 @@ Antwort nicht verstanden. Bitte die Serveradresse überprüfen. HTTPS Zertifikatsfehler: %1$s. SSL Verbindungsfehler. Bitte das Serverzertifikat überprüfen. - Bitte warten…  + Bitte warten… Lesezeichen Medienbibliothek Chat @@ -59,8 +59,8 @@ Fernbedienung ist nicht erlaubt. Bitte Jukebox Modus auf dem Subsonic Server in Benutzer > Einstellungen aktivieren. Fernbedienung ausgeschaltet. Musik wird auf dem Telefon wiedergegeben. Fernbedienungs-Modus is Offline nicht verfügbar. - Fernbedienung ausgeschaltet. Musik wird auf dem Server wiedergegeben. - Fernbedienungs Modus wird nicht unterstützt. Bitte den Subsonic Server aktualisieren. + Fernbedienung eingeschaltet. Musik wird auf dem Server wiedergegeben. + Fernbedienungs Modus wird nicht unterstützt. Bitte aktualisiere den Subsonic Server. Equalizer Jukebox Aus Jukebox An @@ -443,9 +443,15 @@ Album-Artwork Benachrichtigungen sind für die Medienwiedergabe erforderlich. Du kannst die Erlaubnis jederzeit in den Android-Einstellungen erteilen. Hardware-Wiedergabe verwenden (experimentell) - Versuche, die Medien mit dem Mediendecoder-Chip auf Deinem Telefon abzuspielen. Dadurch kann der Akku besser genutzt werden. + Versuche, die Medien mit dem Mediendecoder-Chip auf Deinem Telefon abzuspielen. Dadurch kann der Akku besser genutzt werden. Einige Nutzer berichten über Stotterer beim Abspielen, wenn diese Option aktiv ist. Liste Cover Unterstützte Funktionen Jukebox + Zufällige Titel abspielen + Zufällige Titel + + Ein Titel wurde zur Wiedergabeliste hinzugefügt + %d Titel wurden zur Wiedergabeliste hinzugefügt + \ No newline at end of file From 71d45c89fb6c33abcb3aada8039092a4a3c9fbd5 Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Sat, 5 Aug 2023 15:10:25 +0000 Subject: [PATCH 03/31] Migrate more files to Kotlin --- .../receiver/UltrasonicIntentReceiver.java | 38 ----- .../service/ExternalStorageMonitor.java | 53 ------ .../moire/ultrasonic/service/Scrobbler.java | 50 ------ .../moire/ultrasonic/service/Supplier.java | 12 -- .../moire/ultrasonic/view/ChatAdapter.java | 159 ------------------ .../moire/ultrasonic/view/GenreAdapter.java | 110 ------------ .../moire/ultrasonic/view/ShareAdapter.java | 56 ------ .../org/moire/ultrasonic/view/ShareView.java | 64 ------- .../fragment/legacy/SharesFragment.kt | 19 ++- .../receiver/BluetoothIntentReceiver.kt | 74 ++++---- .../receiver/UltrasonicIntentReceiver.kt | 34 ++++ .../service/ExternalStorageMonitor.kt | 47 ++++++ .../org/moire/ultrasonic/service/Scrobbler.kt | 52 ++++++ .../org/moire/ultrasonic/view/ChatAdapter.kt | 108 ++++++++++++ .../org/moire/ultrasonic/view/GenreAdapter.kt | 71 ++++++++ .../org/moire/ultrasonic/view/ShareAdapter.kt | 53 ++++++ .../src/main/res/layout/share_list_item.xml | 5 +- 17 files changed, 411 insertions(+), 594 deletions(-) delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/service/ExternalStorageMonitor.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/service/Scrobbler.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/service/Supplier.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/view/ChatAdapter.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareAdapter.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareView.java rename ultrasonic/src/main/{java => kotlin}/org/moire/ultrasonic/receiver/BluetoothIntentReceiver.kt (54%) create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/ExternalStorageMonitor.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Scrobbler.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/ChatAdapter.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/GenreAdapter.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/ShareAdapter.kt diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.java b/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.java deleted file mode 100644 index 5eb8e214..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.moire.ultrasonic.receiver; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import timber.log.Timber; - -import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport; - -import kotlin.Lazy; - -import static org.koin.java.KoinJavaComponent.inject; - -public class UltrasonicIntentReceiver extends BroadcastReceiver -{ - private Lazy lifecycleSupport = inject(MediaPlayerLifecycleSupport.class); - - @Override - public void onReceive(Context context, Intent intent) - { - String intentAction = intent.getAction(); - Timber.i("Received Ultrasonic Intent: %s", intentAction); - - try - { - lifecycleSupport.getValue().receiveIntent(intent); - - if (isOrderedBroadcast()) - { - abortBroadcast(); - } - } - catch (Exception x) - { - // Ignored. - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/ExternalStorageMonitor.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/ExternalStorageMonitor.java deleted file mode 100644 index a2813758..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/ExternalStorageMonitor.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.moire.ultrasonic.service; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; - -import org.moire.ultrasonic.app.UApp; - -import timber.log.Timber; - -/** - * Monitors the state of the mobile's external storage - */ -public class ExternalStorageMonitor -{ - private BroadcastReceiver ejectEventReceiver; - private boolean externalStorageAvailable = true; - - public void onCreate(final Runnable ejectedCallback) - { - // Stop when SD card is ejected. - ejectEventReceiver = new BroadcastReceiver() - { - @Override - public void onReceive(Context context, Intent intent) - { - externalStorageAvailable = Intent.ACTION_MEDIA_MOUNTED.equals(intent.getAction()); - if (!externalStorageAvailable) - { - Timber.i("External media is ejecting. Stopping playback."); - ejectedCallback.run(); - } - else - { - Timber.i("External media is available."); - } - } - }; - - IntentFilter ejectFilter = new IntentFilter(Intent.ACTION_MEDIA_EJECT); - ejectFilter.addAction(Intent.ACTION_MEDIA_MOUNTED); - ejectFilter.addDataScheme("file"); - UApp.Companion.applicationContext().registerReceiver(ejectEventReceiver, ejectFilter); - } - - public void onDestroy() - { - UApp.Companion.applicationContext().unregisterReceiver(ejectEventReceiver); - } - - public boolean isExternalStorageAvailable() { return externalStorageAvailable; } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/Scrobbler.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/Scrobbler.java deleted file mode 100644 index 27ea8172..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/Scrobbler.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.moire.ultrasonic.service; - -import timber.log.Timber; -import org.moire.ultrasonic.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.Track; - -/** - * Scrobbles played songs to Last.fm. - * - * @author Sindre Mehus - * @version $Id$ - */ -public class Scrobbler -{ - private String lastSubmission; - private String lastNowPlaying; - - public void scrobble(final Track song, final boolean submission) - { - if (song == null || !ActiveServerProvider.Companion.isScrobblingEnabled()) return; - - final String id = song.getId(); - - // Avoid duplicate registrations. - if (submission && id.equals(lastSubmission)) return; - - if (!submission && id.equals(lastNowPlaying)) return; - - if (submission) lastSubmission = id; - else lastNowPlaying = id; - - new Thread(String.format("Scrobble %s", song)) - { - @Override - public void run() - { - MusicService service = MusicServiceFactory.getMusicService(); - try - { - service.scrobble(id, submission); - Timber.i("Scrobbled '%s' for %s", submission ? "submission" : "now playing", song); - } - catch (Exception x) - { - Timber.i(x, "Failed to scrobble'%s' for %s", submission ? "submission" : "now playing", song); - } - } - }.start(); - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/Supplier.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/Supplier.java deleted file mode 100644 index 6137491b..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/Supplier.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.moire.ultrasonic.service; - -/** - * Abstract class for supplying items to a consumer - * @param The type of the item supplied - */ -public abstract class Supplier -{ - public abstract T get(); -} - - diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ChatAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/ChatAdapter.java deleted file mode 100644 index 6a6ccf37..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ChatAdapter.java +++ /dev/null @@ -1,159 +0,0 @@ -package org.moire.ultrasonic.view; - -import android.content.Context; -import android.text.TextUtils; -import android.text.method.LinkMovementMethod; -import android.text.util.Linkify; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.ImageView; -import android.widget.TextView; -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.ChatMessage; -import org.moire.ultrasonic.imageloader.ImageLoader; -import org.moire.ultrasonic.subsonic.ImageLoaderProvider; - -import java.text.DateFormat; -import java.util.Date; -import java.util.List; -import java.util.regex.Pattern; - -import kotlin.Lazy; - -import static org.koin.java.KoinJavaComponent.inject; - -public class ChatAdapter extends ArrayAdapter -{ - private final Context context; - private final List messages; - - private static final String phoneRegex = "1?\\W*([2-9][0-8][0-9])\\W*([2-9][0-9]{2})\\W*([0-9]{4})"; - private static final Pattern phoneMatcher = Pattern.compile(phoneRegex); - - private final Lazy activeServerProvider = inject(ActiveServerProvider.class); - private final Lazy imageLoaderProvider = inject(ImageLoaderProvider.class); - - public ChatAdapter(Context context, List messages) - { - super(context, R.layout.chat_item, messages); - this.context = context; - this.messages = messages; - } - - @Override - public boolean areAllItemsEnabled() { - return true; - } - - @Override - public boolean isEnabled(int position) { - return false; - } - - @Override - public int getCount() - { - return messages.size(); - } - - @Override - public View getView(int position, View convertView, ViewGroup parent) - { - ChatMessage message = this.getItem(position); - - ViewHolder holder; - int layout; - - String messageUser = message.getUsername(); - Date messageTime = new java.util.Date(message.getTime()); - String messageText = message.getMessage(); - - String me = activeServerProvider.getValue().getActiveServer().getUserName(); - - layout = messageUser.equals(me) ? R.layout.chat_item_reverse : R.layout.chat_item; - - if (convertView == null) - { - convertView = inflateView(layout, parent); - holder = createViewHolder(layout, convertView); - } - else - { - holder = (ViewHolder) convertView.getTag(); - - if (!holder.chatMessage.equals(message)) - { - convertView = inflateView(layout, parent); - holder = createViewHolder(layout, convertView); - } - } - - holder.chatMessage = message; - - DateFormat timeFormat = android.text.format.DateFormat.getTimeFormat(context); - String messageTimeFormatted = String.format("[%s]", timeFormat.format(messageTime)); - - ImageLoader imageLoader = imageLoaderProvider.getValue().getImageLoader(); - - if (holder.avatar != null && !TextUtils.isEmpty(messageUser)) - { - imageLoader.loadAvatarImage(holder.avatar, messageUser); - } - - holder.username.setText(messageUser); - holder.message.setText(messageText); - holder.time.setText(messageTimeFormatted); - - return convertView; - } - - private View inflateView(int layout, ViewGroup parent) - { - return LayoutInflater.from(context).inflate(layout, parent, false); - } - - private static ViewHolder createViewHolder(int layout, View convertView) - { - ViewHolder holder = new ViewHolder(); - holder.layout = layout; - - TextView usernameView; - TextView timeView; - TextView messageView; - ImageView imageView; - - if (convertView != null) - { - usernameView = (TextView) convertView.findViewById(R.id.chat_username); - timeView = (TextView) convertView.findViewById(R.id.chat_time); - messageView = (TextView) convertView.findViewById(R.id.chat_message); - imageView = (ImageView) convertView.findViewById(R.id.chat_avatar); - - messageView.setMovementMethod(LinkMovementMethod.getInstance()); - Linkify.addLinks(messageView, Linkify.ALL); - Linkify.addLinks(messageView, phoneMatcher, "tel:"); - - holder.avatar = imageView; - holder.message = messageView; - holder.username = usernameView; - holder.time = timeView; - - convertView.setTag(holder); - } - - return holder; - } - - private static class ViewHolder - { - int layout; - ImageView avatar; - TextView message; - TextView username; - TextView time; - ChatMessage chatMessage; - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java deleted file mode 100644 index eeb1c48b..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/GenreAdapter.java +++ /dev/null @@ -1,110 +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 2010 (C) Sindre Mehus - */ -package org.moire.ultrasonic.view; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.SectionIndexer; -import android.widget.TextView; - -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.domain.Genre; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.List; - -/** - * @author Sindre Mehus - */ -public class GenreAdapter extends ArrayAdapter implements SectionIndexer -{ - private final LayoutInflater layoutInflater; - // Both arrays are indexed by section ID. - private final Object[] sections; - private final Integer[] positions; - - public GenreAdapter(@NonNull Context context, List genres) - { - super(context, R.layout.list_item_generic, genres); - - layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - - Collection sectionSet = new LinkedHashSet(30); - List positionList = new ArrayList(30); - - for (int i = 0; i < genres.size(); i++) - { - Genre genre = genres.get(i); - String index = genre.getIndex(); - if (!sectionSet.contains(index)) - { - sectionSet.add(index); - positionList.add(i); - } - } - - sections = sectionSet.toArray(new Object[0]); - positions = positionList.toArray(new Integer[0]); - } - - @NonNull - @Override - public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { - View rowView = convertView; - if (rowView == null) { - rowView = layoutInflater.inflate(R.layout.list_item_generic, parent, false); - } - - ((TextView) rowView).setText(getItem(position).getName()); - - return rowView; - } - - @Override - public Object[] getSections() - { - return sections; - } - - @Override - public int getPositionForSection(int section) - { - return positions[section]; - } - - @Override - public int getSectionForPosition(int pos) - { - for (int i = 0; i < sections.length - 1; i++) - { - if (pos < positions[i + 1]) - { - return i; - } - } - return sections.length - 1; - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareAdapter.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareAdapter.java deleted file mode 100644 index 2df142a7..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareAdapter.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.moire.ultrasonic.view; - -import android.content.Context; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.TextView; - -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.domain.Share; - -import java.util.List; - -/** - * @author Sindre Mehus - */ -public class ShareAdapter extends ArrayAdapter -{ - private final Context context; - - public ShareAdapter(Context context, List Shares) - { - super(context, R.layout.share_list_item, Shares); - this.context = context; - } - - @Override - public View getView(int position, View convertView, ViewGroup parent) - { - Share entry = getItem(position); - ShareView view; - - if (convertView instanceof ShareView) - { - ShareView currentView = (ShareView) convertView; - - ViewHolder viewHolder = (ViewHolder) convertView.getTag(); - view = currentView; - view.setViewHolder(viewHolder); - } - else - { - view = new ShareView(context); - view.setLayout(); - } - - view.setShare(entry); - return view; - } - - static class ViewHolder - { - TextView url; - TextView description; - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareView.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareView.java deleted file mode 100644 index 0bed3b2c..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/ShareView.java +++ /dev/null @@ -1,64 +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.view; - -import android.content.Context; -import android.view.LayoutInflater; -import android.widget.LinearLayout; - -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.domain.Share; - -/** - * Used to display playlists in a {@code ListView}. - * - * @author Joshua Bahnsen - */ -public class ShareView extends LinearLayout -{ - private final Context context; - private ShareAdapter.ViewHolder viewHolder; - - public ShareView(Context context) - { - super(context); - this.context = context; - } - - public void setLayout() - { - LayoutInflater.from(context).inflate(R.layout.share_list_item, this, true); - viewHolder = new ShareAdapter.ViewHolder(); - viewHolder.url = findViewById(R.id.share_url); - viewHolder.description = findViewById(R.id.share_description); - setTag(viewHolder); - } - - public void setViewHolder(ShareAdapter.ViewHolder viewHolder) - { - this.viewHolder = viewHolder; - setTag(this.viewHolder); - } - - public void setShare(Share share) - { - viewHolder.url.setText(share.getName()); - viewHolder.description.setText(share.getDescription()); - } -} \ No newline at end of file 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 31fa05bd..32bb6176 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 @@ -1,6 +1,6 @@ /* * SharesFragment.kt - * Copyright (C) 2009-2022 Ultrasonic developers + * Copyright (C) 2009-2023 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ @@ -58,7 +58,7 @@ class SharesFragment : Fragment(), KoinComponent { private var sharesListView: ListView? = null private var emptyTextView: View? = null private var shareAdapter: ShareAdapter? = null - private val downloadHandler = inject() + private val downloadHandler: DownloadHandler by inject() private var cancellationToken: CancellationToken? = null override fun onCreate(savedInstanceState: Bundle?) { Util.applyTheme(this.context) @@ -110,8 +110,9 @@ class SharesFragment : Fragment(), KoinComponent { } override fun done(result: List) { - sharesListView!!.adapter = ShareAdapter(context, result).also { shareAdapter = it } - emptyTextView!!.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE + shareAdapter = ShareAdapter(requireContext(), result) + sharesListView?.adapter = shareAdapter + emptyTextView?.visibility = if (result.isEmpty()) View.VISIBLE else View.GONE } } task.execute() @@ -132,7 +133,7 @@ class SharesFragment : Fragment(), KoinComponent { val share = sharesListView!!.getItemAtPosition(info.position) as Share when (menuItem.itemId) { R.id.share_menu_pin -> { - downloadHandler.value.justDownload( + downloadHandler.justDownload( DownloadAction.PIN, fragment = this, id = share.id, @@ -142,7 +143,7 @@ class SharesFragment : Fragment(), KoinComponent { ) } R.id.share_menu_unpin -> { - downloadHandler.value.justDownload( + downloadHandler.justDownload( DownloadAction.UNPIN, fragment = this, id = share.id, @@ -152,7 +153,7 @@ class SharesFragment : Fragment(), KoinComponent { ) } R.id.share_menu_download -> { - downloadHandler.value.justDownload( + downloadHandler.justDownload( DownloadAction.DOWNLOAD, fragment = this, id = share.id, @@ -162,7 +163,7 @@ class SharesFragment : Fragment(), KoinComponent { ) } R.id.share_menu_play_now -> { - downloadHandler.value.fetchTracksAndAddToController( + downloadHandler.fetchTracksAndAddToController( this, share.id, share.name, @@ -172,7 +173,7 @@ class SharesFragment : Fragment(), KoinComponent { ) } R.id.share_menu_play_shuffled -> { - downloadHandler.value.fetchTracksAndAddToController( + downloadHandler.fetchTracksAndAddToController( this, share.id, share.name, diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/BluetoothIntentReceiver.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/BluetoothIntentReceiver.kt similarity index 54% rename from ultrasonic/src/main/java/org/moire/ultrasonic/receiver/BluetoothIntentReceiver.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/BluetoothIntentReceiver.kt index 727a7ff5..54753ea9 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/BluetoothIntentReceiver.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/BluetoothIntentReceiver.kt @@ -1,6 +1,6 @@ /* * BluetoothIntentReceiver.kt - * Copyright (C) 2009-2022 Ultrasonic developers + * Copyright (C) 2009-2023 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ @@ -10,9 +10,6 @@ package org.moire.ultrasonic.receiver import android.Manifest import android.bluetooth.BluetoothA2dp import android.bluetooth.BluetoothDevice -import android.bluetooth.BluetoothDevice.ACTION_ACL_CONNECTED -import android.bluetooth.BluetoothDevice.ACTION_ACL_DISCONNECTED -import android.bluetooth.BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED import android.bluetooth.BluetoothProfile import android.content.BroadcastReceiver import android.content.Context @@ -22,9 +19,6 @@ import android.os.Build import androidx.core.app.ActivityCompat import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_A2DP -import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_ALL -import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_DISABLED import org.moire.ultrasonic.util.Settings import timber.log.Timber @@ -42,27 +36,27 @@ class BluetoothIntentReceiver : BroadcastReceiver() { Timber.d("Bluetooth device: $name; State: $state; Action: $action") // In these flags we store what kind of device (any or a2dp) has (dis)connected - var connectionStatus = PREFERENCE_VALUE_DISABLED - var disconnectionStatus = PREFERENCE_VALUE_DISABLED + var connectionStatus = Constants.PREFERENCE_VALUE_DISABLED + var disconnectionStatus = Constants.PREFERENCE_VALUE_DISABLED // First check for general devices when (action) { - ACTION_ACL_CONNECTED -> { - connectionStatus = PREFERENCE_VALUE_ALL + BluetoothDevice.ACTION_ACL_CONNECTED -> { + connectionStatus = Constants.PREFERENCE_VALUE_ALL } - ACTION_ACL_DISCONNECTED, - ACTION_ACL_DISCONNECT_REQUESTED -> { - disconnectionStatus = PREFERENCE_VALUE_ALL + BluetoothDevice.ACTION_ACL_DISCONNECTED, + BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED -> { + disconnectionStatus = Constants.PREFERENCE_VALUE_ALL } } // Then check for A2DP devices when (state) { BluetoothA2dp.STATE_CONNECTED -> { - connectionStatus = PREFERENCE_VALUE_A2DP + connectionStatus = Constants.PREFERENCE_VALUE_A2DP } BluetoothA2dp.STATE_DISCONNECTED -> { - disconnectionStatus = PREFERENCE_VALUE_A2DP + disconnectionStatus = Constants.PREFERENCE_VALUE_A2DP } } @@ -72,20 +66,20 @@ class BluetoothIntentReceiver : BroadcastReceiver() { // Now check the settings and set the appropriate flags when (Settings.resumeOnBluetoothDevice) { - PREFERENCE_VALUE_ALL -> { - shouldResume = (connectionStatus != PREFERENCE_VALUE_DISABLED) + Constants.PREFERENCE_VALUE_ALL -> { + shouldResume = (connectionStatus != Constants.PREFERENCE_VALUE_DISABLED) } - PREFERENCE_VALUE_A2DP -> { - shouldResume = (connectionStatus == PREFERENCE_VALUE_A2DP) + Constants.PREFERENCE_VALUE_A2DP -> { + shouldResume = (connectionStatus == Constants.PREFERENCE_VALUE_A2DP) } } when (Settings.pauseOnBluetoothDevice) { - PREFERENCE_VALUE_ALL -> { - shouldPause = (disconnectionStatus != PREFERENCE_VALUE_DISABLED) + Constants.PREFERENCE_VALUE_ALL -> { + shouldPause = (disconnectionStatus != Constants.PREFERENCE_VALUE_DISABLED) } - PREFERENCE_VALUE_A2DP -> { - shouldPause = (disconnectionStatus == PREFERENCE_VALUE_A2DP) + Constants.PREFERENCE_VALUE_A2DP -> { + shouldPause = (disconnectionStatus == Constants.PREFERENCE_VALUE_A2DP) } } @@ -105,24 +99,24 @@ class BluetoothIntentReceiver : BroadcastReceiver() { ) } } -} -private fun BluetoothDevice?.getNameSafely(): String? { - val logBluetoothName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - ( - ActivityCompat.checkSelfPermission( - UApp.applicationContext(), Manifest.permission.BLUETOOTH_CONNECT - ) != PackageManager.PERMISSION_GRANTED - ) + private fun BluetoothDevice?.getNameSafely(): String? { + val logBluetoothName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + ( + ActivityCompat.checkSelfPermission( + UApp.applicationContext(), Manifest.permission.BLUETOOTH_CONNECT + ) != PackageManager.PERMISSION_GRANTED + ) - return if (logBluetoothName) this?.name else "Unknown" -} + return if (logBluetoothName) this?.name else "Unknown" + } -private fun Intent.getBluetoothDevice(): BluetoothDevice? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) - } else { - @Suppress("DEPRECATION") - getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + private fun Intent.getBluetoothDevice(): BluetoothDevice? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.kt new file mode 100644 index 00000000..9f75fdfd --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/UltrasonicIntentReceiver.kt @@ -0,0 +1,34 @@ +/* + * UltrasonicIntentReceiver.kt + * Copyright (C) 2009-2023 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 org.koin.java.KoinJavaComponent.inject +import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport +import timber.log.Timber + +class UltrasonicIntentReceiver : BroadcastReceiver() { + private val lifecycleSupport = inject( + MediaPlayerLifecycleSupport::class.java + ) + + override fun onReceive(context: Context, intent: Intent) { + val intentAction = intent.action + Timber.i("Received Ultrasonic Intent: %s", intentAction) + try { + lifecycleSupport.value.receiveIntent(intent) + if (isOrderedBroadcast) { + abortBroadcast() + } + } catch (_: Exception) { + // Ignored. + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/ExternalStorageMonitor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/ExternalStorageMonitor.kt new file mode 100644 index 00000000..86cee2b8 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/ExternalStorageMonitor.kt @@ -0,0 +1,47 @@ +/* + * ExternalStorageMonitor.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import org.moire.ultrasonic.app.UApp.Companion.applicationContext +import timber.log.Timber + +/** + * Monitors the state of the mobile's external storage + */ +class ExternalStorageMonitor { + private var ejectEventReceiver: BroadcastReceiver? = null + var isExternalStorageAvailable = true + private set + + fun onCreate(ejectedCallback: Runnable) { + // Stop when SD card is ejected. + ejectEventReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + isExternalStorageAvailable = Intent.ACTION_MEDIA_MOUNTED == intent.action + if (!isExternalStorageAvailable) { + Timber.i("External media is ejecting. Stopping playback.") + ejectedCallback.run() + } else { + Timber.i("External media is available.") + } + } + } + val ejectFilter = IntentFilter(Intent.ACTION_MEDIA_EJECT) + ejectFilter.addAction(Intent.ACTION_MEDIA_MOUNTED) + ejectFilter.addDataScheme("file") + applicationContext().registerReceiver(ejectEventReceiver, ejectFilter) + } + + fun onDestroy() { + applicationContext().unregisterReceiver(ejectEventReceiver) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Scrobbler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Scrobbler.kt new file mode 100644 index 00000000..0f7d6e3f --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Scrobbler.kt @@ -0,0 +1,52 @@ +/* + * Scrobbler.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isScrobblingEnabled +import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService +import timber.log.Timber + +/** + * Scrobbles played songs to Last.fm. + */ +class Scrobbler : CoroutineScope by CoroutineScope(Dispatchers.IO) { + private var lastSubmission: String? = null + private var lastNowPlaying: String? = null + fun scrobble(song: Track?, submission: Boolean) { + if (song == null || !isScrobblingEnabled()) return + val id = song.id + + // Avoid duplicate registrations. + if (submission && id == lastSubmission) return + if (!submission && id == lastNowPlaying) return + if (submission) lastSubmission = id else lastNowPlaying = id + + launch { + val service = getMusicService() + try { + service.scrobble(id, submission) + Timber.i( + "Scrobbled '%s' for %s", + if (submission) "submission" else "now playing", + song + ) + } catch (all: Exception) { + Timber.i( + all, + "Failed to scrobble'%s' for %s", + if (submission) "submission" else "now playing", + song + ) + } + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/ChatAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/ChatAdapter.kt new file mode 100644 index 00000000..cb2db531 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/ChatAdapter.kt @@ -0,0 +1,108 @@ +/* + * ChatAdapter.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.view + +import android.content.Context +import android.text.TextUtils +import android.text.format.DateFormat +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.TextView +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.domain.ChatMessage +import org.moire.ultrasonic.subsonic.ImageLoaderProvider + +class ChatAdapter(private val context: Context, private val messages: List) : + ArrayAdapter( + context, R.layout.chat_item, messages + ), + KoinComponent { + + private val activeServerProvider: ActiveServerProvider by inject() + private val imageLoaderProvider: ImageLoaderProvider by inject() + + override fun areAllItemsEnabled(): Boolean { + return true + } + + override fun isEnabled(position: Int): Boolean { + return false + } + + override fun getCount(): Int { + return messages.size + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var view = convertView + val message = getItem(position) + val holder: ViewHolder + val layout: Int + val me = activeServerProvider.getActiveServer().userName + + // Different layouts based on the message sender/recipient + layout = if (message?.username == me) R.layout.chat_item_reverse else R.layout.chat_item + + if (view == null || (view.tag as ViewHolder).layout != layout) { + view = inflateView(layout, parent) + holder = ViewHolder() + } else { + holder = view.tag as ViewHolder + } + + linkHolder(holder, layout, view) + if (message != null) setData(holder, message) + return view + } + + private fun inflateView(layout: Int, parent: ViewGroup): View { + return LayoutInflater.from(context).inflate(layout, parent, false) + } + + private class ViewHolder { + var layout = R.layout.chat_item + var avatar: ImageView? = null + var message: TextView? = null + var username: TextView? = null + var time: TextView? = null + var chatMessage: ChatMessage? = null + } + + private fun linkHolder(holder: ViewHolder, layout: Int, view: View) { + holder.layout = layout + holder.avatar = view.findViewById(R.id.chat_avatar) + holder.message = view.findViewById(R.id.chat_message) + holder.message?.movementMethod = LinkMovementMethod.getInstance() + holder.username = view.findViewById(R.id.chat_username) + holder.time = view.findViewById(R.id.chat_time) + view.tag = holder + } + + private fun setData( + holder: ViewHolder, + message: ChatMessage + ) { + holder.chatMessage = message + val timeFormat = DateFormat.getTimeFormat(context) + val messageTimeFormatted = "[${timeFormat.format(message.time)}]" + val imageLoader = imageLoaderProvider.getImageLoader() + if (holder.avatar != null && !TextUtils.isEmpty(message.username)) { + imageLoader.loadAvatarImage(holder.avatar!!, message.username) + } + holder.username?.text = message.username + holder.message?.text = message.message + holder.time?.text = messageTimeFormatted + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/GenreAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/GenreAdapter.kt new file mode 100644 index 00000000..0641a83a --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/GenreAdapter.kt @@ -0,0 +1,71 @@ +/* + * GenreAdapter.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ +package org.moire.ultrasonic.view + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.SectionIndexer +import android.widget.TextView +import org.moire.ultrasonic.R +import org.moire.ultrasonic.domain.Genre + +class GenreAdapter(context: Context, genres: List) : + ArrayAdapter(context, R.layout.list_item_generic, genres), SectionIndexer { + private val layoutInflater: LayoutInflater + + // Both arrays are indexed by section ID. + private val sections: Array + private val positions: Array + + init { + layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val sectionSet: MutableCollection = LinkedHashSet(INITIAL_CAPACITY) + val positionList: MutableList = ArrayList(INITIAL_CAPACITY) + for (i in genres.indices) { + val (index) = genres[i] + if (!sectionSet.contains(index)) { + sectionSet.add(index) + positionList.add(i) + } + } + sections = sectionSet.toTypedArray() + positions = positionList.toTypedArray() + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var rowView = convertView + if (rowView == null) { + rowView = layoutInflater.inflate(R.layout.list_item_generic, parent, false) + } + (rowView as TextView?)!!.text = getItem(position)!!.name + return rowView!! + } + + override fun getSections(): Array { + return sections + } + + override fun getPositionForSection(section: Int): Int { + return positions[section] + } + + override fun getSectionForPosition(pos: Int): Int { + for (i in 0 until sections.size - 1) { + if (pos < positions[i + 1]) { + return i + } + } + return sections.size - 1 + } + + companion object { + const val INITIAL_CAPACITY: Int = 30 + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/ShareAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/ShareAdapter.kt new file mode 100644 index 00000000..9f6d3195 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/view/ShareAdapter.kt @@ -0,0 +1,53 @@ +/* + * ShareAdapter.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.view + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import org.moire.ultrasonic.R +import org.moire.ultrasonic.domain.Share + +class ShareAdapter(private val context: Context, shares: List) : ArrayAdapter( + context, R.layout.share_list_item, shares +) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val entry = getItem(position) + val view: View + val holder: ViewHolder + + if (convertView == null) { + holder = ViewHolder() + val inflater = LayoutInflater.from(context) + view = inflater.inflate(R.layout.share_list_item, parent, false) + holder.url = view.findViewById(R.id.share_url) + holder.description = view.findViewById(R.id.share_description) + view.tag = holder + } else { + view = convertView + holder = view.tag as ViewHolder + } + + if (entry != null) setData(entry, holder) + + return view + } + + private fun setData(entry: Share, holder: ViewHolder) { + holder.url?.text = entry.name + holder.description?.text = entry.description + } + + class ViewHolder { + var url: TextView? = null + var description: TextView? = null + } +} diff --git a/ultrasonic/src/main/res/layout/share_list_item.xml b/ultrasonic/src/main/res/layout/share_list_item.xml index a0f3c45e..6bd892c3 100644 --- a/ultrasonic/src/main/res/layout/share_list_item.xml +++ b/ultrasonic/src/main/res/layout/share_list_item.xml @@ -1,15 +1,14 @@ + a:paddingEnd="16dip"> Date: Mon, 7 Aug 2023 19:31:40 +0000 Subject: [PATCH 04/31] Update dependency org.jlleitschuh.gradle:ktlint-gradle to v11.5.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6137c2c9..9965426a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ navigation = "2.6.0" gradlePlugin = "8.1.0" androidxcore = "1.10.1" ktlint = "0.43.2" -ktlintGradle = "11.5.0" +ktlintGradle = "11.5.1" detekt = "1.23.0" preferences = "1.2.1" media3 = "1.1.0" From 82f67586499d138ef26f7e8bcc51257d15de2f64 Mon Sep 17 00:00:00 2001 From: Alex Katlein Date: Wed, 9 Aug 2023 21:16:52 +0200 Subject: [PATCH 05/31] Utilize CarConnection to determine whether to set repeat mode to ALL --- gradle/libs.versions.toml | 4 +- ultrasonic/build.gradle | 1 + ultrasonic/src/main/AndroidManifest.xml | 2 + .../playback/AutoMediaBrowserCallback.kt | 44 ++++++++++++++++++- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6137c2c9..9b704ce0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ gradle = "8.1.1" navigation = "2.6.0" gradlePlugin = "8.1.0" +androidxcar = "1.2.0" androidxcore = "1.10.1" ktlint = "0.43.2" ktlintGradle = "11.5.0" @@ -49,6 +50,7 @@ kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin" ktlintGradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlintGradle" } detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } +car = { module = "androidx.car.app:app", version.ref = "androidxcar" } core = { module = "androidx.core:core-ktx", version.ref = "androidxcore" } design = { module = "com.google.android.material:material", version.ref = "materialDesign" } annotations = { module = "androidx.annotation:annotation", version.ref = "androidSupport" } @@ -103,4 +105,4 @@ apacheCodecs = { module = "commons-codec:commons-codec", version.ref robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } [plugins] -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 004d151c..08dd6559 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -100,6 +100,7 @@ dependencies { exclude group: "com.android.support" } + implementation libs.car implementation libs.core implementation libs.design implementation libs.multidex diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 711f13e2..0d25405d 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ + + = Build.VERSION_CODES.M) { + Timber.d("Car app library available, observing CarConnection") + + val originalRepeatMode = player.repeatMode + + var lastCarConnectionType = -1 + + CarConnection(applicationContext).type.observeForever { + if (lastCarConnectionType == it) + return@observeForever + + lastCarConnectionType = it + + Timber.d("CarConnection type changed to %s", it) + + when (it) { + CarConnection.CONNECTION_TYPE_PROJECTION -> + if (!customRepeatModeSet) { + Timber.d("[CarConnection] Setting repeat mode to ALL") + player.repeatMode = Player.REPEAT_MODE_ALL + customRepeatModeSet = true + } + + CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> + if (customRepeatModeSet) { + Timber.d("[CarConnection] Resetting repeat mode") + player.repeatMode = originalRepeatMode + customRepeatModeSet = false + } + } + } + } else + Timber.d("Car app library not available") + } + override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { if (controller.controllerVersion != 0) { // Let Media3 controller (for instance the MediaNotificationProvider) @@ -369,6 +410,7 @@ class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callbac PlaybackService.CUSTOM_COMMAND_REPEAT_MODE -> { customCommandFuture = Futures.immediateFuture(SessionResult(RESULT_SUCCESS)) + customRepeatModeSet = true session.player.setNextRepeatMode() session.updateCustomCommands() From ab10d39f75fecc6bd4371a36878c033d016273aa Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 9 Aug 2023 20:31:51 +0000 Subject: [PATCH 06/31] Update dependency org.mockito.kotlin:mockito-kotlin to v5.1.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6137c2c9..9051be3b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ picasso = "2.8" junit4 = "4.13.2" junit5 = "5.10.0" mockito = "5.4.0" -mockitoKotlin = "5.0.0" +mockitoKotlin = "5.1.0" kluent = "1.73" apacheCodecs = "1.16.0" robolectric = "4.10.3" From 002a3250e4bed539d13e7df1f12a8e6578f660c0 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 16 Aug 2023 12:32:00 +0000 Subject: [PATCH 07/31] Update media3 to v1.1.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6137c2c9..b6d7a09b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ ktlint = "0.43.2" ktlintGradle = "11.5.0" detekt = "1.23.0" preferences = "1.2.1" -media3 = "1.1.0" +media3 = "1.1.1" androidSupport = "1.6.0" materialDesign = "1.9.0" From fa3ca57a4efeb59834303c51512f250fbf9a78ab Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 17 Aug 2023 07:32:56 +0000 Subject: [PATCH 08/31] Update dependency gradle to v8.3 --- gradle/wrapper/gradle-wrapper.jar | Bin 63375 -> 63721 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 3 ++- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c4cdf41af1ab109bc7f253b2b887023340..7f93135c49b765f8051ef9d0a6055ff8e46073d8 100644 GIT binary patch delta 28216 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c747538f..d11cdd90 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.2.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index fcb6fca1..0adc8e1a 100755 --- a/gradlew +++ b/gradlew @@ -83,7 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum From 0a3717f448234b979bd5e4c336ae1f6f6accf5e4 Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Tue, 22 Aug 2023 08:14:06 +0000 Subject: [PATCH 09/31] Merge back from release --- fastlane/metadata/android/en-US/changelogs/130.txt | 9 +++++++++ ultrasonic/build.gradle | 4 ++-- .../ultrasonic/playback/AutoMediaBrowserCallback.kt | 2 +- .../org/moire/ultrasonic/service/MediaPlayerManager.kt | 2 +- .../org/moire/ultrasonic/subsonic/DownloadHandler.kt | 2 +- ultrasonic/src/main/res/drawable/empty.xml | 7 ------- 6 files changed, 14 insertions(+), 12 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/130.txt delete mode 100644 ultrasonic/src/main/res/drawable/empty.xml diff --git a/fastlane/metadata/android/en-US/changelogs/130.txt b/fastlane/metadata/android/en-US/changelogs/130.txt new file mode 100644 index 00000000..3d2ccf81 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130.txt @@ -0,0 +1,9 @@ +### 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 diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 004d151c..cea9d70f 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -12,8 +12,8 @@ android { defaultConfig { applicationId "org.moire.ultrasonic" - versionCode 126 - versionName "4.6.3" + versionCode 128 + versionName "4.7.0" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt index d7abb1bb..0154a05a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt @@ -276,7 +276,7 @@ class AutoMediaBrowserCallback : MediaLibraryService.MediaLibrarySession.Callbac private fun getPlaceholderButton() = CommandButton.Builder() .setDisplayName("Placeholder") - .setIconResId(R.drawable.empty) + .setIconResId(android.R.color.transparent) .setSessionCommand( SessionCommand( PlaybackService.CUSTOM_COMMAND_PLACEHOLDER, 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 93588750..d07b4c52 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt @@ -460,7 +460,7 @@ class MediaPlayerManager( // We can't just use play(0,0) then all random playlists will start with the first track. // Additionally the shuffle order becomes clear on after some time, so we need to wait for // the right event, and can start playback only then. - if (autoPlay) { + if (autoPlay && controller?.isPlaying != true) { if (isShufflePlayEnabled) { deferredPlay = { val start = controller?.currentTimeline diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt index 207c9141..a0b4c156 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt @@ -169,7 +169,7 @@ class DownloadHandler( ) if (Settings.shouldTransitionOnPlayback && - (insertionMode == MediaPlayerManager.InsertionMode.CLEAR || autoPlay) + insertionMode == MediaPlayerManager.InsertionMode.CLEAR ) { fragment.findNavController().popBackStack(R.id.playerFragment, true) fragment.findNavController().navigate(R.id.playerFragment) diff --git a/ultrasonic/src/main/res/drawable/empty.xml b/ultrasonic/src/main/res/drawable/empty.xml deleted file mode 100644 index a4762a86..00000000 --- a/ultrasonic/src/main/res/drawable/empty.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - From cd101aef2af95d0b35a2449c39ba66eb0f82ba4b Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 22 Aug 2023 09:31:49 +0000 Subject: [PATCH 10/31] Update dependency com.android.tools.build:gradle to v8.1.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9b704ce0..511d4584 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ gradle = "8.1.1" navigation = "2.6.0" -gradlePlugin = "8.1.0" +gradlePlugin = "8.1.1" androidxcar = "1.2.0" androidxcore = "1.10.1" ktlint = "0.43.2" From deef540ddcd3215ee46beef42bec5aa7ade94a18 Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Tue, 22 Aug 2023 10:08:53 +0000 Subject: [PATCH 11/31] Disable a lint --- ultrasonic/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index de0ec940..40c72e39 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -81,6 +81,7 @@ android { disable 'ObsoleteLintCustomCheck' // We manage dependencies on Gitlab with RenovateBot disable 'GradleDependency' + disable 'AndroidGradlePluginVersion' textReport true checkDependencies true } From 896c946a5a03a6cb81a63f864c9ad475b5b3cd26 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 22 Aug 2023 13:31:52 +0000 Subject: [PATCH 12/31] Update dependency org.mockito:mockito-core to v5.5.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d35a63f4..e507d621 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ picasso = "2.8" junit4 = "4.13.2" junit5 = "5.10.0" -mockito = "5.4.0" +mockito = "5.5.0" mockitoKotlin = "5.1.0" kluent = "1.73" apacheCodecs = "1.16.0" From fb123d926d885e5a17e260b54bb5e3c982e2f364 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 23 Aug 2023 09:45:59 +0000 Subject: [PATCH 13/31] Update dependency com.google.devtools.ksp to v1.9.0-1.0.13 --- gradle/libs.versions.toml | 6 +++--- gradle/versions.gradle | 4 ++-- .../moire/ultrasonic/fragment/PlayerFragment.kt | 8 ++++---- .../moire/ultrasonic/fragment/SettingsFragment.kt | 4 +++- .../moire/ultrasonic/service/JukeboxMediaPlayer.kt | 1 + .../service/JukeboxUnimplementedFunctions.kt | 14 ++++++++++++++ 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d35a63f4..c41108d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # You need to run ./gradlew wrapper after updating the version gradle = "8.1.1" -navigation = "2.6.0" +navigation = "2.7.0" gradlePlugin = "8.1.1" androidxcar = "1.2.0" androidxcore = "1.10.1" @@ -17,8 +17,8 @@ materialDesign = "1.9.0" constraintLayout = "2.1.4" multidex = "2.0.1" room = "2.5.2" -kotlin = "1.8.22" -ksp = "1.8.22-1.0.11" +kotlin = "1.9.0" +ksp = "1.9.0-1.0.13" kotlinxCoroutines = "1.7.3" viewModelKtx = "2.6.1" swipeRefresh = "1.1.0" diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 4e7d1ef2..3b8d8617 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -1,5 +1,5 @@ ext.versions = [ minSdk : 21, targetSdk : 33, - compileSdk : 33, -] \ No newline at end of file + compileSdk : 34, +] 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 adcffd95..dd7a23c1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -1219,14 +1219,14 @@ class PlayerFragment : @Suppress("ReturnCount") override fun onFling( - e1: MotionEvent, + e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float ): Boolean { - val e1X = e1.x + val e1X = e1?.x ?: 0F val e2X = e2.x - val e1Y = e1.y + val e1Y = e1?.y ?: 0F val e2Y = e2.y val absX = abs(velocityX) val absY = abs(velocityY) @@ -1263,7 +1263,7 @@ class PlayerFragment : override fun onLongPress(e: MotionEvent) {} override fun onScroll( - e1: MotionEvent, + e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float 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 d007c399..9e654186 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -108,7 +108,9 @@ class SettingsFragment : preferences.unregisterOnSharedPreferenceChangeListener(this) } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + if (key == null || sharedPreferences == null) return + Timber.d("Preference changed: %s", key) updateCustomPreferences() 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 fb7d3b71..4be7ed45 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -286,6 +286,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {} + @Deprecated("Deprecated in Java") override fun setDeviceVolume(volume: Int) { setDeviceVolume(volume, 0) } 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 c92abd23..d32b2ca6 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt @@ -91,10 +91,12 @@ abstract class JukeboxUnimplementedFunctions : Player { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun hasPrevious(): Boolean { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun hasPreviousWindow(): Boolean { TODO("Not yet implemented") } @@ -103,10 +105,12 @@ abstract class JukeboxUnimplementedFunctions : Player { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun previous() { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun seekToPreviousWindow() { TODO("Not yet implemented") } @@ -115,10 +119,12 @@ abstract class JukeboxUnimplementedFunctions : Player { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun hasNext(): Boolean { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun hasNextWindow(): Boolean { TODO("Not yet implemented") } @@ -127,10 +133,12 @@ abstract class JukeboxUnimplementedFunctions : Player { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun next() { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun seekToNextWindow() { TODO("Not yet implemented") } @@ -163,10 +171,12 @@ abstract class JukeboxUnimplementedFunctions : Player { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun getCurrentWindowIndex(): Int { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun getNextWindowIndex(): Int { TODO("Not yet implemented") } @@ -175,6 +185,7 @@ abstract class JukeboxUnimplementedFunctions : Player { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun getPreviousWindowIndex(): Int { TODO("Not yet implemented") } @@ -183,14 +194,17 @@ abstract class JukeboxUnimplementedFunctions : Player { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun isCurrentWindowDynamic(): Boolean { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun isCurrentWindowLive(): Boolean { TODO("Not yet implemented") } + @Deprecated("Deprecated in Java") override fun isCurrentWindowSeekable(): Boolean { TODO("Not yet implemented") } From 2d943edd6121a6c0103943b045d216f8d9ebf14a Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 23 Aug 2023 10:31:43 +0000 Subject: [PATCH 14/31] Update kotlin monorepo to v1.9.10 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7492581..2a47f388 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ materialDesign = "1.9.0" constraintLayout = "2.1.4" multidex = "2.0.1" room = "2.5.2" -kotlin = "1.9.0" +kotlin = "1.9.10" ksp = "1.9.0-1.0.13" kotlinxCoroutines = "1.7.3" viewModelKtx = "2.6.1" From e581776e43d53c8892f4a17f027e1ac1aeaabe33 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 23 Aug 2023 12:31:51 +0000 Subject: [PATCH 15/31] Update dependency io.reactivex.rxjava3:rxjava to v3.1.7 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2a47f388..8ec6f398 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ robolectric = "4.10.3" timber = "5.0.1" fastScroll = "2.0.1" colorPicker = "2.2.4" -rxJava = "3.1.6" +rxJava = "3.1.7" rxAndroid = "3.0.2" multiType = "4.3.0" From 8ab3b2634d850aeebffc955c0df27480132f9e37 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 23 Aug 2023 17:08:15 +0000 Subject: [PATCH 16/31] Update navigation to v2.7.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ec6f398..e96393c3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # You need to run ./gradlew wrapper after updating the version gradle = "8.1.1" -navigation = "2.7.0" +navigation = "2.7.1" gradlePlugin = "8.1.1" androidxcar = "1.2.0" androidxcore = "1.10.1" From d2ed058d311884f7508d06500ce1cbe9b4a90b64 Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Thu, 24 Aug 2023 06:32:43 +0000 Subject: [PATCH 17/31] Sync master and develop --- fastlane/metadata/android/en-US/changelogs/128.txt | 9 +++++++++ fastlane/metadata/android/en-US/changelogs/129.txt | 12 ++++++++++++ ultrasonic/build.gradle | 4 ++-- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/128.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/129.txt diff --git a/fastlane/metadata/android/en-US/changelogs/128.txt b/fastlane/metadata/android/en-US/changelogs/128.txt new file mode 100644 index 00000000..3d2ccf81 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/128.txt @@ -0,0 +1,9 @@ +### 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 diff --git a/fastlane/metadata/android/en-US/changelogs/129.txt b/fastlane/metadata/android/en-US/changelogs/129.txt new file mode 100644 index 00000000..99df21de --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/129.txt @@ -0,0 +1,12 @@ +### Fixes +- Fix a bug in 4.7.0 that repeat mode was activated by default. + +### 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 diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 40c72e39..5f9420ae 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -12,8 +12,8 @@ android { defaultConfig { applicationId "org.moire.ultrasonic" - versionCode 128 - versionName "4.7.0" + versionCode 129 + versionName "4.7.1" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk From db0f3b21e1d4298085a0baddae50d64e2c05ac2d Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 24 Aug 2023 09:31:42 +0000 Subject: [PATCH 18/31] Update dependency com.google.devtools.ksp to v1.9.10-1.0.13 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e96393c3..751681ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ constraintLayout = "2.1.4" multidex = "2.0.1" room = "2.5.2" kotlin = "1.9.10" -ksp = "1.9.0-1.0.13" +ksp = "1.9.10-1.0.13" kotlinxCoroutines = "1.7.3" viewModelKtx = "2.6.1" swipeRefresh = "1.1.0" From f6ad5be3e05d7d96dd6c10d76831801562f7a996 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 6 Sep 2023 17:31:37 +0000 Subject: [PATCH 19/31] Update dependency androidx.lifecycle:lifecycle-viewmodel-ktx to v2.6.2 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e96393c3..b7c2803e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ room = "2.5.2" kotlin = "1.9.10" ksp = "1.9.0-1.0.13" kotlinxCoroutines = "1.7.3" -viewModelKtx = "2.6.1" +viewModelKtx = "2.6.2" swipeRefresh = "1.1.0" retrofit = "2.9.0" From 907c94096bde56d4117d573763ee293fbc900dc8 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 6 Sep 2023 18:31:55 +0000 Subject: [PATCH 20/31] Update dependency androidx.annotation:annotation to v1.7.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e96393c3..b30748cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ detekt = "1.23.0" preferences = "1.2.1" media3 = "1.1.1" -androidSupport = "1.6.0" +androidSupport = "1.7.0" materialDesign = "1.9.0" constraintLayout = "2.1.4" multidex = "2.0.1" From 76f16cc9f11fc16e222bad9abee84ab36dae286c Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 6 Sep 2023 18:31:57 +0000 Subject: [PATCH 21/31] Update dependency androidx.core:core-ktx to v1.12.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e96393c3..7d435cd5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ gradle = "8.1.1" navigation = "2.7.1" gradlePlugin = "8.1.1" androidxcar = "1.2.0" -androidxcore = "1.10.1" +androidxcore = "1.12.0" ktlint = "0.43.2" ktlintGradle = "11.5.1" detekt = "1.23.0" From 8c7e8a8ae050c7d8ad0de46af179a72df6f063e6 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 13 Sep 2023 08:31:46 +0000 Subject: [PATCH 22/31] Update koin to v3.5.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e96393c3..4a3e2f46 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ 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" -koin = "3.4.3" +koin = "3.5.0" picasso = "2.8" junit4 = "4.13.2" From f9bafa93da6751e8ef326526ab385c8e24c38eed Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 18 Sep 2023 20:31:51 +0000 Subject: [PATCH 23/31] Update dependency org.jlleitschuh.gradle:ktlint-gradle to v11.6.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e96393c3..3ae99cd8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ gradlePlugin = "8.1.1" androidxcar = "1.2.0" androidxcore = "1.10.1" ktlint = "0.43.2" -ktlintGradle = "11.5.1" +ktlintGradle = "11.6.0" detekt = "1.23.0" preferences = "1.2.1" media3 = "1.1.1" From 7a24be4b98dc2aad95d4a93522fc0ecfcea07c5a Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 20 Sep 2023 17:31:48 +0000 Subject: [PATCH 24/31] Update navigation to v2.7.3 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e96393c3..78c6e66c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # You need to run ./gradlew wrapper after updating the version gradle = "8.1.1" -navigation = "2.7.1" +navigation = "2.7.3" gradlePlugin = "8.1.1" androidxcar = "1.2.0" androidxcore = "1.10.1" From 5167f9e45e65300f1098af20d083b9ec18e26967 Mon Sep 17 00:00:00 2001 From: Nite Date: Mon, 25 Sep 2023 17:38:22 +0200 Subject: [PATCH 25/31] Created outline for rating star images --- .../ultrasonic/fragment/PlayerFragment.kt | 52 +++++++++++++++---- .../res/drawable/ic_star_full_outline.xml | 17 ++++++ .../res/drawable/ic_star_hollow_outline.xml | 17 ++++++ .../main/res/drawable/star_full_outline.xml | 5 ++ .../main/res/drawable/star_hollow_outline.xml | 5 ++ .../main/res/layout-land/current_playing.xml | 10 ++-- .../src/main/res/layout/current_playing.xml | 30 +++++------ 7 files changed, 105 insertions(+), 31 deletions(-) create mode 100644 ultrasonic/src/main/res/drawable/ic_star_full_outline.xml create mode 100644 ultrasonic/src/main/res/drawable/ic_star_hollow_outline.xml create mode 100644 ultrasonic/src/main/res/drawable/star_full_outline.xml create mode 100644 ultrasonic/src/main/res/drawable/star_hollow_outline.xml 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 dd7a23c1..52394da5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -8,13 +8,17 @@ package org.moire.ultrasonic.fragment import android.annotation.SuppressLint +import android.content.Context import android.graphics.Canvas import android.graphics.Color.argb import android.graphics.Point +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.util.TypedValue import android.view.GestureDetector import android.view.LayoutInflater import android.view.Menu @@ -34,6 +38,8 @@ import android.widget.SeekBar.OnSeekBarChangeListener import android.widget.TextView import android.widget.Toast import android.widget.ViewFlipper +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.res.ResourcesCompat import androidx.core.view.MenuHost @@ -52,6 +58,7 @@ import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_IDLE import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.R as RM import com.google.android.material.button.MaterialButton import com.google.android.material.progressindicator.CircularProgressIndicator import io.reactivex.rxjava3.disposables.CompositeDisposable @@ -167,8 +174,10 @@ class PlayerFragment : private lateinit var repeatButton: MaterialButton private lateinit var progressBar: SeekBar private lateinit var progressIndicator: CircularProgressIndicator - private val hollowStar = R.drawable.ic_star_hollow - private val fullStar = R.drawable.ic_star_full + private val hollowStar = R.drawable.star_hollow_outline + private val fullStar = R.drawable.star_full_outline + private lateinit var hollowStarDrawable: Drawable + private lateinit var fullStarDrawable: Drawable private var _binding: CurrentPlayingBinding? = null // This property is only valid between onCreateView and @@ -270,6 +279,11 @@ class PlayerFragment : val ratingLinearLayout = view.findViewById(R.id.song_rating) if (!useFiveStarRating) ratingLinearLayout.isVisible = false + hollowStarDrawable = ResourcesCompat.getDrawable(resources, hollowStar, null)!! + fullStarDrawable = ResourcesCompat.getDrawable(resources, fullStar, null)!! + setLayerDrawableColors(hollowStarDrawable as LayerDrawable) + setLayerDrawableColors(fullStarDrawable as LayerDrawable) + fiveStar1ImageView.setOnClickListener { setSongRating(1) } fiveStar2ImageView.setOnClickListener { setSongRating(2) } fiveStar3ImageView.setOnClickListener { setSongRating(3) } @@ -1100,7 +1114,7 @@ class PlayerFragment : it.loadImage(albumArtImageView, currentSong, true, 0) } - updateSongRating() + updateSongRatingDisplay() } else { currentSong = null songTitleTextView.text = null @@ -1115,7 +1129,7 @@ class PlayerFragment : } } - updateSongRating() + updateSongRatingDisplay() updateMediaButtonActivationState() } @@ -1276,20 +1290,36 @@ class PlayerFragment : return false } - private fun updateSongRating() { + private fun updateSongRatingDisplay() { val rating = currentSong?.userRating ?: 0 - fiveStar1ImageView.setImageResource(if (rating > 0) fullStar else hollowStar) - fiveStar2ImageView.setImageResource(if (rating > 1) fullStar else hollowStar) - fiveStar3ImageView.setImageResource(if (rating > 2) fullStar else hollowStar) - fiveStar4ImageView.setImageResource(if (rating > 3) fullStar else hollowStar) - fiveStar5ImageView.setImageResource(if (rating > 4) fullStar else hollowStar) + fiveStar1ImageView.setImageDrawable(getStarForRating(rating, 0)) + fiveStar2ImageView.setImageDrawable(getStarForRating(rating, 1)) + fiveStar3ImageView.setImageDrawable(getStarForRating(rating, 2)) + fiveStar4ImageView.setImageDrawable(getStarForRating(rating, 3)) + fiveStar5ImageView.setImageDrawable(getStarForRating(rating, 4)) } + private fun getStarForRating(rating: Int, position: Int): Drawable { + return if (rating > position) fullStarDrawable else hollowStarDrawable + } + + private fun setLayerDrawableColors(drawable: LayerDrawable) { + drawable.apply { + getDrawable(0).setTint(requireContext().themeColor(RM.attr.colorSurface)) + getDrawable(1).setTint(requireContext().themeColor(RM.attr.colorAccent)) + } + } + + @ColorInt + fun Context.themeColor(@AttrRes attrRes: Int): Int = TypedValue() + .apply { theme.resolveAttribute(attrRes, this, true) } + .data + private fun setSongRating(rating: Int) { if (currentSong == null) return currentSong?.userRating = rating - updateSongRating() + updateSongRatingDisplay() RxBus.ratingSubmitter.onNext( RatingUpdate( diff --git a/ultrasonic/src/main/res/drawable/ic_star_full_outline.xml b/ultrasonic/src/main/res/drawable/ic_star_full_outline.xml new file mode 100644 index 00000000..c5c42cf3 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_star_full_outline.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/ultrasonic/src/main/res/drawable/ic_star_hollow_outline.xml b/ultrasonic/src/main/res/drawable/ic_star_hollow_outline.xml new file mode 100644 index 00000000..cc029c03 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_star_hollow_outline.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/ultrasonic/src/main/res/drawable/star_full_outline.xml b/ultrasonic/src/main/res/drawable/star_full_outline.xml new file mode 100644 index 00000000..5932d246 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/star_full_outline.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/ultrasonic/src/main/res/drawable/star_hollow_outline.xml b/ultrasonic/src/main/res/drawable/star_hollow_outline.xml new file mode 100644 index 00000000..ab2c6c3e --- /dev/null +++ b/ultrasonic/src/main/res/drawable/star_hollow_outline.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout-land/current_playing.xml b/ultrasonic/src/main/res/layout-land/current_playing.xml index 575f62c2..af77ad49 100644 --- a/ultrasonic/src/main/res/layout-land/current_playing.xml +++ b/ultrasonic/src/main/res/layout-land/current_playing.xml @@ -56,7 +56,7 @@ a:importantForAccessibility="no" a:padding="10dip" a:scaleType="fitCenter" - a:src="@drawable/ic_star_hollow" /> + a:src="@drawable/star_hollow_outline" /> + a:src="@drawable/star_hollow_outline" /> + a:src="@drawable/star_hollow_outline" /> + a:src="@drawable/star_hollow_outline" /> + a:src="@drawable/star_hollow_outline" /> diff --git a/ultrasonic/src/main/res/layout/current_playing.xml b/ultrasonic/src/main/res/layout/current_playing.xml index c73d42ce..7b674824 100644 --- a/ultrasonic/src/main/res/layout/current_playing.xml +++ b/ultrasonic/src/main/res/layout/current_playing.xml @@ -48,65 +48,65 @@ a:layout_width="0dip" a:layout_height="fill_parent" a:layout_weight="1" - a:padding="5dip" a:background="@android:color/transparent" a:focusable="false" a:gravity="center_vertical" + a:importantForAccessibility="no" + a:padding="5dip" a:scaleType="fitCenter" - a:src="@drawable/ic_star_hollow" - a:importantForAccessibility="no" /> + a:src="@drawable/star_hollow_outline" /> + a:src="@drawable/star_hollow_outline" /> + a:src="@drawable/star_hollow_outline" /> + a:src="@drawable/star_hollow_outline" /> + a:src="@drawable/star_hollow_outline" /> From 5755363f2e668a44183187a3429964acfb8c9520 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 29 Sep 2023 09:31:24 +0000 Subject: [PATCH 26/31] Update dependency io.reactivex.rxjava3:rxjava to v3.1.8 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e96393c3..6ed1929f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ robolectric = "4.10.3" timber = "5.0.1" fastScroll = "2.0.1" colorPicker = "2.2.4" -rxJava = "3.1.7" +rxJava = "3.1.8" rxAndroid = "3.0.2" multiType = "4.3.0" From 104df418cc6e6e2e611025e0f6e8046cff7d8063 Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Sat, 30 Sep 2023 08:01:56 +0000 Subject: [PATCH 27/31] Merge master to dev From e48c823de0aa62fd51c605d87b8547ea2be20ad6 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 30 Sep 2023 08:31:34 +0000 Subject: [PATCH 28/31] Update dependency com.android.tools.build:gradle to v8.1.2 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0560d51f..fb4af1c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ gradle = "8.1.1" navigation = "2.7.3" -gradlePlugin = "8.1.1" +gradlePlugin = "8.1.2" androidxcar = "1.2.0" androidxcore = "1.12.0" ktlint = "0.43.2" From 17260878ac3257c51bb0de5f860d9de06b996086 Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Sat, 14 Oct 2023 19:09:26 +0000 Subject: [PATCH 29/31] Refactor Koin, Scopes & Lifecycles --- .idea/codeStyles/Project.xml | 5 - ultrasonic/lint-baseline.xml | 44 --- ultrasonic/src/main/AndroidManifest.xml | 14 +- .../ultrasonic/activity/NavigationActivity.kt | 20 +- .../ultrasonic/adapters/ArtistRowBinder.kt | 29 +- .../ultrasonic/adapters/TrackViewHolder.kt | 5 - .../ultrasonic/data/ActiveServerProvider.kt | 49 ++-- .../{playback => data}/CachedDataSource.kt | 4 +- .../moire/ultrasonic/di/ApplicationModule.kt | 5 +- .../moire/ultrasonic/di/MediaPlayerModule.kt | 14 +- .../moire/ultrasonic/di/MusicServiceModule.kt | 8 - .../ultrasonic/fragment/BookmarksFragment.kt | 19 +- .../ultrasonic/fragment/EntryListFragment.kt | 73 +---- .../moire/ultrasonic/fragment/MainFragment.kt | 8 +- .../ultrasonic/fragment/MultiListFragment.kt | 6 +- .../ultrasonic/fragment/NowPlayingFragment.kt | 4 +- .../ultrasonic/fragment/PlayerFragment.kt | 62 ++--- .../ultrasonic/fragment/SearchFragment.kt | 138 ++-------- .../fragment/ServerSelectorFragment.kt | 4 +- .../ultrasonic/fragment/SettingsFragment.kt | 4 +- .../fragment/TrackCollectionFragment.kt | 208 +++++--------- .../fragment/legacy/PlaylistsFragment.kt | 33 +-- .../fragment/legacy/SharesFragment.kt | 49 ++-- .../ultrasonic/imageloader/ImageLoader.kt | 8 +- .../provider/AlbumArtContentProvider.kt | 4 +- .../provider/UltrasonicAppWidgetProvider.kt | 10 +- .../receiver/MediaButtonIntentReceiver.kt | 60 ---- .../receiver/UltrasonicIntentReceiver.kt | 11 +- .../ultrasonic/service/CachedMusicService.kt | 4 + .../ultrasonic/service/DownloadService.kt | 70 +++-- .../moire/ultrasonic/service/DownloadTask.kt | 2 +- .../MediaLibrarySessionCallback.kt} | 39 ++- .../service/MediaPlayerLifecycleSupport.kt | 26 +- .../ultrasonic/service/MediaPlayerManager.kt | 145 +++++++--- .../moire/ultrasonic/service/MusicService.kt | 2 + .../ultrasonic/service/OfflineMusicService.kt | 4 + .../{playback => service}/PlaybackService.kt | 15 +- .../moire/ultrasonic/service/PlaybackState.kt | 11 + .../service/PlaybackStateSerializer.kt | 17 +- .../ultrasonic/service/RESTMusicService.kt | 7 +- .../org/moire/ultrasonic/service/RxBus.kt | 5 +- .../ultrasonic/subsonic/DownloadHandler.kt | 260 ------------------ .../subsonic/ImageLoaderProvider.kt | 4 +- .../subsonic/NetworkAndStorageChecker.kt | 8 +- .../moire/ultrasonic/subsonic/ShareHandler.kt | 212 +++++++------- .../moire/ultrasonic/subsonic/VideoPlayer.kt | 4 +- .../org/moire/ultrasonic/util/CacheCleaner.kt | 12 +- .../org/moire/ultrasonic/util/Constants.kt | 1 + .../moire/ultrasonic/util/ContextMenuUtil.kt | 141 ++++++++++ .../ultrasonic/util/CoroutinePatterns.kt | 77 +++--- .../org/moire/ultrasonic/util/DownloadUtil.kt | 198 +++++++++++++ .../org/moire/ultrasonic/util/Settings.kt | 4 - .../org/moire/ultrasonic/util/Storage.kt | 2 +- .../moire/ultrasonic/util/TimeSpanPicker.kt | 2 +- .../kotlin/org/moire/ultrasonic/util/Util.kt | 60 +++- ultrasonic/src/main/res/values-cs/strings.xml | 4 - ultrasonic/src/main/res/values-de/strings.xml | 4 - ultrasonic/src/main/res/values-es/strings.xml | 4 - ultrasonic/src/main/res/values-fr/strings.xml | 4 - ultrasonic/src/main/res/values-gl/strings.xml | 2 - ultrasonic/src/main/res/values-hu/strings.xml | 4 - ultrasonic/src/main/res/values-it/strings.xml | 4 - ultrasonic/src/main/res/values-ja/strings.xml | 4 - .../src/main/res/values-nb-rNO/strings.xml | 4 - ultrasonic/src/main/res/values-nl/strings.xml | 4 - ultrasonic/src/main/res/values-pl/strings.xml | 4 - .../src/main/res/values-pt-rBR/strings.xml | 4 - ultrasonic/src/main/res/values-pt/strings.xml | 4 - ultrasonic/src/main/res/values-ru/strings.xml | 4 - .../src/main/res/values-zh-rCN/strings.xml | 4 - .../src/main/res/values-zh-rTW/strings.xml | 2 - .../src/main/res/values/setting_keys.xml | 1 - ultrasonic/src/main/res/values/strings.xml | 4 - ultrasonic/src/main/res/xml/settings.xml | 6 - 74 files changed, 1082 insertions(+), 1219 deletions(-) rename ultrasonic/src/main/kotlin/org/moire/ultrasonic/{playback => data}/CachedDataSource.kt (98%) delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/receiver/MediaButtonIntentReceiver.kt rename ultrasonic/src/main/kotlin/org/moire/ultrasonic/{playback/AutoMediaBrowserCallback.kt => service/MediaLibrarySessionCallback.kt} (97%) rename ultrasonic/src/main/kotlin/org/moire/ultrasonic/{playback => service}/PlaybackService.kt (97%) delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ContextMenuUtil.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/DownloadUtil.kt 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/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"/> - - - - - - - - - - - - - - - - - + + + + + - - - - - { + 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/BookmarksFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt index b959742e..1e7038a9 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/BookmarksFragment.kt @@ -17,7 +17,7 @@ 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.service.MediaPlayerManager /** * Lists the Bookmarks available on the server @@ -61,22 +61,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..1072790e 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) { @@ -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/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..0d7cca69 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt @@ -17,7 +17,6 @@ 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 @@ -26,6 +25,7 @@ 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,7 +33,6 @@ 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.Util @@ -42,11 +41,10 @@ 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() { 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 internal var listView: RecyclerView? = null internal lateinit var viewManager: LinearLayoutManager 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 52394da5..2e849731 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -45,7 +45,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 @@ -79,12 +78,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 @@ -106,6 +105,7 @@ import org.moire.ultrasonic.util.CommunicationError import org.moire.ultrasonic.util.ConfirmationDialog import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.Util.toast import org.moire.ultrasonic.util.toTrack import org.moire.ultrasonic.view.AutoRepeatButton import timber.log.Timber @@ -116,9 +116,9 @@ import timber.log.Timber */ @Suppress("LargeClass", "TooManyFunctions", "MagicNumber") class PlayerFragment : - Fragment(), + ScopeFragment(), GestureDetector.OnGestureListener, - KoinComponent, + KoinScopeComponent, CoroutineScope by CoroutineScope(Dispatchers.Main) { // Settings @@ -356,14 +356,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 -> { } @@ -410,7 +410,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) } @@ -457,9 +457,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) @@ -579,8 +579,7 @@ class PlayerFragment : equalizerMenuItem.isVisible = isEqualizerAvailable } - val mediaPlayerController = mediaPlayerManager - val track = mediaPlayerController.currentMediaItem?.toTrack() + val track = mediaPlayerManager.currentMediaItem?.toTrack() if (track != null) { currentSong = track @@ -600,7 +599,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) @@ -609,7 +608,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) @@ -707,8 +706,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 @@ -760,7 +758,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 -> { @@ -776,13 +774,12 @@ 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 + val playlist = mediaPlayerManager.playlist for (item in playlist) { val playlistEntry = item.toTrack() tracks.add(playlistEntry) @@ -790,8 +787,6 @@ class PlayerFragment : shareHandler.createShare( this, tracks = tracks, - swipe = null, - cancellationToken = cancellationToken, ) return true } @@ -804,8 +799,6 @@ class PlayerFragment : shareHandler.createShare( this, tracks, - swipe = null, - cancellationToken = cancellationToken ) return true } @@ -822,7 +815,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 @@ -835,7 +828,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( @@ -844,7 +837,7 @@ class PlayerFragment : resources.getString(R.string.download_playlist_error), CommunicationError.getErrorMessage(it) ) - Util.toast(UApp.applicationContext(), msg) + toast(msg) } } } @@ -958,7 +951,7 @@ class PlayerFragment : item?.mediaMetadata?.title ) - Util.toast(context, songRemoved) + toast(songRemoved) // Remove the item from the playlist mediaPlayerManager.removeFromPlaylist(pos) @@ -1059,15 +1052,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..048d3961 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 @@ -36,13 +36,12 @@ import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.fragment.FragmentTitle.Companion.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.Settings import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util.toast @@ -51,15 +50,12 @@ import org.moire.ultrasonic.util.Util.toast * Initiates a search on the media library and displays the results */ -class SearchFragment : MultiListFragment(), KoinComponent { +class SearchFragment : MultiListFragment(), KoinScopeComponent { private var searchResult: SearchResult? = null private var searchRefresh: 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() @@ -137,18 +133,6 @@ class SearchFragment : MultiListFragment(), KoinComponent { 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 @@ -253,7 +237,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 +272,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..36a8c264 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -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..ee8193a9 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -46,14 +46,16 @@ 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.view.SortOrder import org.moire.ultrasonic.view.ViewCapabilities import timber.log.Timber @@ -86,7 +88,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,7 +103,6 @@ open class TrackCollectionFragment( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - cancellationToken = CancellationToken() albumButtons = view.findViewById(R.id.menu_album) @@ -211,19 +211,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 +235,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 +283,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 = getSelectedTracks(), + additionalId = navArgs.id ) return true } @@ -294,46 +294,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,25 +328,65 @@ 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( + mediaPlayerManager.suggestedPlaylistName = navArgs.playlistName + mediaPlayerManager.addToPlaylist( songs = getAllSongs(), 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() + ) + } + + private fun downloadSelectedOrAllTracks(save: Boolean) { + var tracks = getSelectedTracks() + if (tracks.isEmpty()) tracks = getAllSongs() + + DownloadUtil.justDownload( + action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD, + fragment = this, + tracks = tracks + ) + } + + private fun playSelectedOrAllTracks( + insertionMode: MediaPlayerManager.InsertionMode + ) { + var tracks = getSelectedTracks() + if (tracks.isEmpty()) tracks = getAllSongs() + + mediaPlayerManager.playTracksAndToast( + fragment = this, + insertionMode = insertionMode, + tracks = tracks + ) + } + + private fun deleteSelectedTracks() { + DownloadUtil.justDownload( + action = DownloadAction.DELETE, + fragment = this, + tracks = getSelectedTracks() + ) + } @Suppress("UNCHECKED_CAST") private fun getAllSongs(): List { @@ -403,7 +407,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 +435,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") @@ -606,48 +579,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/PlaylistsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/PlaylistsFragment.kt index ebffc3cc..91d74642 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,11 +25,11 @@ import android.widget.CheckBox import android.widget.EditText import android.widget.ListView import android.widget.TextView -import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import java.util.Locale -import org.koin.core.component.KoinComponent +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 @@ -39,12 +39,12 @@ import org.moire.ultrasonic.domain.Playlist import org.moire.ultrasonic.fragment.FragmentTitle.Companion.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.DownloadAction +import org.moire.ultrasonic.util.DownloadUtil import org.moire.ultrasonic.util.FragmentBackgroundTask import org.moire.ultrasonic.util.InfoDialog import org.moire.ultrasonic.util.LoadingTask @@ -56,14 +56,12 @@ import org.moire.ultrasonic.util.Util.toast * * TODO: This file has been converted from Java, but not modernized yet. */ -class PlaylistsFragment : Fragment(), KoinComponent { +class PlaylistsFragment : ScopeFragment(), KoinScopeComponent { private var refreshPlaylistsListView: 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?) { @@ -115,7 +113,8 @@ class PlaylistsFragment : Fragment(), KoinComponent { override fun doInBackground(): List { val musicService = getMusicService() val playlists = musicService.getPlaylists(refresh) - if (!isOffline()) CacheCleaner().cleanPlaylists(playlists) + val cacheCleaner: CacheCleaner by inject() + if (!isOffline()) cacheCleaner.cleanPlaylists(playlists) return playlists } @@ -147,8 +146,8 @@ class PlaylistsFragment : Fragment(), KoinComponent { 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 +156,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 +166,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, @@ -227,7 +226,6 @@ class PlaylistsFragment : Fragment(), KoinComponent { playlistAdapter!!.remove(playlist) playlistAdapter!!.notifyDataSetChanged() toast( - context, resources.getString(R.string.menu_deleted_playlist, playlist.name) ) } @@ -246,7 +244,7 @@ class PlaylistsFragment : Fragment(), KoinComponent { ), getErrorMessage(error) ) - toast(context, msg, false) + toast(msg, false) } }.execute() }.setNegativeButton(R.string.common_cancel, null).show() @@ -310,7 +308,6 @@ class PlaylistsFragment : Fragment(), KoinComponent { override fun done(result: Any?) { load(true) toast( - context, resources.getString(R.string.playlist_updated_info, playlist.name) ) } @@ -329,7 +326,7 @@ class PlaylistsFragment : Fragment(), KoinComponent { ), getErrorMessage(error) ) - toast(context, msg, false) + toast(msg, false) } }.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..981cb3b9 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,11 +24,11 @@ import android.widget.CheckBox import android.widget.EditText import android.widget.ListView import android.widget.TextView -import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import java.util.Locale -import org.koin.core.component.KoinComponent +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 @@ -38,14 +38,15 @@ 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.DownloadAction +import org.moire.ultrasonic.util.DownloadUtil import org.moire.ultrasonic.util.FragmentBackgroundTask import org.moire.ultrasonic.util.LoadingTask import org.moire.ultrasonic.util.TimeSpanPicker import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.Util.toast import org.moire.ultrasonic.view.ShareAdapter /** @@ -53,12 +54,12 @@ import org.moire.ultrasonic.view.ShareAdapter * * TODO: This file has been converted from Java, but not modernized yet. */ -class SharesFragment : Fragment(), KoinComponent { +class SharesFragment : ScopeFragment(), KoinScopeComponent { private var refreshSharesListView: SwipeRefreshLayout? = null private var sharesListView: ListView? = null private var emptyTextView: View? = null private var shareAdapter: ShareAdapter? = null - private val downloadHandler: DownloadHandler by inject() + private val mediaPlayerManager: MediaPlayerManager by inject() private var cancellationToken: CancellationToken? = null override fun onCreate(savedInstanceState: Bundle?) { Util.applyTheme(this.context) @@ -133,8 +134,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 +144,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 +154,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 +164,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, ) } @@ -214,8 +213,7 @@ class SharesFragment : Fragment(), KoinComponent { override fun done(result: Any?) { shareAdapter!!.remove(share) shareAdapter!!.notifyDataSetChanged() - Util.toast( - context, + toast( resources.getString(R.string.menu_deleted_share, share.name) ) } @@ -237,7 +235,7 @@ class SharesFragment : Fragment(), KoinComponent { getErrorMessage(error) ) } - Util.toast(context, msg, false) + toast(msg, false) } }.execute() }.setNegativeButton(R.string.common_cancel, null).show() @@ -315,8 +313,7 @@ class SharesFragment : Fragment(), KoinComponent { override fun done(result: Any?) { load(true) - Util.toast( - context, + toast( resources.getString(R.string.playlist_updated_info, share.name) ) } @@ -338,7 +335,7 @@ class SharesFragment : Fragment(), KoinComponent { getErrorMessage(error) ) } - Util.toast(context, msg, false) + toast(msg, false) } }.execute() } 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..a63417ca 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,17 @@ 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) { + 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/provider/AlbumArtContentProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/AlbumArtContentProvider.kt index 51b3ed52..026fb22f 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,10 @@ 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. 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..017c0151 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt @@ -315,6 +315,10 @@ 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 { return musicService.updateJukeboxPlaylist(ids) 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..8eeb60ba 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") 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..483220b2 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) // 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/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 ee203b41..e2525785 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 d07b4c52..17eedd37 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,11 @@ 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.lifecycle.lifecycleScope import androidx.media3.common.C import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem @@ -29,17 +30,20 @@ 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.launchWithToast import org.moire.ultrasonic.util.toMediaItem import org.moire.ultrasonic.util.toTrack import timber.log.Timber @@ -56,12 +60,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 @@ -73,8 +74,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 @@ -145,12 +148,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 @@ -199,7 +201,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() @@ -246,10 +248,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() @@ -320,17 +322,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, @@ -474,6 +474,80 @@ 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 + ) { + val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope + + scope.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 @@ -649,21 +723,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 @@ -676,7 +735,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) } @@ -724,7 +783,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) } @@ -768,9 +827,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 } } @@ -784,8 +842,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..e80ea8ac 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt @@ -138,6 +138,8 @@ 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 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..d9bd3100 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt @@ -335,6 +335,10 @@ class OfflineMusicService : MusicService, KoinComponent { } } + override fun isJukeboxAvailable(): Boolean { + return false + } + @Throws(Exception::class) override fun updateJukeboxPlaylist(ids: List?): JukeboxStatus { throw OfflineException("Jukebox not available in offline mode") 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 9ec36400..34f6acc5 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..f6071cb6 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,6 +504,11 @@ 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? 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..daf0eb98 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 @@ -20,8 +19,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 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..2dd7f18f 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,21 +50,26 @@ 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.ShareOnServer && shareDetails.Entries.size == 1) + return@withContext null if (shareDetails.Entries.isEmpty()) { additionalId.ifNotNull { ids.add(it) @@ -86,78 +90,80 @@ class ShareHandler(val context: Context) { 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) - ) - ) + // Return the share + shares[0] + } 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, additionalId: String? = null ) { val askForDetails = Settings.shouldAskForShareDetails val shareDetails = ShareDetails() shareDetails.Entries = tracks if (askForDetails) { - showDialog(fragment, shareDetails, swipe, cancellationToken, additionalId) + showDialog(fragment, shareDetails, additionalId) } else { 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) @@ -175,18 +181,57 @@ class ShareHandler(val context: Context) { textViewExpiration = layout.findViewById(R.id.textViewExpiration) as TextView } + // Handle the visibility based on shareDetails.Entries size if (shareDetails.Entries.size == 1) { - // For single songs the sharing may be done by text only shareOnServerCheckBox?.setOnCheckedChangeListener { _, _ -> updateVisibility() } - shareOnServerCheckBox?.isChecked = Settings.shareOnServer } else { shareOnServerCheckBox?.isVisible = false } + updateVisibility() + // Set up the dialog builder + val builder = makeDialogBuilder(fragment, shareDetails, additionalId, layout) + + // Initialize UI components with default values + setupDefaultValues() + + 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() + val timeSpanType = split[1] + if (timeSpanAmount > 0) { + noExpirationCheckBox!!.isChecked = false + timeSpanPicker!!.isEnabled = true + timeSpanPicker!!.setTimeSpanAmount(timeSpanAmount.toString()) + timeSpanPicker!!.timeSpanType = timeSpanType + } else { + noExpirationCheckBox!!.isChecked = true + timeSpanPicker!!.isEnabled = false + } + } else { + noExpirationCheckBox!!.isChecked = true + timeSpanPicker!!.isEnabled = false + } + shareDescription!!.setText(defaultDescription) + } + + 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) @@ -214,7 +259,7 @@ class ShareHandler(val context: Context) { Settings.shareOnServer = shareDetails.ShareOnServer } - share(fragment, shareDetails, swipe, cancellationToken, additionalId) + share(fragment, shareDetails, additionalId) } builder.setNegativeButton(R.string.common_cancel) { dialog, _ -> @@ -224,35 +269,12 @@ class ShareHandler(val context: Context) { builder.setView(layout) builder.setCancelable(true) - timeSpanPicker!!.setTimeSpanDisableText(context.resources.getString(R.string.no_expiration)) + // Set up the timeSpanPicker + timeSpanPicker!!.setTimeSpanDisableText(getString(R.string.no_expiration)) noExpirationCheckBox!!.setOnCheckedChangeListener { _, b -> timeSpanPicker!!.isEnabled = !b } - - val defaultDescription = Settings.defaultShareDescription - val timeSpan = Settings.defaultShareExpiration - - val split = pattern.split(timeSpan) - if (split.size == 2) { - val timeSpanAmount = split[0].toInt() - val timeSpanType = split[1] - if (timeSpanAmount > 0) { - noExpirationCheckBox!!.isChecked = false - timeSpanPicker!!.isEnabled = true - timeSpanPicker!!.setTimeSpanAmount(timeSpanAmount.toString()) - timeSpanPicker!!.timeSpanType = timeSpanType - } else { - noExpirationCheckBox!!.isChecked = true - timeSpanPicker!!.isEnabled = false - } - } else { - noExpirationCheckBox!!.isChecked = true - timeSpanPicker!!.isEnabled = false - } - - shareDescription!!.setText(defaultDescription) - builder.create() - builder.show() + 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/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..220ca6c1 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ContextMenuUtil.kt @@ -0,0 +1,141 @@ +/* + * 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, + additionalId = null + ) + } + 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..08b9f07c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt @@ -9,14 +9,12 @@ package org.moire.ultrasonic.util import android.os.Handler import android.os.Looper -import androidx.fragment.app.Fragment 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 timber.log.Timber @@ -30,53 +28,50 @@ object CoroutinePatterns { } } -fun CoroutineScope.executeTaskWithToast( - task: suspend CoroutineScope.() -> Unit, - successString: () -> String? -): Job { +fun CoroutineScope.launchWithToast( + block: suspend CoroutineScope.() -> String? +) { // Launch the Job - val job = launch(CoroutinePatterns.loggingExceptionHandler, block = task) + val deferred = async(CoroutinePatterns.loggingExceptionHandler, 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) } 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() + val successString = toastString ?: deferred.await() + if (successString != null) { + Util.toast(successString, UApp.applicationContext()) + } } } } + +// 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..8c78b73f --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/DownloadUtil.kt @@ -0,0 +1,198 @@ +/* + * 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 androidx.lifecycle.lifecycleScope +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 + ) { + + val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope + + // Launch the Job + scope.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/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index 5b179ec1..c6e69c8f 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/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..90316aaf 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPicker.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPicker.kt @@ -31,7 +31,7 @@ class TimeSpanPicker(private var mContext: Context, attrs: AttributeSet?, defSty AdapterView.OnItemSelectedListener { private val timeSpanEditText: EditText private val timeSpanSpinner: Spinner - private val timeSpanDisableCheckbox: CheckBox + val timeSpanDisableCheckbox: CheckBox private var mTimeSpan: Long = -1L private val adapter: ArrayAdapter private val dialog: View 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 83ecb11b..fae3ef41 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 @@ -37,12 +36,15 @@ import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.AnyRes +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 @@ -107,33 +109,41 @@ 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( @@ -153,6 +163,22 @@ object Util { } } + 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: @@ -479,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 @@ -504,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) } } @@ -557,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) } } @@ -569,6 +599,7 @@ object Util { } } } + fun postNotificationIfPermitted( notificationManagerCompat: NotificationManagerCompat, id: Int, @@ -583,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 @@ -838,4 +869,11 @@ object Util { Timber.d("${it.key}: ${it.value}") } } + + fun Fragment.navigateToCurrent() { + if (Settings.shouldTransitionOnPlayback) { + findNavController().popBackStack(R.id.playerFragment, true) + findNavController().navigate(R.id.playerFragment) + } + } } diff --git a/ultrasonic/src/main/res/values-cs/strings.xml b/ultrasonic/src/main/res/values-cs/strings.xml index 140946b6..3cf8675c 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 bc954811..46aa76fc 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 6583680d..28991f37 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 diff --git a/ultrasonic/src/main/res/values-fr/strings.xml b/ultrasonic/src/main/res/values-fr/strings.xml index 0f2f8e74..f2578bef 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 cca3e6db..0bfd09f0 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 51274e09..8641b570 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 7a2c4406..81719af4 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 af539bfd..5f34a530 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 f62deae6..6610ad1c 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 70a42293..43c96885 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 834ea509..ad5cd34f 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 0c750c2f..38756d33 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 2410948c..625d81fb 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 0e135e5e..20356d83 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 04488224..4ab4279d 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 @@ -234,8 +232,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 diff --git a/ultrasonic/src/main/res/xml/settings.xml b/ultrasonic/src/main/res/xml/settings.xml index 692117c5..bb5d6fdd 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"/> - Date: Wed, 18 Oct 2023 10:19:10 +0000 Subject: [PATCH 30/31] Migrate remaining Java Code and modernize it --- gradle/libs.versions.toml | 2 + ultrasonic/detekt-baseline.xml | 16 +- ultrasonic/src/main/AndroidManifest.xml | 25 +- .../ultrasonic/fragment/ChatFragment.java | 297 ------------------ .../moire/ultrasonic/util/BackgroundTask.java | 72 ----- .../util/FragmentBackgroundTask.java | 89 ------ .../moire/ultrasonic/util/LoadingTask.java | 66 ---- .../ultrasonic/util/ProgressListener.java | 29 -- .../moire/ultrasonic/util/ShareDetails.java | 16 - .../ultrasonic/util/TimeSpanPreference.java | 38 --- .../ultrasonic/fragment/AboutFragment.kt | 3 +- .../ultrasonic/fragment/AlbumListFragment.kt | 16 +- .../ultrasonic/fragment/ArtistListFragment.kt | 2 +- .../ultrasonic/fragment/BookmarksFragment.kt | 11 +- .../ultrasonic/fragment/EntryListFragment.kt | 4 +- .../ultrasonic/fragment/EqualizerFragment.kt | 2 +- .../ultrasonic/fragment/FragmentTitle.kt | 34 +- .../ultrasonic/fragment/MultiListFragment.kt | 27 +- .../ultrasonic/fragment/PlayerFragment.kt | 14 +- .../ultrasonic/fragment/SearchFragment.kt | 35 +-- .../ultrasonic/fragment/SettingsFragment.kt | 2 +- .../fragment/TrackCollectionFragment.kt | 49 +-- .../fragment/legacy/ChatFragment.kt | 214 +++++++++++++ .../fragment/legacy/LyricsFragment.kt | 55 ++-- .../fragment/legacy/PlaylistsFragment.kt | 180 +++++------ .../fragment/legacy/PodcastFragment.kt | 72 ++--- .../fragment/legacy/SelectGenreFragment.kt | 73 ++--- .../fragment/legacy/SharesFragment.kt | 196 +++++------- .../ultrasonic/imageloader/ImageLoader.kt | 1 + .../moire/ultrasonic/model/ChatViewModel.kt | 31 ++ .../moire/ultrasonic/model/SearchListModel.kt | 5 +- .../provider/AlbumArtContentProvider.kt | 1 + .../ultrasonic/service/DownloadService.kt | 16 +- .../moire/ultrasonic/service/DownloadTask.kt | 2 +- .../ultrasonic/service/MediaPlayerManager.kt | 4 +- .../subsonic/ImageLoaderProvider.kt | 3 +- .../moire/ultrasonic/subsonic/ShareHandler.kt | 60 ++-- .../ultrasonic/util/CancellationToken.kt | 4 +- .../moire/ultrasonic/util/ContextMenuUtil.kt | 3 +- .../ultrasonic/util/CoroutinePatterns.kt | 36 ++- .../org/moire/ultrasonic/util/DownloadUtil.kt | 5 +- .../ultrasonic/util/RefreshableFragment.kt | 14 + .../org/moire/ultrasonic/util/ShareDetails.kt | 12 + .../moire/ultrasonic/util/TimeSpanPicker.kt | 6 +- .../ultrasonic/util/TimeSpanPreference.kt | 27 ++ .../kotlin/org/moire/ultrasonic/util/Util.kt | 36 +-- .../src/main/res/layout/filter_button_bar.xml | 2 +- .../src/main/res/layout/navigation_header.xml | 1 + .../src/main/res/layout/share_details.xml | 71 +++-- .../src/main/res/layout/time_span_dialog.xml | 11 +- .../main/res/navigation/navigation_graph.xml | 2 +- ultrasonic/src/main/res/values/strings.xml | 1 + 52 files changed, 787 insertions(+), 1206 deletions(-) delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/fragment/ChatFragment.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/BackgroundTask.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/FragmentBackgroundTask.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/LoadingTask.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/ProgressListener.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/ShareDetails.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/TimeSpanPreference.java create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/legacy/ChatFragment.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ChatViewModel.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/RefreshableFragment.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ShareDetails.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPreference.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb4af1c9..0cab93bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ media3 = "1.1.1" androidSupport = "1.7.0" materialDesign = "1.9.0" constraintLayout = "2.1.4" +activity = "1.8.0" multidex = "2.0.1" room = "2.5.2" kotlin = "1.9.10" @@ -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/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/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index d8134933..f8870756 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -22,20 +22,22 @@ android:xlargeScreens="true"/> + 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/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 1e7038a9..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.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 } 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 1072790e..4b1eed22 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EntryListFragment.kt @@ -73,7 +73,7 @@ abstract class EntryListFragment : MultiListFragment(), Koi currentSetting.musicFolderId = it.id serverSettingsModel.updateItem(currentSetting) } - listModel.refresh(refreshListView!!) + listModel.refresh(swipeRefresh!!) } viewAdapter.register( @@ -90,7 +90,7 @@ abstract class EntryListFragment : MultiListFragment(), Koi * 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) 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/MultiListFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/MultiListFragment.kt index 0d7cca69..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 @@ -23,7 +21,6 @@ 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 @@ -34,18 +31,18 @@ import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.model.GenericListModel import org.moire.ultrasonic.model.ServerSettingsModel 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 : ScopeFragment() { +abstract class MultiListFragment : ScopeFragment(), RefreshableFragment { internal val activeServerProvider: ActiveServerProvider by inject() internal val serverSettingsModel: ServerSettingsModel by viewModel() internal val imageLoaderProvider: ImageLoaderProvider 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 @@ -95,16 +92,6 @@ abstract class MultiListFragment : ScopeFragment() { */ 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( @@ -122,7 +109,7 @@ abstract class MultiListFragment : ScopeFragment() { * 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) } @@ -130,9 +117,9 @@ abstract class MultiListFragment : ScopeFragment() { 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/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 2e849731..a7249b40 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -92,7 +92,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 @@ -778,11 +778,8 @@ class PlayerFragment : return true } R.id.menu_item_share -> { - val tracks: MutableList = ArrayList() - val playlist = mediaPlayerManager.playlist - for (item in playlist) { - val playlistEntry = item.toTrack() - tracks.add(playlistEntry) + val tracks = mediaPlayerManager.playlist.map { + it.toTrack() } shareHandler.createShare( this, @@ -793,12 +790,9 @@ class PlayerFragment : R.id.menu_item_share_song -> { if (track == null) return true - val tracks: MutableList = ArrayList() - tracks.add(track) - shareHandler.createShare( this, - tracks, + listOf(track), ) return true } 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 048d3961..9074fe16 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -34,39 +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.MediaPlayerManager 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(), KoinScopeComponent { +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 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( @@ -79,8 +72,8 @@ class SearchFragment : MultiListFragment(), KoinScopeComponent { } } - searchRefresh = view.findViewById(R.id.swipe_refresh_view) - searchRefresh!!.isEnabled = false + swipeRefresh = view.findViewById(R.id.swipe_refresh_view) + swipeRefresh!!.isEnabled = false registerForContextMenu(listView!!) @@ -129,17 +122,17 @@ class SearchFragment : MultiListFragment(), KoinScopeComponent { override fun onDestroyView() { Util.hideKeyboard(activity) - cancellationToken?.cancel() super.onDestroyView() } 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() } } 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 36a8c264..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 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 ee8193a9..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,7 +41,7 @@ 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 @@ -56,6 +56,7 @@ import org.moire.ultrasonic.util.EntryByDiscAndTrackComparator import org.moire.ultrasonic.util.Settings 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 @@ -107,8 +108,8 @@ open class TrackCollectionFragment( albumButtons = view.findViewById(R.id.menu_album) // Setup refresh handler - refreshListView = view.findViewById(refreshListId) - refreshListView?.setOnRefreshListener { + swipeRefresh = view.findViewById(refreshListId) + swipeRefresh?.setOnRefreshListener { handleRefresh() } @@ -284,7 +285,7 @@ open class TrackCollectionFragment( } else if (item.itemId == R.id.menu_item_share) { shareHandler.createShare( fragment = this@TrackCollectionFragment, - tracks = getSelectedTracks(), + tracks = getSelectedOrAllTracks(), additionalId = navArgs.id ) return true @@ -338,7 +339,7 @@ open class TrackCollectionFragment( } else { mediaPlayerManager.suggestedPlaylistName = navArgs.playlistName mediaPlayerManager.addToPlaylist( - songs = getAllSongs(), + songs = getAllTracks(), insertionMode = insertionMode, autoPlay = (insertionMode != MediaPlayerManager.InsertionMode.APPEND), shuffle = shuffle @@ -357,26 +358,20 @@ open class TrackCollectionFragment( } private fun downloadSelectedOrAllTracks(save: Boolean) { - var tracks = getSelectedTracks() - if (tracks.isEmpty()) tracks = getAllSongs() - DownloadUtil.justDownload( action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD, fragment = this, - tracks = tracks + tracks = getSelectedOrAllTracks() ) } private fun playSelectedOrAllTracks( insertionMode: MediaPlayerManager.InsertionMode ) { - var tracks = getSelectedTracks() - if (tracks.isEmpty()) tracks = getAllSongs() - mediaPlayerManager.playTracksAndToast( fragment = this, insertionMode = insertionMode, - tracks = tracks + tracks = getSelectedOrAllTracks() ) } @@ -388,13 +383,6 @@ open class TrackCollectionFragment( ) } - @Suppress("UNCHECKED_CAST") - private fun getAllSongs(): List { - return viewAdapter.getCurrentList().filter { - it is Track && !it.isDirectory - } as List - } - private fun selectAllOrNone() { val someUnselected = viewAdapter.selectedSet.size < childCount selectAll(someUnselected) @@ -503,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) } @@ -534,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!!) @@ -570,7 +573,7 @@ open class TrackCollectionFragment( } } - refreshListView?.isRefreshing = false + swipeRefresh?.isRefreshing = false } return listModel.currentList } 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 91d74642..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,48 +25,46 @@ import android.widget.CheckBox import android.widget.EditText import android.widget.ListView import android.widget.TextView +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import java.util.Locale +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.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.DownloadAction import org.moire.ultrasonic.util.DownloadUtil -import org.moire.ultrasonic.util.FragmentBackgroundTask 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 : ScopeFragment(), KoinScopeComponent { - 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 var cancellationToken: CancellationToken? = null - override fun onCreate(savedInstanceState: Bundle?) { - applyTheme(this.context) super.onCreate(savedInstanceState) + applyTheme(requireContext()) } override fun onCreateView( @@ -78,17 +76,16 @@ class PlaylistsFragment : ScopeFragment(), KoinScopeComponent { } 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, ) @@ -99,33 +96,25 @@ class PlaylistsFragment : ScopeFragment(), KoinScopeComponent { 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) - val cacheCleaner: CacheCleaner by inject() - 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?) { @@ -143,7 +132,7 @@ class PlaylistsFragment : ScopeFragment(), KoinScopeComponent { 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 -> { DownloadUtil.justDownload( @@ -214,57 +203,45 @@ class PlaylistsFragment : ScopeFragment(), KoinScopeComponent { .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( 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(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) @@ -293,42 +270,31 @@ class PlaylistsFragment : ScopeFragment(), KoinScopeComponent { 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( 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(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 981cb3b9..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,29 +24,29 @@ import android.widget.CheckBox import android.widget.EditText import android.widget.ListView import android.widget.TextView +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import java.util.Locale +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.util.BackgroundTask -import org.moire.ultrasonic.util.CancellationToken import org.moire.ultrasonic.util.DownloadAction import org.moire.ultrasonic.util.DownloadUtil -import org.moire.ultrasonic.util.FragmentBackgroundTask -import org.moire.ultrasonic.util.LoadingTask +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 /** @@ -54,16 +54,16 @@ import org.moire.ultrasonic.view.ShareAdapter * * TODO: This file has been converted from Java, but not modernized yet. */ -class SharesFragment : ScopeFragment(), KoinScopeComponent { - 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 mediaPlayerManager: MediaPlayerManager by inject() - private var cancellationToken: CancellationToken? = null + override fun onCreate(savedInstanceState: Bundle?) { - Util.applyTheme(this.context) super.onCreate(savedInstanceState) + Util.applyTheme(requireContext()) } override fun onCreateView( @@ -75,48 +75,41 @@ class SharesFragment : ScopeFragment(), KoinScopeComponent { } 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( @@ -202,45 +195,34 @@ class SharesFragment : ScopeFragment(), KoinScopeComponent { .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() - toast( - 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) - ) - } - toast(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) @@ -255,18 +237,18 @@ class SharesFragment : ScopeFragment(), KoinScopeComponent { ( 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) @@ -297,49 +279,31 @@ class SharesFragment : ScopeFragment(), KoinScopeComponent { 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) - toast( - 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) - ) - } - toast(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 a63417ca..bb7a76c0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt @@ -197,6 +197,7 @@ class ImageLoader( * Download a cover art file of a Track and cache it on disk */ fun downloadCoverArt(track: Track) { + if (track.coverArt == null) return downloadCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track)) } 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 026fb22f..d55de716 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/AlbumArtContentProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/AlbumArtContentProvider.kt @@ -58,6 +58,7 @@ class AlbumArtContentProvider : ContentProvider(), KoinComponent { 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.downloadCoverArt(parts[0], albumArtFile) } 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 8eeb60ba..0bf444a5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt @@ -510,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 483220b2..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.downloadCoverArt(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/MediaPlayerManager.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt index 17eedd37..6671a0f3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt @@ -11,7 +11,6 @@ import android.os.Handler import android.os.Looper import androidx.annotation.IntRange import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope import androidx.media3.common.C import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem @@ -502,9 +501,8 @@ class MediaPlayerManager( shuffle: Boolean = false, isArtist: Boolean = false ) { - val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope - scope.launchWithToast { + fragment.launchWithToast { val list: List = tracks.ifEmpty { 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 daf0eb98..b70852f1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt @@ -12,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 @@ -55,7 +56,7 @@ class ImageLoaderProvider : } 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/ShareHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt index 2dd7f18f..ce2f0eaa 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt @@ -68,14 +68,14 @@ class ShareHandler { val ids: MutableList = ArrayList() - if (!shareDetails.ShareOnServer && shareDetails.Entries.size == 1) + if (!shareDetails.shareOnServer && shareDetails.entries.size == 1) return@withContext null - if (shareDetails.Entries.isEmpty()) { + if (shareDetails.entries.isEmpty()) { additionalId.ifNotNull { ids.add(it) } } else { - for ((id) in shareDetails.Entries) { + for ((id) in shareDetails.entries) { ids.add(id) } } @@ -83,15 +83,21 @@ class ShareHandler { 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) + val shares = musicService.createShare( + ids = ids, + description = shareDetails.description, + expires = timeInMillis + ) // Return the share - shares[0] + if (shares.isNotEmpty()) + shares[0] + else + null } catch (ignored: Exception) { null } @@ -120,15 +126,15 @@ class ShareHandler { val textBuilder = StringBuilder() textBuilder.appendLine(Settings.shareGreeting) - if (!shareDetails.Entries[0].title.isNullOrEmpty()) + 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()) + .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()) + .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) + .append(": ").append(shareDetails.entries[0].album) intent.putExtra(Intent.EXTRA_TEXT, textBuilder.toString()) } @@ -144,17 +150,17 @@ class ShareHandler { fun createShare( fragment: Fragment, - tracks: List?, + 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, additionalId) } else { - shareDetails.Description = Settings.defaultShareDescription - shareDetails.Expiration = System.currentTimeMillis() + + shareDetails.description = Settings.defaultShareDescription + shareDetails.expiration = System.currentTimeMillis() + Settings.defaultShareExpirationInMillis share(fragment, shareDetails, additionalId) } @@ -177,12 +183,12 @@ class ShareHandler { 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 } // Handle the visibility based on shareDetails.Entries size - if (shareDetails.Entries.size == 1) { + if (shareDetails.entries.size == 1) { shareOnServerCheckBox?.setOnCheckedChangeListener { _, _ -> updateVisibility() } @@ -238,11 +244,11 @@ class ShareHandler { builder.setPositiveButton(R.string.menu_share) { _, _ -> if (!noExpirationCheckBox!!.isChecked) { val timeSpan: Long = timeSpanPicker!!.getTimeSpan() - shareDetails.Expiration = System.currentTimeMillis() + timeSpan + shareDetails.expiration = System.currentTimeMillis() + timeSpan } - shareDetails.Description = shareDescription!!.text.toString() - shareDetails.ShareOnServer = shareOnServerCheckBox!!.isChecked + shareDetails.description = shareDescription!!.text.toString() + shareDetails.shareOnServer = shareOnServerCheckBox!!.isChecked if (hideDialogCheckBox!!.isChecked) { Settings.shouldAskForShareDetails = false @@ -255,8 +261,8 @@ class ShareHandler { if (!noExpirationCheckBox!!.isChecked && timeSpanAmount > 0) String.format("%d:%s", timeSpanAmount, timeSpanType) else "" - Settings.defaultShareDescription = shareDetails.Description - Settings.shareOnServer = shareDetails.ShareOnServer + Settings.defaultShareDescription = shareDetails.description!! + Settings.shareOnServer = shareDetails.shareOnServer } share(fragment, shareDetails, additionalId) 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/ContextMenuUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ContextMenuUtil.kt index 220ca6c1..420b4c90 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ContextMenuUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ContextMenuUtil.kt @@ -130,8 +130,7 @@ object ContextMenuUtil : KoinComponent { val shareHandler: ShareHandler by inject() shareHandler.createShare( fragment = fragment, - tracks = tracks, - additionalId = null + tracks = tracks ) } else -> return false 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 08b9f07c..dad0ae39 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CoroutinePatterns.kt @@ -9,13 +9,16 @@ 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.async import kotlinx.coroutines.launch -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 { @@ -28,24 +31,43 @@ object CoroutinePatterns { } } -fun CoroutineScope.launchWithToast( +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 deferred = async(CoroutinePatterns.loggingExceptionHandler, block = block) + val deferred = scope.async(block = block) // Setup a handler when the job is done deferred.invokeOnCompletion { val toastString = if (it != null && it !is CancellationException) { - CommunicationError.getErrorMessage(it) + getErrorMessage(it) } else { null } - - launch(Dispatchers.Main) { + scope.launch(Dispatchers.Main) { val successString = toastString ?: deferred.await() if (successString != null) { - Util.toast(successString, UApp.applicationContext()) + this@launchWithToast.toast(successString) } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/DownloadUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/DownloadUtil.kt index 8c78b73f..00ce9acd 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/DownloadUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/DownloadUtil.kt @@ -8,7 +8,6 @@ package org.moire.ultrasonic.util import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope import java.util.LinkedList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -36,10 +35,8 @@ object DownloadUtil { tracks: List? = null ) { - val scope = fragment.activity?.lifecycleScope ?: fragment.lifecycleScope - // Launch the Job - scope.launchWithToast { + fragment.launchWithToast { val tracksToDownload: List = tracks ?: getTracksFromServerAsync(isArtist, id!!, isDirectory, name, isShare) 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/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/TimeSpanPicker.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPicker.kt index 90316aaf..33b66540 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPicker.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeSpanPicker.kt @@ -31,7 +31,7 @@ class TimeSpanPicker(private var mContext: Context, attrs: AttributeSet?, defSty AdapterView.OnItemSelectedListener { private val timeSpanEditText: EditText private val timeSpanSpinner: Spinner - val timeSpanDisableCheckbox: CheckBox + private val timeSpanDisableCheckbox: CheckBox private var mTimeSpan: Long = -1L private val adapter: ArrayAdapter private val dialog: View @@ -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 fae3ef41..a21df27f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -30,7 +30,6 @@ import android.os.Build import android.os.Environment import android.text.TextUtils import android.util.DisplayMetrics -import android.view.Gravity import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.ComponentActivity @@ -87,7 +86,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 { @@ -145,19 +143,11 @@ object Util { // 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) } @@ -277,15 +267,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) } @@ -299,7 +290,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 @@ -319,15 +310,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) } } 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/strings.xml b/ultrasonic/src/main/res/values/strings.xml index 4ab4279d..98c0263f 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -356,6 +356,7 @@ Save as default Comment Time To Expiration + Options \"%s\" was removed from playlist Share Playlist Share Current Song From 69fc9b955ad406b7ce28995c61db94d1fccbb9e7 Mon Sep 17 00:00:00 2001 From: tzugen Date: Wed, 18 Oct 2023 12:48:32 +0200 Subject: [PATCH 31/31] RC 4.8.0 --- fastlane/metadata/android/en-US/changelogs/130.txt | 12 ++++-------- ultrasonic/build.gradle | 4 ++-- 2 files changed, 6 insertions(+), 10 deletions(-) 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/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