diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md index 6e1d657a..9e639a38 100644 --- a/.gitlab/merge_request_templates/Default.md +++ b/.gitlab/merge_request_templates/Default.md @@ -1,4 +1,3 @@ - ---------------------------------------------------------------------------- @@ -7,8 +6,6 @@ - [ ] I ran `./gradlew -Pqc ktlintCheck`, `./gradlew -Pqc detekt` and `./gradlew :ultrasonic:lintRelease` and no problems found. See [CONTRIBUTING](CONTRIBUTING.md) for further information. -- [ ] I'm using my own branch in my local copy. Ej, I want to merge - `myuser/ultrasonic:my-new-contribution` into `develop`. - [ ] All commits [are signed](https://docs.gitlab.com/ee/user/project/repository/gpg_signed_commits/). - [ ] I agree to release my code and all other changes of this MR under the diff --git a/.gitlab/merge_request_templates/Release.md b/.gitlab/merge_request_templates/Release.md new file mode 100644 index 00000000..d6b50a2c --- /dev/null +++ b/.gitlab/merge_request_templates/Release.md @@ -0,0 +1,10 @@ +#### Before merge: +- [ ] MR is targetting the master branch +- [ ] **Squash commits must be disabled!** +- [ ] RoboTests (5 physical, 10 virtual) on a Release apk return no errors +- [ ] Release notes present + +#### After merge +- [ ] ``git fetch`` +- [ ] Create an annotated and signed tag: ``git tag -sa`` +- [ ] Push the tag to git:``git push --tags`` diff --git a/fastlane/metadata/android/en-US/changelogs/123.txt b/fastlane/metadata/android/en-US/changelogs/123.txt new file mode 100644 index 00000000..490f3eb8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/123.txt @@ -0,0 +1,12 @@ +Features: +- Search is accesible through a new icon on the main screen +- Modernize Back Handling +- Reenable R8 Code minification +- Add a "Play Random Songs" shortcut + +Bug fixes: +- Tracks buttons flash a scrollbar sometimes in Android 13 +- Fix EndlessScrolling in genre listing +- Couldn't delete a track when shuffle was active +- Upgrade material to 1.9.0 + diff --git a/gradle.properties b/gradle.properties index e2c4770f..5fc57f04 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,5 +21,5 @@ android.nonFinalResIds=true org.gradle.unsafe.configuration-cache=true # TODO Renable on day (check that Retrofit, Jackson, and Imageloader are working) -android.enableR8.fullMode=false +android.enableR8.fullMode=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 057f57a7..a18ca95a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,21 +2,21 @@ # You need to run ./gradlew wrapper after updating the version gradle = "8.1.1" -navigation = "2.5.3" +navigation = "2.6.0" gradlePlugin = "8.0.2" androidxcore = "1.10.1" ktlint = "0.43.2" -ktlintGradle = "11.3.2" +ktlintGradle = "11.4.0" detekt = "1.23.0" preferences = "1.2.0" media3 = "1.0.2" androidSupport = "1.6.0" -materialDesign = "1.8.0" +materialDesign = "1.9.0" constraintLayout = "2.1.4" multidex = "2.0.1" room = "2.5.1" -kotlin = "1.8.21" +kotlin = "1.8.22" kotlinxCoroutines = "1.7.1" viewModelKtx = "2.6.1" swipeRefresh = "1.1.0" diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index bc68203f..a437074b 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -9,8 +9,8 @@ android { defaultConfig { applicationId "org.moire.ultrasonic" - versionCode 122 - versionName "4.5.0" + versionCode 123 + versionName "4.6.0" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk diff --git a/ultrasonic/minify/proguard-jackson.pro b/ultrasonic/minify/proguard-jackson.pro index 865b1091..d154a877 100644 --- a/ultrasonic/minify/proguard-jackson.pro +++ b/ultrasonic/minify/proguard-jackson.pro @@ -1,5 +1,4 @@ #### From Jackson - -keepattributes *Annotation*,EnclosingMethod,Signature -keepnames class com.fasterxml.jackson.** { *; diff --git a/ultrasonic/minify/proguard-main.pro b/ultrasonic/minify/proguard-main.pro index 83df2458..dda62679 100644 --- a/ultrasonic/minify/proguard-main.pro +++ b/ultrasonic/minify/proguard-main.pro @@ -1,8 +1,14 @@ -dontobfuscate ### Don't remove subsonic api serializers/entities --keep class org.moire.ultrasonic.api.subsonic.response.** { *; } --keep class org.moire.ultrasonic.api.subsonic.models.** { *; } +-keep class org.moire.ultrasonic.api.subsonic.** { *; } + +## Don't remove the domain models +-keep class org.moire.ultrasonic.domain.** { *; } + +## Don't remove the imageloader +-keep class org.moire.ultrasonic.imageloader.** { *; } +-keep class org.moire.ultrasonic.provider.AlbumArtContentProvider { *; } ## Don't remove NowPlayingFragment -keep class org.moire.ultrasonic.fragment.NowPlayingFragment { *; } diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 266ccd79..711f13e2 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -32,7 +32,9 @@ android:usesCleartextTraffic="true" android:supportsRtl="false" android:preserveLegacyExternalStorage="true" + android:networkSecurityConfig="@xml/network_security_config" tools:ignore="UnusedAttribute"> + diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/SearchSuggestionProvider.java b/ultrasonic/src/main/java/org/moire/ultrasonic/provider/SearchSuggestionProvider.java deleted file mode 100644 index 4cca8444..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/provider/SearchSuggestionProvider.java +++ /dev/null @@ -1,37 +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.provider; - -import android.content.SearchRecentSuggestionsProvider; - -/** - * Provides search suggestions based on recent searches. - * - * @author Sindre Mehus - */ -public class SearchSuggestionProvider extends SearchRecentSuggestionsProvider -{ - public static final String AUTHORITY = SearchSuggestionProvider.class.getName(); - public static final int MODE = DATABASE_MODE_QUERIES; - - public SearchSuggestionProvider() - { - setupSuggestions(AUTHORITY, MODE); - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index 8ed8c8bc..76df1ea5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -18,15 +18,20 @@ import android.os.Bundle import android.provider.MediaStore import android.provider.SearchRecentSuggestions import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.widget.ImageView +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.core.view.GravityCompat +import androidx.core.view.MenuProvider import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.FragmentContainerView +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player.STATE_BUFFERING @@ -51,18 +56,20 @@ import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ServerSettingDao -import org.moire.ultrasonic.fragment.OnBackPressedHandler import org.moire.ultrasonic.model.ServerSettingsModel import org.moire.ultrasonic.provider.SearchSuggestionProvider import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport import org.moire.ultrasonic.service.MediaPlayerManager +import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign +import org.moire.ultrasonic.subsonic.DownloadHandler import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.InfoDialog import org.moire.ultrasonic.util.LocaleHelper import org.moire.ultrasonic.util.ServerColor import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.ShortcutUtil import org.moire.ultrasonic.util.Storage import org.moire.ultrasonic.util.UncaughtExceptionHandler import org.moire.ultrasonic.util.Util @@ -91,6 +98,10 @@ class NavigationActivity : AppCompatActivity() { private var selectServerButton: MaterialButton? = null private var headerBackgroundImage: ImageView? = null + // We store the last search string in this variable. + // Seems a bit like a hack, is there a better way? + var searchQuery: String? = null + private lateinit var appBarConfiguration: AppBarConfiguration private var rxBusSubscription: CompositeDisposable = CompositeDisposable() @@ -126,6 +137,8 @@ class NavigationActivity : AppCompatActivity() { navigationView = findViewById(R.id.nav_view) drawerLayout = findViewById(R.id.drawer_layout) + setupDrawerLayout(drawerLayout!!) + val toolbar = findViewById(R.id.toolbar) setSupportActionBar(toolbar) @@ -210,6 +223,80 @@ class NavigationActivity : AppCompatActivity() { cachedServerCount = count ?: 0 updateNavigationHeaderForServer() } + + // Setup app shortcuts on supported devices, but not on first start, when the server + // is not configured yet. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && !UApp.instance!!.isFirstRun) { + ShortcutUtil.registerShortcuts(this) + } + + // Register our options menu + addMenuProvider( + searchMenuProvider, + this, + Lifecycle.State.RESUMED + ) + } + + private val searchMenuProvider: MenuProvider = object : MenuProvider { + override fun onPrepareMenu(menu: Menu) { + setupSearchField(menu) + } + + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.search_view_menu, menu) + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + return false + } + } + + fun setupSearchField(menu: Menu) { + Timber.i("Recreating search field") + val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + val searchableInfo = searchManager.getSearchableInfo(this.componentName) + searchView.setSearchableInfo(searchableInfo) + searchView.setIconifiedByDefault(false) + + if (searchQuery != null) { + Timber.e("Found existing search query") + searchItem.expandActionView() + searchView.isIconified = false + searchView.setQuery(searchQuery, false) + searchView.clearFocus() + // Restore search text only once! + searchQuery = null + } + } + + private fun setupDrawerLayout(drawerLayout: DrawerLayout) { + // Set initial state passed on drawer state + closeNavigationDrawerOnBack.isEnabled = drawerLayout.isOpen + + // Add the back press listener + onBackPressedDispatcher.addCallback(this, closeNavigationDrawerOnBack) + + // Listen to changes in the drawer state and enable the back press listener accordingly. + drawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + // Nothing + } + + override fun onDrawerOpened(drawerView: View) { + closeNavigationDrawerOnBack.isEnabled = true + } + + override fun onDrawerClosed(drawerView: View) { + closeNavigationDrawerOnBack.isEnabled = false + } + + override fun onDrawerStateChanged(newState: Int) { + // Nothing + } + }) } override fun onResume() { @@ -315,11 +402,18 @@ class NavigationActivity : AppCompatActivity() { selectServerButton = navigationView?.getHeaderView(0)?.findViewById(R.id.header_select_server) - selectServerButton?.setOnClickListener { + val dropDownButton: ImageView? = + navigationView?.getHeaderView(0)?.findViewById(R.id.edit_server_button) + + val onClick: (View) -> Unit = { if (drawerLayout?.isDrawerVisible(GravityCompat.START) == true) this.drawerLayout?.closeDrawer(GravityCompat.START) navController.navigate(R.id.serverSelectorFragment) } + + selectServerButton?.setOnClickListener(onClick) + dropDownButton?.setOnClickListener(onClick) + headerBackgroundImage = navigationView?.getHeaderView(0)?.findViewById(R.id.img_header_bg) } @@ -328,13 +422,9 @@ class NavigationActivity : AppCompatActivity() { setupActionBarWithNavController(navController, appBarConfig) } - override fun onBackPressed() { - if (drawerLayout?.isDrawerVisible(GravityCompat.START) == true) { - this.drawerLayout?.closeDrawer(GravityCompat.START) - } else { - val currentFragment = host!!.childFragmentManager.fragments.last() - if (currentFragment is OnBackPressedHandler) currentFragment.onBackPressed() - else super.onBackPressed() + private val closeNavigationDrawerOnBack = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + drawerLayout?.closeDrawer(GravityCompat.START) } } @@ -352,40 +442,60 @@ class NavigationActivity : AppCompatActivity() { super.onOptionsItemSelected(item) } + // TODO: Why is this needed? Shouldn't it just work by default? override fun onSupportNavigateUp(): Boolean { - val currentFragment = host!!.childFragmentManager.fragments.last() - return if (currentFragment is OnBackPressedHandler) { - currentFragment.onBackPressed() - true - } else { - findNavController(R.id.nav_host_fragment).navigateUp(appBarConfiguration) + return findNavController(R.id.nav_host_fragment).navigateUp(appBarConfiguration) + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + when (intent?.action) { + Constants.INTENT_PLAY_RANDOM_SONGS -> { + playRandomSongs() + } + Intent.ACTION_MAIN -> { + if (intent.getBooleanExtra(Constants.INTENT_SHOW_PLAYER, false)) { + findNavController(R.id.nav_host_fragment).navigate(R.id.playerFragment) + } + } + Intent.ACTION_SEARCH -> { + searchQuery = intent.getStringExtra(SearchManager.QUERY) + handleSearchIntent(searchQuery, false) + } + MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH -> { + searchQuery = intent.getStringExtra(SearchManager.QUERY) + handleSearchIntent(searchQuery, true) + } } } - // TODO Test if this works with external Intents - // android.intent.action.SEARCH and android.media.action.MEDIA_PLAY_FROM_SEARCH calls here - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - if (intent == null) return + private fun handleSearchIntent(query: String?, autoPlay: Boolean) { + val suggestions = SearchRecentSuggestions( + this, + SearchSuggestionProvider.AUTHORITY, SearchSuggestionProvider.MODE + ) + suggestions.saveRecentQuery(query, null) - if (intent.getBooleanExtra(Constants.INTENT_SHOW_PLAYER, false)) { - findNavController(R.id.nav_host_fragment).navigate(R.id.playerFragment) - return - } + val action = NavigationGraphDirections.toSearchFragment(query, autoPlay) + findNavController(R.id.nav_host_fragment).navigate(action) + } - val query = intent.getStringExtra(SearchManager.QUERY) - - if (query != null) { - val autoPlay = intent.action == MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH - val suggestions = SearchRecentSuggestions( - this, - SearchSuggestionProvider.AUTHORITY, SearchSuggestionProvider.MODE - ) - suggestions.saveRecentQuery(query, null) - - val action = NavigationGraphDirections.toSearchFragment(query, autoPlay) - findNavController(R.id.nav_host_fragment).navigate(action) - } + private fun playRandomSongs() { + val currentFragment = host?.childFragmentManager?.fragments?.last() ?: return + val service = MusicServiceFactory.getMusicService() + val musicDirectory = service.getRandomSongs(Settings.maxSongs) + val downloadHandler: DownloadHandler by inject() + downloadHandler.addTracksToMediaController( + songs = musicDirectory.getTracks(), + append = false, + playNext = false, + autoPlay = true, + shuffle = false, + fragment = currentFragment, + playlistName = null + ) + return } /** diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 2efea271..4994a0b4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -1,5 +1,6 @@ package org.moire.ultrasonic.adapters +import android.graphics.Color import android.view.View import android.widget.Checkable import android.widget.CheckedTextView @@ -13,6 +14,7 @@ import androidx.lifecycle.MutableLiveData import androidx.media3.common.HeartRating import androidx.media3.common.StarRating import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.color.MaterialColors import com.google.android.material.progressindicator.CircularProgressIndicator import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope @@ -43,8 +45,13 @@ class TrackViewHolder(val view: View) : KoinComponent, CoroutineScope by CoroutineScope(Dispatchers.IO) { + companion object { + val COLOR_HIGHLIGHT = com.google.android.material.R.attr.colorSecondaryContainer + } + var entry: Track? = null private set + var songLayout: LinearLayout = view.findViewById(R.id.song_layout) var check: CheckedTextView = view.findViewById(R.id.song_check) var drag: ImageView = view.findViewById(R.id.song_drag) var observableChecked = MutableLiveData(false) @@ -164,17 +171,23 @@ class TrackViewHolder(val view: View) : ContextCompat.getDrawable(view.context, R.drawable.ic_stat_play)!! } + @Suppress("MagicNumber") private fun setPlayIcon(isPlaying: Boolean) { if (isPlaying && !isPlayingCached) { isPlayingCached = true title.setCompoundDrawablesWithIntrinsicBounds( playingIcon, null, null, null ) + val color = MaterialColors.getColor(view, COLOR_HIGHLIGHT) + songLayout.setBackgroundColor(color) + songLayout.elevation = 3F } else if (!isPlaying && isPlayingCached) { isPlayingCached = false title.setCompoundDrawablesWithIntrinsicBounds( 0, 0, 0, 0 ) + songLayout.setBackgroundColor(Color.TRANSPARENT) + songLayout.elevation = 0F } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt index 73d3a0ba..b50cfb67 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt @@ -7,12 +7,14 @@ package org.moire.ultrasonic.fragment +import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.ImageView +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment @@ -52,7 +54,7 @@ private const val DIALOG_PADDING = 12 /** * Displays a form where server settings can be created / edited */ -class EditServerFragment : Fragment(), OnBackPressedHandler { +class EditServerFragment : Fragment() { private val serverSettingsModel: ServerSettingsModel by viewModel() private val activeServerProvider: ActiveServerProvider by inject() @@ -82,6 +84,13 @@ class EditServerFragment : Fragment(), OnBackPressedHandler { super.onCreate(savedInstanceState) } + override fun onAttach(context: Context) { + requireActivity().onBackPressedDispatcher.addCallback( + this, confirmCloseCallback + ) + super.onAttach(context) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -189,11 +198,25 @@ class EditServerFragment : Fragment(), OnBackPressedHandler { } } + private val confirmCloseCallback = object : OnBackPressedCallback( + true // default to enabled + ) { + override fun handleOnBackPressed() { + finishActivity() + } + } + override fun onStop() { Util.hideKeyboard(activity) + confirmCloseCallback.isEnabled = false super.onStop() } + override fun onResume() { + confirmCloseCallback.isEnabled = true + super.onResume() + } + private fun correctServerAddress() { serverAddressEditText?.editText?.setText( serverAddressEditText?.editText?.text?.trim(' ', '/') @@ -206,11 +229,6 @@ class EditServerFragment : Fragment(), OnBackPressedHandler { image?.setTint(currentColor) serverColorImageView?.background = image } - - override fun onBackPressed() { - finishActivity() - } - override fun onSaveInstanceState(savedInstanceState: Bundle) { savedInstanceState.putString( ::serverNameEditText.name, serverNameEditText!!.editText?.text.toString() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/OnBackPressedHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/OnBackPressedHandler.kt deleted file mode 100644 index c34ca2dd..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/OnBackPressedHandler.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * OnBackPressedHandler.kt - * Copyright (C) 2009-2022 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.fragment - -/** - * Interface for fragments handling their own Back button - */ -interface OnBackPressedHandler { - fun onBackPressed() -} 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 cc46a670..7f50a870 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -51,7 +51,6 @@ import androidx.recyclerview.widget.ItemTouchHelper 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.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton import com.google.android.material.progressindicator.CircularProgressIndicator @@ -481,9 +480,7 @@ class PlayerFragment : val index = mediaPlayerManager.currentMediaItemIndex if (index != -1) { - val smoothScroller = LinearSmoothScroller(context) - smoothScroller.targetPosition = index - viewManager.startSmoothScroll(smoothScroller) + viewManager.scrollToPosition(index) } } @@ -930,7 +927,8 @@ class PlayerFragment : // Swipe to delete from playlist @SuppressLint("NotifyDataSetChanged") override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val pos = viewHolder.bindingAdapterPosition + val viewPos = viewHolder.bindingAdapterPosition + val pos = mediaPlayerManager.getUnshuffledIndexOf(viewPos) val item = mediaPlayerManager.getMediaItemAt(pos) // Remove the item from the list quickly 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 bd70f431..cd4d1104 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -7,19 +7,11 @@ package org.moire.ultrasonic.fragment -import android.app.SearchManager -import android.content.Context import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater import android.view.MenuItem import android.view.View -import androidx.appcompat.widget.SearchView -import androidx.core.view.MenuHost -import androidx.core.view.MenuProvider import androidx.core.view.isVisible import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle import androidx.lifecycle.viewModelScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -54,16 +46,14 @@ import org.moire.ultrasonic.util.CommunicationError import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util.toast -import timber.log.Timber /** * Initiates a search on the media library and displays the results - * TODO: Switch to material3 class + */ class SearchFragment : MultiListFragment(), KoinComponent { private var searchResult: SearchResult? = null private var searchRefresh: SwipeRefreshLayout? = null - private var searchView: SearchView? = null private val mediaPlayerManager: MediaPlayerManager by inject() @@ -83,13 +73,6 @@ class SearchFragment : MultiListFragment(), KoinComponent { cancellationToken = CancellationToken() setTitle(this, R.string.search_title) - // Register our options menu - (requireActivity() as MenuHost).addMenuProvider( - menuProvider, - viewLifecycleOwner, - Lifecycle.State.RESUMED - ) - listModel.searchResult.observe( viewLifecycleOwner ) { @@ -148,73 +131,6 @@ class SearchFragment : MultiListFragment(), KoinComponent { } } - /** - * This provide creates the search bar above the recycler view - */ - private val menuProvider: MenuProvider = object : MenuProvider { - override fun onPrepareMenu(menu: Menu) { - setupOptionsMenu(menu) - } - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.search, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return true - } - } - fun setupOptionsMenu(menu: Menu) { - val activity = activity ?: return - val searchManager = activity.getSystemService(Context.SEARCH_SERVICE) as SearchManager - val searchItem = menu.findItem(R.id.search_item) - searchView = searchItem.actionView as SearchView - val searchableInfo = searchManager.getSearchableInfo(requireActivity().componentName) - searchView!!.setSearchableInfo(searchableInfo) - - val autoPlay = navArgs.autoplay - val query = navArgs.query - - // If started with a query, enter it to the searchView - if (query != null) { - searchView!!.setQuery(query, false) - searchView!!.clearFocus() - } - - searchView!!.setOnSuggestionListener(object : SearchView.OnSuggestionListener { - override fun onSuggestionSelect(position: Int): Boolean { - return true - } - - override fun onSuggestionClick(position: Int): Boolean { - Timber.d("onSuggestionClick: %d", position) - val cursor = searchView!!.suggestionsAdapter.cursor - cursor.moveToPosition(position) - - // 2 is the index of col containing suggestion name. - val suggestion = cursor.getString(2) - searchView!!.setQuery(suggestion, true) - return true - } - }) - - searchView!!.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - Timber.d("onQueryTextSubmit: %s", query) - searchView!!.clearFocus() - search(query, autoPlay) - return true - } - - override fun onQueryTextChange(newText: String): Boolean { - return true - } - }) - - searchView!!.setIconifiedByDefault(false) - searchItem.expandActionView() - } - override fun onDestroyView() { Util.hideKeyboard(activity) cancellationToken?.cancel() @@ -313,7 +229,6 @@ class SearchFragment : MultiListFragment(), KoinComponent { } private fun onAlbumSelected(album: Album, autoplay: Boolean) { - val action = SearchFragmentDirections.searchToTrackCollection( id = album.id, name = album.title, 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 5491bca0..cd71ee7c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -36,7 +36,6 @@ import org.moire.ultrasonic.adapters.AlbumHeader import org.moire.ultrasonic.adapters.AlbumRowDelegate import org.moire.ultrasonic.adapters.HeaderViewBinder import org.moire.ultrasonic.adapters.TrackViewBinder -import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.MusicDirectory @@ -64,7 +63,6 @@ import timber.log.Timber * In most cases the data should be just a list of Entries, but there are some cases * where the list can contain Albums as well. This happens especially when having ID3 tags disabled, * or using Offline mode, both in which Indexes instead of Artists are being used. - * */ @Suppress("TooManyFunctions") open class TrackCollectionFragment( @@ -93,7 +91,6 @@ open class TrackCollectionFragment( private val rxBusSubscription: CompositeDisposable = CompositeDisposable() private var sortOrder = initialOrder - private var offset: Int? = null /** * The id of the main layout @@ -190,13 +187,12 @@ open class TrackCollectionFragment( private fun loadMoreTracks() { if (displayRandom() || navArgs.genreName != null) { - offset = navArgs.offset + navArgs.size - getLiveData(refresh = true, append = true) + getLiveData(append = true) } } internal open fun handleRefresh() { - getLiveData(true) + getLiveData(refresh = true) } internal open fun setupButtons(view: View) { @@ -268,6 +264,9 @@ open class TrackCollectionFragment( private val menuProvider: MenuProvider = object : MenuProvider { override fun onPrepareMenu(menu: Menu) { + // Hide search button (from xml) + menu.findItem(R.id.action_search).isVisible = false + playAllButton = menu.findItem(R.id.select_album_play_all) if (playAllButton != null) { @@ -282,7 +281,7 @@ open class TrackCollectionFragment( } override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.select_album, menu) + inflater.inflate(R.menu.track_collection_menu, menu) } override fun onMenuItemSelected(item: MenuItem): Boolean { @@ -552,7 +551,7 @@ open class TrackCollectionFragment( val getVideos = navArgs.getVideos val getRandomTracks = displayRandom() val size = if (navArgs.size < 0) Settings.maxSongs else navArgs.size - val offset = offset ?: navArgs.offset + val offset = navArgs.offset val refresh2 = navArgs.refresh || refresh listModel.viewModelScope.launch(handler) { @@ -584,12 +583,8 @@ open class TrackCollectionFragment( } else { setTitle(name) - if (ActiveServerProvider.shouldUseId3Tags()) { - if (isAlbum) { - listModel.getAlbum(refresh2, id, name) - } else { - throw IllegalAccessException("Use AlbumFragment instead!") - } + if (isAlbum) { + listModel.getAlbum(refresh2, id, name) } else { listModel.getMusicDirectory(refresh2, id, name) } @@ -688,7 +683,7 @@ open class TrackCollectionFragment( override fun setOrderType(newOrder: SortOrder) { sortOrder = newOrder - getLiveData(true) + getLiveData(refresh = true) } override var viewCapabilities: ViewCapabilities = ViewCapabilities( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt index df0cb4aa..22a92869 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt @@ -27,6 +27,7 @@ import org.moire.ultrasonic.util.Util class TrackCollectionModel(application: Application) : GenericListModel(application) { val currentList: MutableLiveData> = MutableLiveData() + private var loadedUntil: Int = 0 /* * Especially when dealing with indexes, this method can return Albums, Entries or a mix of both! @@ -37,7 +38,6 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat name: String? ) { withContext(Dispatchers.IO) { - val service = MusicServiceFactory.getMusicService() val musicDirectory = service.getMusicDirectory(id, name, refresh) currentListIsSortable = true @@ -57,11 +57,19 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat } suspend fun getSongsForGenre(genre: String, count: Int, offset: Int, append: Boolean) { + // Handle the logic for endless scrolling: + // If appending the existing list, set the offset from where to load + var newOffset = offset + if (append) newOffset += (count + loadedUntil) + withContext(Dispatchers.IO) { val service = MusicServiceFactory.getMusicService() - val musicDirectory = service.getSongsByGenre(genre, count, offset) + val musicDirectory = service.getSongsByGenre(genre, count, newOffset) currentListIsSortable = false updateList(musicDirectory, append) + + // Update current offset + loadedUntil = newOffset } } @@ -96,7 +104,6 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat } suspend fun getRandom(size: Int, append: Boolean) { - withContext(Dispatchers.IO) { val service = MusicServiceFactory.getMusicService() val musicDirectory = service.getRandomSongs(size) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt index 6dd656a3..7965558e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -340,6 +340,7 @@ class PlaybackService : // needed starting Android 12 (S = 31) flags = flags or FLAG_IMMUTABLE } + intent.action = Intent.ACTION_MAIN intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) return PendingIntent.getActivity(this, 0, intent, flags) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/SearchSuggestionProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/SearchSuggestionProvider.kt new file mode 100644 index 00000000..06803c17 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/SearchSuggestionProvider.kt @@ -0,0 +1,24 @@ +/* + * SearchSuggestionProvider.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ +package org.moire.ultrasonic.provider + +import android.content.SearchRecentSuggestionsProvider +import org.moire.ultrasonic.BuildConfig + +/** + * Provides search suggestions based on recent searches. + */ +class SearchSuggestionProvider : SearchRecentSuggestionsProvider() { + init { + setupSuggestions(AUTHORITY, MODE) + } + + companion object { + val AUTHORITY = "${BuildConfig.APPLICATION_ID}.provider.SearchSuggestionProvider" + const val MODE = DATABASE_MODE_QUERIES + } +} 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 5902d772..3a422a2b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.kt @@ -219,8 +219,8 @@ open class UltrasonicAppWidgetProvider : AppWidgetProvider() { NavigationActivity::class.java ).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) if (playerActive) intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) - intent.action = "android.intent.action.MAIN" - intent.addCategory("android.intent.category.LAUNCHER") + intent.action = Intent.ACTION_MAIN + intent.addCategory(Intent.CATEGORY_LAUNCHER) var flags = PendingIntent.FLAG_UPDATE_CURRENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // needed starting Android 12 (S = 31) 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 a580b3d3..dba110ec 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerManager.kt @@ -720,16 +720,25 @@ class MediaPlayerManager( /** * Loops over the timeline windows to find the entry which matches the given closure. * + * @param returnWindow Determines which of the two indexes of a match to return: + * True for the position in the playlist, False for position in the play order. * @param searchClosure Determines the condition which the searched for window needs to match. * @return the index of the window that satisfies the search condition, * or [C.INDEX_UNSET] if not found. */ - private fun getWindowIndexWhere(searchClosure: (Int, Int) -> Boolean): Int { + @Suppress("KotlinConstantConditions") + private fun getWindowIndexWhere( + returnWindow: Boolean, + searchClosure: (Int, Int) -> Boolean + ): Int { val timeline = controller?.currentTimeline!! var windowIndex = timeline.getFirstWindowIndex(true) var count = 0 + while (windowIndex != C.INDEX_UNSET) { - if (searchClosure(count, windowIndex)) return count + val match = searchClosure(count, windowIndex) + if (match && returnWindow) return windowIndex + if (match && !returnWindow) return count count++ windowIndex = timeline.getNextWindowIndex( windowIndex, REPEAT_MODE_OFF, true @@ -748,7 +757,10 @@ class MediaPlayerManager( * @return The index of the item in the shuffled timeline, or [C.INDEX_UNSET] if not found. */ fun getShuffledIndexOf(searchPosition: Int): Int { - return getWindowIndexWhere { _, windowIndex -> windowIndex == searchPosition } + return getWindowIndexWhere(false) { + _, windowIndex -> + windowIndex == searchPosition + } } /** @@ -760,7 +772,10 @@ 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 { count, _ -> count == shufflePosition } + return getWindowIndexWhere(true) { + count, _ -> + count == shufflePosition + } } val mediaItemCount: Int 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 351de91c..fceee797 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt @@ -25,11 +25,11 @@ object Constants { const val CMD_PREVIOUS = "org.moire.ultrasonic.CMD_PREVIOUS" const val CMD_NEXT = "org.moire.ultrasonic.CMD_NEXT" const val INTENT_SHOW_PLAYER = "org.moire.ultrasonic.SHOW_PLAYER" + const val INTENT_PLAY_RANDOM_SONGS = "org.moire.ultrasonic.CMD_RANDOM_SONGS" // Legacy Preferences keys // Warning: Don't add any new here! // Use setting_keys.xml - const val PREFERENCES_KEY_USE_FIVE_STAR_RATING = "use_five_star_rating" const val PREFERENCE_VALUE_ALL = 0 const val PREFERENCE_VALUE_A2DP = 1 const val PREFERENCE_VALUE_DISABLED = 2 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ShortcutUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ShortcutUtil.kt new file mode 100644 index 00000000..7034d75b --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ShortcutUtil.kt @@ -0,0 +1,37 @@ +/* + * ShortcutUtil.kt + * Copyright (C) 2009-2023 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.util + +import android.app.Activity +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.RequiresApi +import org.moire.ultrasonic.R +import org.moire.ultrasonic.activity.NavigationActivity + +object ShortcutUtil { + @RequiresApi(Build.VERSION_CODES.N_MR1) + fun registerShortcuts(activity: Activity) { + val shortcutIntent = Intent(activity, NavigationActivity::class.java).apply { + action = Constants.INTENT_PLAY_RANDOM_SONGS + } + + val shortcut = ShortcutInfo.Builder(activity, "shortcut_play_random_songs") + .setShortLabel(activity.getString(R.string.shortcut_play_random_songs_short)) + .setLongLabel(activity.getString(R.string.shortcut_play_random_songs_long)) + .setIcon(Icon.createWithResource(activity, R.drawable.media_shuffle)) + .setIntent(shortcutIntent) + .build() + + val shortcutManager = activity.getSystemService(ShortcutManager::class.java) + shortcutManager?.dynamicShortcuts = listOf(shortcut) + } +} diff --git a/ultrasonic/src/main/res/drawable/arrow_drop_down.xml b/ultrasonic/src/main/res/drawable/arrow_drop_down.xml new file mode 100644 index 00000000..0a9fd3a6 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/arrow_drop_down.xml @@ -0,0 +1,5 @@ + + + diff --git a/ultrasonic/src/main/res/layout/navigation_activity.xml b/ultrasonic/src/main/res/layout/navigation_activity.xml index 867f1f6b..ef421eac 100644 --- a/ultrasonic/src/main/res/layout/navigation_activity.xml +++ b/ultrasonic/src/main/res/layout/navigation_activity.xml @@ -42,6 +42,7 @@ - + app:layout_constraintTop_toTopOf="parent" />