diff --git a/.gitignore b/.gitignore index 26ed3fc..db0d46a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ *.iml -**.gradle +**/.gradle /local.properties /.idea .DS_Store diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c198c16..5037a63 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,12 +5,15 @@ import java.util.Properties plugins { id("com.android.application") id("kotlin-android") + id("androidx.navigation.safeargs.kotlin") + id("kotlin-parcelize") id("org.jlleitschuh.gradle.ktlint") version "11.0.0" id("com.gladed.androidgitversion") version "0.4.14" id("com.github.triplet.play") version "3.7.0" id("de.mobilej.unmock") id("com.github.ben-manes.versions") + id("org.jetbrains.kotlin.android") jacoco } @@ -48,6 +51,7 @@ android { buildFeatures { viewBinding = true + dataBinding = true } packagingOptions { @@ -158,6 +162,9 @@ play { } dependencies { + val navVersion: String by rootProject.extra + val lifecycleVersion: String by rootProject.extra + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar")))) implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0") @@ -166,7 +173,8 @@ dependencies { implementation("androidx.appcompat:appcompat:1.4.2") implementation("androidx.core:core-ktx:1.9.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion") implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0") implementation("androidx.preference:preference-ktx:1.2.0") implementation("androidx.recyclerview:recyclerview:1.2.1") @@ -189,7 +197,7 @@ dependencies { isTransitive = false } - implementation("com.aliassadi:power-preference-lib:2.0.0") + implementation("com.github.AliAsadi:PowerPreference:2.1.0") implementation("com.github.kittinunf.fuel:fuel:2.3.1") implementation("com.github.kittinunf.fuel:fuel-coroutines:2.3.1") implementation("com.github.kittinunf.fuel:fuel-android:2.3.1") @@ -199,6 +207,10 @@ dependencies { implementation("jp.wasabeef:picasso-transformations:2.4.0") implementation("net.openid:appauth:0.11.1") + implementation("androidx.navigation:navigation-fragment-ktx:$navVersion") + implementation("androidx.navigation:navigation-ui-ktx:$navVersion") + implementation("androidx.navigation:navigation-dynamic-features-fragment:$navVersion") + testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk:1.13.3") testImplementation("androidx.test:core:1.5.0") @@ -206,6 +218,7 @@ dependencies { testImplementation("org.robolectric:robolectric:4.9.2") androidTestImplementation("io.mockk:mockk-android:1.13.3") + androidTestImplementation("androidx.navigation:navigation-testing:$navVersion") } project.afterEvaluate { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3dd0c98..63ffef8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,10 +44,6 @@ android:name=".activities.MainActivity" android:screenOrientation="portrait" /> - - diff --git a/app/src/main/java/audio/funkwhale/ffa/activities/MainActivity.kt b/app/src/main/java/audio/funkwhale/ffa/activities/MainActivity.kt index 6feffda..7647e96 100644 --- a/app/src/main/java/audio/funkwhale/ffa/activities/MainActivity.kt +++ b/app/src/main/java/audio/funkwhale/ffa/activities/MainActivity.kt @@ -16,23 +16,23 @@ import android.view.View import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator import android.widget.SeekBar +import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.PopupMenu import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment import audio.funkwhale.ffa.FFA import audio.funkwhale.ffa.R import audio.funkwhale.ffa.databinding.ActivityMainBinding import audio.funkwhale.ffa.fragments.AddToPlaylistDialog -import audio.funkwhale.ffa.fragments.AlbumsFragment -import audio.funkwhale.ffa.fragments.ArtistsFragment -import audio.funkwhale.ffa.fragments.BrowseFragment +import audio.funkwhale.ffa.fragments.BrowseFragmentDirections import audio.funkwhale.ffa.fragments.LandscapeQueueFragment import audio.funkwhale.ffa.fragments.QueueFragment import audio.funkwhale.ffa.fragments.TrackInfoDetailsFragment @@ -89,40 +89,47 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private val oAuth: OAuth by inject(OAuth::class.java) + private val navigation: NavController by lazy { + val navHost = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + navHost.navController + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - AppContext.init(this) - binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.appbar) + onBackPressedDispatcher.addCallback(this) { + if (binding.nowPlaying.isOpened()) { + binding.nowPlaying.close() + } else { + navigation.navigateUp() + } + } + when (intent.action) { MediaControlsManager.NOTIFICATION_ACTION_OPEN_QUEUE.toString() -> launchDialog(QueueFragment()) } - supportFragmentManager - .beginTransaction() - .replace(R.id.container, BrowseFragment()) - .commit() - watchEventBus() } override fun onResume() { super.onResume() - (binding.container as? DisableableFrameLayout)?.setShouldRegisterTouch { _ -> - if (binding.nowPlaying.isOpened()) { - binding.nowPlaying.close() - - return@setShouldRegisterTouch false + findViewById(R.id.container)?.apply { + setShouldRegisterTouch { + if (binding.nowPlaying.isOpened()) { + binding.nowPlaying.close() + false + } else { + true + } } - - true } favoritedRepository.update(this, lifecycleScope) @@ -178,15 +185,6 @@ class MainActivity : AppCompatActivity() { } } - override fun onBackPressed() { - if (binding.nowPlaying.isOpened()) { - binding.nowPlaying.close() - return - } - - super.onBackPressed() - } - override fun onPrepareOptionsMenu(menu: Menu): Boolean { this.menu = menu @@ -226,18 +224,11 @@ class MainActivity : AppCompatActivity() { when (item.itemId) { android.R.id.home -> { binding.nowPlaying.close() - - (supportFragmentManager.fragments.last() as? BrowseFragment)?.let { - it.selectTabAt(0) - - return true - } - - launchFragment(BrowseFragment()) + navigation.popBackStack(R.id.browseFragment, false) } R.id.nav_queue -> launchDialog(QueueFragment()) - R.id.nav_search -> startActivity(Intent(this, SearchActivity::class.java)) + R.id.nav_search -> navigation.navigate(BrowseFragmentDirections.browseToSearch()) R.id.nav_all_music, R.id.nav_my_music, R.id.nav_followed -> { menu?.let { menu -> item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW) @@ -300,26 +291,8 @@ class MainActivity : AppCompatActivity() { return true } - private fun launchFragment(fragment: Fragment) { - supportFragmentManager.fragments.lastOrNull()?.also { oldFragment -> - oldFragment.enterTransition = null - oldFragment.exitTransition = null - - supportFragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } - - supportFragmentManager - .beginTransaction() - .setCustomAnimations(0, 0, 0, 0) - .replace(R.id.container, fragment) - .commit() - } - - private fun launchDialog(fragment: DialogFragment) { - supportFragmentManager.beginTransaction().let { - fragment.show(it, "") - } - } + private fun launchDialog(fragment: DialogFragment) = + fragment.show(supportFragmentManager.beginTransaction(), "") @SuppressLint("NewApi") private fun watchEventBus() { @@ -343,7 +316,7 @@ class MainActivity : AppCompatActivity() { } } else if (event is Event.PlaybackStopped) { if (binding.nowPlaying.visibility == View.VISIBLE) { - (binding.container.layoutParams as? ViewGroup.MarginLayoutParams)?.let { + (binding.navHostFragment.layoutParams as? ViewGroup.MarginLayoutParams)?.let { it.bottomMargin = it.bottomMargin / 2 } @@ -368,15 +341,17 @@ class MainActivity : AppCompatActivity() { } else if (event is Event.StateChanged) { when (event.playing) { true -> { - binding.nowPlayingContainer?.nowPlayingToggle?.icon = getDrawable(R.drawable.pause) + binding.nowPlayingContainer?.nowPlayingToggle?.icon = + AppCompatResources.getDrawable(this@MainActivity, R.drawable.pause) binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon = - getDrawable(R.drawable.pause) + AppCompatResources.getDrawable(this@MainActivity, R.drawable.pause) } false -> { - binding.nowPlayingContainer?.nowPlayingToggle?.icon = getDrawable(R.drawable.play) + binding.nowPlayingContainer?.nowPlayingToggle?.icon = + AppCompatResources.getDrawable(this@MainActivity, R.drawable.play) binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon = - getDrawable(R.drawable.play) + AppCompatResources.getDrawable(this@MainActivity, R.drawable.play) } } } else if (event is Event.QueueChanged) { @@ -459,7 +434,7 @@ class MainActivity : AppCompatActivity() { .setListener(null) .start() - (binding.container.layoutParams as? ViewGroup.MarginLayoutParams)?.let { + (binding.navHostFragment?.layoutParams as? ViewGroup.MarginLayoutParams)?.let { it.bottomMargin = it.bottomMargin * 2 } @@ -534,12 +509,11 @@ class MainActivity : AppCompatActivity() { setOnMenuItemClickListener { when (it.itemId) { - R.id.track_info_artist -> ArtistsFragment.openAlbums( - this@MainActivity, + R.id.track_info_artist -> BrowseFragmentDirections.browseToAlbums( track.artist, - art = track.album?.cover() + track.album?.cover() ) - R.id.track_info_album -> AlbumsFragment.openTracks(this@MainActivity, track.album) + R.id.track_info_album -> track.album?.let(BrowseFragmentDirections::browseToTracks) R.id.track_info_details -> TrackInfoDetailsFragment.new(track) .show(supportFragmentManager, "dialog") } diff --git a/app/src/main/java/audio/funkwhale/ffa/activities/SearchActivity.kt b/app/src/main/java/audio/funkwhale/ffa/activities/SearchActivity.kt deleted file mode 100644 index d5ac646..0000000 --- a/app/src/main/java/audio/funkwhale/ffa/activities/SearchActivity.kt +++ /dev/null @@ -1,195 +0,0 @@ -package audio.funkwhale.ffa.activities - -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.SearchView -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import audio.funkwhale.ffa.adapters.FavoriteListener -import audio.funkwhale.ffa.adapters.SearchAdapter -import audio.funkwhale.ffa.databinding.ActivitySearchBinding -import audio.funkwhale.ffa.fragments.AddToPlaylistDialog -import audio.funkwhale.ffa.fragments.AlbumsFragment -import audio.funkwhale.ffa.fragments.ArtistsFragment -import audio.funkwhale.ffa.model.Album -import audio.funkwhale.ffa.model.Artist -import audio.funkwhale.ffa.repositories.AlbumsSearchRepository -import audio.funkwhale.ffa.repositories.ArtistsSearchRepository -import audio.funkwhale.ffa.repositories.FavoritesRepository -import audio.funkwhale.ffa.repositories.Repository -import audio.funkwhale.ffa.repositories.TracksSearchRepository -import audio.funkwhale.ffa.utils.Command -import audio.funkwhale.ffa.utils.CommandBus -import audio.funkwhale.ffa.utils.Event -import audio.funkwhale.ffa.utils.EventBus -import audio.funkwhale.ffa.utils.getMetadata -import audio.funkwhale.ffa.utils.untilNetwork -import com.google.android.exoplayer2.offline.Download -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.URLEncoder -import java.util.Locale - -class SearchActivity : AppCompatActivity() { - private lateinit var adapter: SearchAdapter - - private lateinit var artistsRepository: ArtistsSearchRepository - private lateinit var albumsRepository: AlbumsSearchRepository - private lateinit var tracksRepository: TracksSearchRepository - private lateinit var favoritesRepository: FavoritesRepository - private lateinit var binding: ActivitySearchBinding - - var done = 0 - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - artistsRepository = ArtistsSearchRepository(this@SearchActivity, "") - albumsRepository = AlbumsSearchRepository(this@SearchActivity, "") - tracksRepository = TracksSearchRepository(this@SearchActivity, "") - favoritesRepository = FavoritesRepository(this@SearchActivity) - - binding = ActivitySearchBinding.inflate(layoutInflater) - - setContentView(binding.root) - - binding.search.requestFocus() - } - - override fun onResume() { - super.onResume() - - lifecycleScope.launch(Dispatchers.Main) { - CommandBus.get().collect { command -> - if (command is Command.AddToPlaylist) { - - if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - AddToPlaylistDialog.show( - layoutInflater, - this@SearchActivity, - lifecycleScope, - command.tracks - ) - } - } - } - } - - lifecycleScope.launch(Dispatchers.IO) { - EventBus.get().collect { event -> - if (event is Event.DownloadChanged) refreshDownloadedTrack(event.download) - } - } - - adapter = - SearchAdapter( - layoutInflater, - this, - SearchResultClickListener(), - FavoriteListener(favoritesRepository) - ).also { - binding.results.layoutManager = LinearLayoutManager(this) - binding.results.adapter = it - } - - binding.search.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - - override fun onQueryTextSubmit(rawQuery: String?): Boolean { - binding.search.clearFocus() - - rawQuery?.let { - done = 0 - - val query = URLEncoder.encode(it, "UTF-8") - - artistsRepository.query = query.lowercase(Locale.ROOT) - albumsRepository.query = query.lowercase(Locale.ROOT) - tracksRepository.query = query.lowercase(Locale.ROOT) - - binding.searchSpinner.visibility = View.VISIBLE - binding.searchEmpty.visibility = View.GONE - binding.searchNoResults.visibility = View.GONE - - adapter.artists.clear() - adapter.albums.clear() - adapter.tracks.clear() - adapter.notifyDataSetChanged() - - artistsRepository.fetch(Repository.Origin.Network.origin) - .untilNetwork(lifecycleScope) { artists, _, _, _ -> - done++ - - adapter.artists.addAll(artists) - refresh() - } - - albumsRepository.fetch(Repository.Origin.Network.origin) - .untilNetwork(lifecycleScope) { albums, _, _, _ -> - done++ - - adapter.albums.addAll(albums) - refresh() - } - - tracksRepository.fetch(Repository.Origin.Network.origin) - .untilNetwork(lifecycleScope) { tracks, _, _, _ -> - done++ - - adapter.tracks.addAll(tracks) - refresh() - } - } - - return true - } - - override fun onQueryTextChange(newText: String?) = true - }) - } - - private fun refresh() { - adapter.notifyDataSetChanged() - - if (adapter.artists.size + adapter.albums.size + adapter.tracks.size == 0) { - binding.searchNoResults.visibility = View.VISIBLE - } else { - binding.searchNoResults.visibility = View.GONE - } - - if (done == 3) { - binding.searchSpinner.visibility = View.INVISIBLE - } - } - - private suspend fun refreshDownloadedTrack(download: Download) { - if (download.state == Download.STATE_COMPLETED) { - download.getMetadata()?.let { info -> - adapter.tracks.withIndex().associate { it.value to it.index } - .filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match -> - withContext(Dispatchers.Main) { - adapter.tracks[match.second].downloaded = true - adapter.notifyItemChanged( - adapter.getPositionOf( - SearchAdapter.ResultType.Track, - match.second - ) - ) - } - } - } - } - } - - inner class SearchResultClickListener : SearchAdapter.OnSearchResultClickListener { - override fun onArtistClick(holder: View?, artist: Artist) { - ArtistsFragment.openAlbums(this@SearchActivity, artist) - } - - override fun onAlbumClick(holder: View?, album: Album) { - AlbumsFragment.openTracks(this@SearchActivity, album) - } - } -} diff --git a/app/src/main/java/audio/funkwhale/ffa/adapters/SearchAdapter.kt b/app/src/main/java/audio/funkwhale/ffa/adapters/SearchAdapter.kt index 7d8c987..9710ef9 100644 --- a/app/src/main/java/audio/funkwhale/ffa/adapters/SearchAdapter.kt +++ b/app/src/main/java/audio/funkwhale/ffa/adapters/SearchAdapter.kt @@ -7,10 +7,10 @@ import android.graphics.PorterDuffColorFilter import android.graphics.Typeface import android.os.Build import android.view.Gravity -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu +import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView import audio.funkwhale.ffa.R import audio.funkwhale.ffa.databinding.RowSearchHeaderBinding @@ -24,11 +24,13 @@ import audio.funkwhale.ffa.utils.CoverArt import audio.funkwhale.ffa.utils.maybeNormalizeUrl import audio.funkwhale.ffa.utils.onApi import audio.funkwhale.ffa.utils.toast +import audio.funkwhale.ffa.viewmodel.SearchViewModel +import com.squareup.picasso.Picasso import jp.wasabeef.picasso.transformations.RoundedCornersTransformation class SearchAdapter( - private val layoutInflater: LayoutInflater, - private val context: Context?, + viewModel: SearchViewModel, + private val fragment: Fragment, private val listener: OnSearchResultClickListener, private val favoriteListener: FavoriteListener ) : RecyclerView.Adapter() { @@ -50,12 +52,27 @@ class SearchAdapter( val sectionCount = 3 - var artists: MutableList = mutableListOf() - var albums: MutableList = mutableListOf() - var tracks: MutableList = mutableListOf() + var artists = listOf() + var albums = listOf() + var tracks = listOf() var currentTrack: Track? = null + init { + viewModel.artistResults.observe(fragment.viewLifecycleOwner) { + artists = it + this.notifyDataSetChanged() + } + viewModel.albumResults.observe(fragment.viewLifecycleOwner) { + albums = it + this.notifyDataSetChanged() + } + viewModel.trackResults.observe(fragment.viewLifecycleOwner) { + tracks = it + this.notifyDataSetChanged() + } + } + override fun getItemCount() = sectionCount + artists.size + albums.size + tracks.size override fun getItemId(position: Int): Long { @@ -67,7 +84,7 @@ class SearchAdapter( } ResultType.Artist.ordinal -> artists[position].id.toLong() - ResultType.Artist.ordinal -> albums[position - artists.size - 2].id.toLong() + ResultType.Album.ordinal -> albums[position - artists.size - 2].id.toLong() ResultType.Track.ordinal -> tracks[position - artists.size - albums.size - sectionCount].id.toLong() else -> 0 @@ -86,12 +103,12 @@ class SearchAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return when (viewType) { ResultType.Header.ordinal -> { - searchHeaderBinding = RowSearchHeaderBinding.inflate(layoutInflater, parent, false) - SearchHeaderViewHolder(searchHeaderBinding, context) + searchHeaderBinding = RowSearchHeaderBinding.inflate(fragment.layoutInflater, parent, false) + SearchHeaderViewHolder(searchHeaderBinding, fragment.requireContext()) } else -> { - rowTrackBinding = RowTrackBinding.inflate(layoutInflater, parent, false) - RowTrackViewHolder(rowTrackBinding, context).also { + rowTrackBinding = RowTrackBinding.inflate(fragment.layoutInflater, parent, false) + RowTrackViewHolder(rowTrackBinding, fragment.requireContext()).also { rowTrackBinding.root.setOnClickListener(it) } } @@ -105,47 +122,45 @@ class SearchAdapter( val rowTrackViewHolder = holder as? RowTrackViewHolder if (resultType == ResultType.Header.ordinal) { - context?.let { context -> - if (position == 0) { - searchHeaderViewHolder?.title?.text = context.getString(R.string.artists) - holder.itemView.visibility = View.VISIBLE - holder.itemView.layoutParams = RecyclerView.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) + if (position == 0) { + searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.artists) + holder.itemView.visibility = View.VISIBLE + holder.itemView.layoutParams = RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) - if (artists.isEmpty()) { - holder.itemView.visibility = View.GONE - holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0) - } + if (artists.isEmpty()) { + holder.itemView.visibility = View.GONE + holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0) } + } - if (position == (artists.size + 1)) { - searchHeaderViewHolder?.title?.text = context.getString(R.string.albums) - holder.itemView.visibility = View.VISIBLE - holder.itemView.layoutParams = RecyclerView.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) + if (position == (artists.size + 1)) { + searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.albums) + holder.itemView.visibility = View.VISIBLE + holder.itemView.layoutParams = RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) - if (albums.isEmpty()) { - holder.itemView.visibility = View.GONE - holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0) - } + if (albums.isEmpty()) { + holder.itemView.visibility = View.GONE + holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0) } + } - if (position == (artists.size + albums.size + 2)) { - searchHeaderViewHolder?.title?.text = context.getString(R.string.tracks) - holder.itemView.visibility = View.VISIBLE - holder.itemView.layoutParams = RecyclerView.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) + if (position == (artists.size + albums.size + 2)) { + searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.tracks) + holder.itemView.visibility = View.VISIBLE + holder.itemView.layoutParams = RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) - if (tracks.isEmpty()) { - holder.itemView.visibility = View.GONE - holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0) - } + if (tracks.isEmpty()) { + holder.itemView.visibility = View.GONE + holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0) } } @@ -174,7 +189,7 @@ class SearchAdapter( else -> tracks[position] } - CoverArt.withContext(layoutInflater.context, maybeNormalizeUrl(item.cover())) + CoverArt.withContext(fragment.layoutInflater.context, maybeNormalizeUrl(item.cover())) .fit() .transform(RoundedCornersTransformation(16, 0)) .into(rowTrackViewHolder?.cover) @@ -216,90 +231,91 @@ class SearchAdapter( } ResultType.Track.ordinal -> { (item as? Track)?.let { track -> - context?.let { context -> - if (track == currentTrack || track.current) { - searchHeaderViewHolder?.title?.setTypeface( - searchHeaderViewHolder.title.typeface, - Typeface.BOLD - ) - rowTrackViewHolder?.artist?.setTypeface( - rowTrackViewHolder.artist.typeface, - Typeface.BOLD - ) + if (track == currentTrack || track.current) { + searchHeaderViewHolder?.title?.setTypeface( + searchHeaderViewHolder.title.typeface, + Typeface.BOLD + ) + rowTrackViewHolder?.artist?.setTypeface( + rowTrackViewHolder.artist.typeface, + Typeface.BOLD + ) + } + + when (track.favorite) { + true -> rowTrackViewHolder?.favorite?.setColorFilter( + fragment.requireContext().getColor(R.color.colorFavorite) + ) + false -> rowTrackViewHolder?.favorite?.setColorFilter( + fragment.requireContext().getColor(R.color.colorSelected) + ) + } + + rowTrackViewHolder?.favorite?.setOnClickListener { + favoriteListener.let { + favoriteListener.onToggleFavorite(track.id, !track.favorite) + + tracks[position - artists.size - albums.size - sectionCount].favorite = + !track.favorite + + notifyItemChanged(position) } + } - when (track.favorite) { - true -> rowTrackViewHolder?.favorite?.setColorFilter( - context.getColor(R.color.colorFavorite) - ) - false -> rowTrackViewHolder?.favorite?.setColorFilter( - context.getColor(R.color.colorSelected) - ) + when (track.cached || track.downloaded) { + true -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.downloaded, 0, 0, 0 + ) + false -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds( + 0, 0, 0, 0 + ) + } + + if (track.cached && !track.downloaded) { + rowTrackViewHolder?.title?.compoundDrawables?.forEach { + it?.colorFilter = + PorterDuffColorFilter( + fragment.requireContext().getColor(R.color.cached), + PorterDuff.Mode.SRC_IN + ) } + } - rowTrackViewHolder?.favorite?.setOnClickListener { - favoriteListener.let { - favoriteListener.onToggleFavorite(track.id, !track.favorite) - - tracks[position - artists.size - albums.size - sectionCount].favorite = - !track.favorite - - notifyItemChanged(position) - } + if (track.downloaded) { + rowTrackViewHolder?.title?.compoundDrawables?.forEach { + it?.colorFilter = + PorterDuffColorFilter( + fragment.requireContext().getColor(R.color.downloaded), + PorterDuff.Mode.SRC_IN + ) } + } - when (track.cached || track.downloaded) { - true -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds( - R.drawable.downloaded, 0, 0, 0 - ) - false -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds( - 0, 0, 0, 0 - ) - } + rowTrackViewHolder?.actions?.setOnClickListener { + PopupMenu( + fragment.requireContext(), + rowTrackViewHolder.actions, + Gravity.START, + R.attr.actionOverflowMenuStyle, + 0 + ).apply { + inflate(R.menu.row_track) - if (track.cached && !track.downloaded) { - rowTrackViewHolder?.title?.compoundDrawables?.forEach { - it?.colorFilter = - PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN) - } - } - - if (track.downloaded) { - rowTrackViewHolder?.title?.compoundDrawables?.forEach { - it?.colorFilter = - PorterDuffColorFilter( - context.getColor(R.color.downloaded), - PorterDuff.Mode.SRC_IN + setOnMenuItemClickListener { + when (it.itemId) { + R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track))) + R.id.track_play_next -> CommandBus.send(Command.PlayNext(track)) + R.id.track_pin -> CommandBus.send(Command.PinTrack(track)) + R.id.track_add_to_playlist -> CommandBus.send( + Command.AddToPlaylist(listOf(track)) ) - } - } - - rowTrackViewHolder?.actions?.setOnClickListener { - PopupMenu( - context, - rowTrackViewHolder.actions, - Gravity.START, - R.attr.actionOverflowMenuStyle, - 0 - ).apply { - inflate(R.menu.row_track) - - setOnMenuItemClickListener { - when (it.itemId) { - R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track))) - R.id.track_play_next -> CommandBus.send(Command.PlayNext(track)) - R.id.track_pin -> CommandBus.send(Command.PinTrack(track)) - R.id.track_add_to_playlist -> CommandBus.send( - Command.AddToPlaylist(listOf(track)) - ) - R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track)) - } - - true + R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track)) } - show() + true } + + show() } } } @@ -316,12 +332,12 @@ class SearchAdapter( } } - inner class SearchHeaderViewHolder(val binding: RowSearchHeaderBinding, context: Context?) : + inner class SearchHeaderViewHolder(val binding: RowSearchHeaderBinding, context: Context) : ViewHolder(binding.root, context) { val title = binding.title } - inner class RowTrackViewHolder(val binding: RowTrackBinding, context: Context?) : + inner class RowTrackViewHolder(val binding: RowTrackBinding, context: Context) : ViewHolder(binding.root, context), View.OnClickListener { val title = binding.title val cover = binding.cover diff --git a/app/src/main/java/audio/funkwhale/ffa/fragments/AlbumsFragment.kt b/app/src/main/java/audio/funkwhale/ffa/fragments/AlbumsFragment.kt index 0574b74..1d82783 100644 --- a/app/src/main/java/audio/funkwhale/ffa/fragments/AlbumsFragment.kt +++ b/app/src/main/java/audio/funkwhale/ffa/fragments/AlbumsFragment.kt @@ -1,36 +1,27 @@ package audio.funkwhale.ffa.fragments -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.AccelerateDecelerateInterpolator -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.CircularProgressDrawable -import androidx.transition.Fade -import androidx.transition.Slide import audio.funkwhale.ffa.R -import audio.funkwhale.ffa.activities.MainActivity import audio.funkwhale.ffa.adapters.AlbumsAdapter import audio.funkwhale.ffa.databinding.FragmentAlbumsBinding import audio.funkwhale.ffa.model.Album -import audio.funkwhale.ffa.model.Artist import audio.funkwhale.ffa.repositories.AlbumsRepository import audio.funkwhale.ffa.repositories.ArtistTracksRepository import audio.funkwhale.ffa.repositories.Repository -import audio.funkwhale.ffa.utils.AppContext import audio.funkwhale.ffa.utils.Command import audio.funkwhale.ffa.utils.CommandBus import audio.funkwhale.ffa.utils.CoverArt import audio.funkwhale.ffa.utils.maybeNormalizeUrl -import audio.funkwhale.ffa.utils.onViewPager import com.preference.PowerPreference import jp.wasabeef.picasso.transformations.RoundedCornersTransformation import kotlinx.coroutines.Dispatchers.IO @@ -45,77 +36,22 @@ class AlbumsFragment : FFAFragment() { override val recycler: RecyclerView get() = binding.albums override val alwaysRefresh = false + private val args by navArgs() + private val artistArt: String get() = when { + !args.cover.isNullOrBlank() -> args.cover!! + else -> args.artist.cover() ?: "" + } + private var _binding: FragmentAlbumsBinding? = null private val binding get() = _binding!! private lateinit var artistTracksRepository: ArtistTracksRepository - private var artistId = 0 - private var artistName = "" - private var artistArt = "" - - companion object { - fun new(artist: Artist, _art: String? = null): AlbumsFragment { - val art = _art ?: if (artist.albums?.isNotEmpty() == true) artist.cover() else "" - - return AlbumsFragment().apply { - arguments = bundleOf( - "artistId" to artist.id, - "artistName" to artist.name, - "artistArt" to art - ) - } - } - - fun openTracks(context: Context?, album: Album?, fragment: Fragment? = null) { - if (album == null) { - return - } - - (context as? MainActivity)?.let { - fragment?.let { fragment -> - fragment.onViewPager { - exitTransition = Fade().apply { - duration = AppContext.TRANSITION_DURATION - interpolator = AccelerateDecelerateInterpolator() - - view?.let { - addTarget(it) - } - } - } - } - } - - (context as? AppCompatActivity)?.let { activity -> - val nextFragment = TracksFragment.new(album).apply { - enterTransition = Slide().apply { - duration = AppContext.TRANSITION_DURATION - interpolator = AccelerateDecelerateInterpolator() - } - } - - activity.supportFragmentManager - .beginTransaction() - .replace(R.id.container, nextFragment) - .addToBackStack(null) - .commit() - } - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - arguments?.apply { - artistId = getInt("artistId") - artistName = getString("artistName") ?: "" - artistArt = getString("artistArt") ?: "" - } - adapter = AlbumsAdapter(layoutInflater, context, OnAlbumClickListener()) - repository = AlbumsRepository(context, artistId) - artistTracksRepository = ArtistTracksRepository(context, artistId) + repository = AlbumsRepository(context, args.artist.id) + artistTracksRepository = ArtistTracksRepository(context, args.artist.id) } override fun onCreateView( @@ -151,7 +87,7 @@ class AlbumsFragment : FFAFragment() { .into(cover) } - binding.artist.text = artistName + binding.artist.text = args.artist.name } override fun onResume() { @@ -209,7 +145,7 @@ class AlbumsFragment : FFAFragment() { inner class OnAlbumClickListener : AlbumsAdapter.OnAlbumClickListener { override fun onClick(view: View?, album: Album) { - openTracks(context, album, fragment = this@AlbumsFragment) + findNavController().navigate(AlbumsFragmentDirections.albumsToTracks(album)) } } } diff --git a/app/src/main/java/audio/funkwhale/ffa/fragments/AlbumsGridFragment.kt b/app/src/main/java/audio/funkwhale/ffa/fragments/AlbumsGridFragment.kt index 4259e88..023dbbb 100644 --- a/app/src/main/java/audio/funkwhale/ffa/fragments/AlbumsGridFragment.kt +++ b/app/src/main/java/audio/funkwhale/ffa/fragments/AlbumsGridFragment.kt @@ -4,18 +4,13 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.AccelerateDecelerateInterpolator +import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView -import androidx.transition.Fade -import androidx.transition.Slide -import audio.funkwhale.ffa.R -import audio.funkwhale.ffa.activities.MainActivity import audio.funkwhale.ffa.adapters.AlbumsGridAdapter import audio.funkwhale.ffa.databinding.FragmentAlbumsGridBinding import audio.funkwhale.ffa.model.Album import audio.funkwhale.ffa.repositories.AlbumsRepository -import audio.funkwhale.ffa.utils.AppContext class AlbumsGridFragment : FFAFragment() { @@ -49,29 +44,7 @@ class AlbumsGridFragment : FFAFragment() { inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener { override fun onClick(view: View?, album: Album) { - (context as? MainActivity)?.let { activity -> - exitTransition = Fade().apply { - duration = AppContext.TRANSITION_DURATION - interpolator = AccelerateDecelerateInterpolator() - - view?.let { - addTarget(it) - } - } - - val fragment = TracksFragment.new(album).apply { - enterTransition = Slide().apply { - duration = AppContext.TRANSITION_DURATION - interpolator = AccelerateDecelerateInterpolator() - } - } - - activity.supportFragmentManager - .beginTransaction() - .replace(R.id.container, fragment) - .addToBackStack(null) - .commit() - } + findNavController().navigate(BrowseFragmentDirections.browseToTracks(album)) } } } diff --git a/app/src/main/java/audio/funkwhale/ffa/fragments/ArtistsFragment.kt b/app/src/main/java/audio/funkwhale/ffa/fragments/ArtistsFragment.kt index c2ef420..91af29a 100644 --- a/app/src/main/java/audio/funkwhale/ffa/fragments/ArtistsFragment.kt +++ b/app/src/main/java/audio/funkwhale/ffa/fragments/ArtistsFragment.kt @@ -1,27 +1,17 @@ package audio.funkwhale.ffa.fragments -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.AccelerateDecelerateInterpolator -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView -import androidx.transition.Fade -import androidx.transition.Slide -import audio.funkwhale.ffa.R -import audio.funkwhale.ffa.activities.MainActivity import audio.funkwhale.ffa.adapters.ArtistsAdapter import audio.funkwhale.ffa.databinding.FragmentArtistsBinding import audio.funkwhale.ffa.model.Artist import audio.funkwhale.ffa.repositories.ArtistsRepository -import audio.funkwhale.ffa.utils.AppContext -import audio.funkwhale.ffa.utils.onViewPager class ArtistsFragment : FFAFragment() { - private var _binding: FragmentArtistsBinding? = null private val binding get() = _binding!! @@ -50,49 +40,9 @@ class ArtistsFragment : FFAFragment() { _binding = null } - companion object { - - fun openAlbums( - context: Context?, - artist: Artist, - fragment: Fragment? = null, - art: String? = null - ) { - (context as? MainActivity)?.let { - fragment?.let { fragment -> - fragment.onViewPager { - exitTransition = Fade().apply { - duration = AppContext.TRANSITION_DURATION - interpolator = AccelerateDecelerateInterpolator() - - view?.let { - addTarget(it) - } - } - } - } - } - - (context as? AppCompatActivity)?.let { activity -> - val nextFragment = AlbumsFragment.new(artist, art).apply { - enterTransition = Slide().apply { - duration = AppContext.TRANSITION_DURATION - interpolator = AccelerateDecelerateInterpolator() - } - } - - activity.supportFragmentManager - .beginTransaction() - .replace(R.id.container, nextFragment) - .addToBackStack(null) - .commit() - } - } - } - inner class OnArtistClickListener : ArtistsAdapter.OnArtistClickListener { override fun onClick(holder: View?, artist: Artist) { - openAlbums(context, artist, fragment = this@ArtistsFragment) + findNavController().navigate(BrowseFragmentDirections.browseToAlbums(artist)) } } } diff --git a/app/src/main/java/audio/funkwhale/ffa/fragments/BrowseFragment.kt b/app/src/main/java/audio/funkwhale/ffa/fragments/BrowseFragment.kt index 996c622..aa233e7 100644 --- a/app/src/main/java/audio/funkwhale/ffa/fragments/BrowseFragment.kt +++ b/app/src/main/java/audio/funkwhale/ffa/fragments/BrowseFragment.kt @@ -14,13 +14,6 @@ class BrowseFragment : Fragment() { private var _binding: FragmentBrowseBinding? = null private val binding get() = _binding!! - private var adapter: BrowseTabsAdapter? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - adapter = BrowseTabsAdapter(this) - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -30,10 +23,11 @@ class BrowseFragment : Fragment() { return binding.root.apply { binding.tabs.getTabAt(0)?.select() + val adapter = BrowseTabsAdapter(this@BrowseFragment) binding.pager.adapter = adapter binding.pager.offscreenPageLimit = 3 TabLayoutMediator(binding.tabs, binding.pager) { tab, position -> - tab.text = adapter?.tabText(position) + tab.text = adapter.tabText(position) }.attach() } } @@ -42,8 +36,4 @@ class BrowseFragment : Fragment() { super.onDestroyView() _binding = null } - - fun selectTabAt(position: Int) { - binding.tabs.getTabAt(position)?.select() - } } diff --git a/app/src/main/java/audio/funkwhale/ffa/fragments/PlaylistTracksFragment.kt b/app/src/main/java/audio/funkwhale/ffa/fragments/PlaylistTracksFragment.kt index 61a6545..fb57d4c 100644 --- a/app/src/main/java/audio/funkwhale/ffa/fragments/PlaylistTracksFragment.kt +++ b/app/src/main/java/audio/funkwhale/ffa/fragments/PlaylistTracksFragment.kt @@ -6,14 +6,13 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu -import androidx.core.os.bundleOf import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import audio.funkwhale.ffa.R import audio.funkwhale.ffa.adapters.FavoriteListener import audio.funkwhale.ffa.adapters.PlaylistTracksAdapter import audio.funkwhale.ffa.databinding.FragmentTracksBinding -import audio.funkwhale.ffa.model.Playlist import audio.funkwhale.ffa.model.PlaylistTrack import audio.funkwhale.ffa.model.Track import audio.funkwhale.ffa.repositories.FavoritesRepository @@ -33,43 +32,19 @@ import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch class PlaylistTracksFragment : FFAFragment() { - override val recycler: RecyclerView get() = binding.tracks + private val args by navArgs() + private var _binding: FragmentTracksBinding? = null private val binding get() = _binding!! lateinit var favoritesRepository: FavoritesRepository lateinit var playlistsRepository: ManagementPlaylistsRepository - var albumId = 0 - var albumArtist = "" - var albumTitle = "" - var albumCover = "" - - companion object { - fun new(playlist: Playlist): PlaylistTracksFragment { - return PlaylistTracksFragment().apply { - arguments = bundleOf( - "albumId" to playlist.id, - "albumArtist" to "N/A", - "albumTitle" to playlist.name, - "albumCover" to "" - ) - } - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - arguments?.apply { - albumId = getInt("albumId") - albumArtist = getString("albumArtist") ?: "" - albumTitle = getString("albumTitle") ?: "" - albumCover = getString("albumCover") ?: "" - } - favoritesRepository = FavoritesRepository(context) playlistsRepository = ManagementPlaylistsRepository(context) @@ -79,7 +54,7 @@ class PlaylistTracksFragment : FFAFragment FavoriteListener(favoritesRepository), PlaylistListener() ) - repository = PlaylistTracksRepository(context, albumId) + repository = PlaylistTracksRepository(context, args.playlist.id) watchEventBus() } @@ -105,8 +80,8 @@ class PlaylistTracksFragment : FFAFragment binding.cover.visibility = View.INVISIBLE binding.covers.visibility = View.VISIBLE - binding.artist.text = "Playlist" - binding.title.text = albumTitle + binding.artist.text = getString(R.string.playlist) + binding.title.text = args.playlist.name } override fun onResume() { @@ -216,12 +191,12 @@ class PlaylistTracksFragment : FFAFragment inner class PlaylistListener : PlaylistTracksAdapter.OnPlaylistListener { override fun onMoveTrack(from: Int, to: Int) { - playlistsRepository.move(albumId, from, to) + playlistsRepository.move(args.playlist.id, from, to) } override fun onRemoveTrackFromPlaylist(track: Track, index: Int) { lifecycleScope.launch(Main) { - playlistsRepository.remove(albumId, index) + playlistsRepository.remove(args.playlist.id, index) update() } } diff --git a/app/src/main/java/audio/funkwhale/ffa/fragments/PlaylistsFragment.kt b/app/src/main/java/audio/funkwhale/ffa/fragments/PlaylistsFragment.kt index 25cff0d..24c910c 100644 --- a/app/src/main/java/audio/funkwhale/ffa/fragments/PlaylistsFragment.kt +++ b/app/src/main/java/audio/funkwhale/ffa/fragments/PlaylistsFragment.kt @@ -4,17 +4,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.AccelerateDecelerateInterpolator +import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView -import androidx.transition.Fade -import androidx.transition.Slide -import audio.funkwhale.ffa.R -import audio.funkwhale.ffa.activities.MainActivity import audio.funkwhale.ffa.adapters.PlaylistsAdapter import audio.funkwhale.ffa.databinding.FragmentPlaylistsBinding import audio.funkwhale.ffa.model.Playlist import audio.funkwhale.ffa.repositories.PlaylistsRepository -import audio.funkwhale.ffa.utils.AppContext class PlaylistsFragment : FFAFragment() { @@ -48,29 +43,7 @@ class PlaylistsFragment : FFAFragment() { inner class OnPlaylistClickListener : PlaylistsAdapter.OnPlaylistClickListener { override fun onClick(holder: View?, playlist: Playlist) { - (context as? MainActivity)?.let { activity -> - exitTransition = Fade().apply { - duration = AppContext.TRANSITION_DURATION - interpolator = AccelerateDecelerateInterpolator() - - view?.let { - addTarget(it) - } - } - - val fragment = PlaylistTracksFragment.new(playlist).apply { - enterTransition = Slide().apply { - duration = AppContext.TRANSITION_DURATION - interpolator = AccelerateDecelerateInterpolator() - } - } - - activity.supportFragmentManager - .beginTransaction() - .replace(R.id.container, fragment) - .addToBackStack(null) - .commit() - } + findNavController().navigate(BrowseFragmentDirections.browseToPlaylistTracks(playlist)) } } } diff --git a/app/src/main/java/audio/funkwhale/ffa/fragments/SearchFragment.kt b/app/src/main/java/audio/funkwhale/ffa/fragments/SearchFragment.kt new file mode 100644 index 0000000..65b0ee0 --- /dev/null +++ b/app/src/main/java/audio/funkwhale/ffa/fragments/SearchFragment.kt @@ -0,0 +1,136 @@ +package audio.funkwhale.ffa.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import audio.funkwhale.ffa.adapters.FavoriteListener +import audio.funkwhale.ffa.adapters.SearchAdapter +import audio.funkwhale.ffa.databinding.FragmentSearchBinding +import audio.funkwhale.ffa.model.Album +import audio.funkwhale.ffa.model.Artist +import audio.funkwhale.ffa.repositories.FavoritesRepository +import audio.funkwhale.ffa.utils.Command +import audio.funkwhale.ffa.utils.CommandBus +import audio.funkwhale.ffa.utils.Event +import audio.funkwhale.ffa.utils.EventBus +import audio.funkwhale.ffa.utils.getMetadata +import audio.funkwhale.ffa.viewmodel.SearchViewModel +import com.google.android.exoplayer2.offline.Download +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SearchFragment : Fragment() { + private lateinit var adapter: SearchAdapter + private lateinit var binding: FragmentSearchBinding + private val viewModel by activityViewModels() + private val noSearchYet = MutableLiveData(true) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentSearchBinding.inflate(layoutInflater, container, false) + binding.lifecycleOwner = this + binding.isLoadingData = viewModel.isLoadingData + binding.hasResults = viewModel.hasResults + binding.noSearchYet = noSearchYet + return binding.root + } + + override fun onResume() { + super.onResume() + binding.search.requestFocus() + + lifecycleScope.launch(Dispatchers.Main) { + CommandBus.get().collect { command -> + if (command is Command.AddToPlaylist) { + + if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + AddToPlaylistDialog.show( + layoutInflater, + requireActivity(), + lifecycleScope, + command.tracks + ) + } + } + } + } + + lifecycleScope.launch(Dispatchers.IO) { + EventBus.get().collect { event -> + if (event is Event.DownloadChanged) refreshDownloadedTrack(event.download) + } + } + + adapter = + SearchAdapter( + viewModel, + this, + SearchResultClickListener(), + FavoriteListener(FavoritesRepository(requireContext())) + ).also { + binding.results.layoutManager = LinearLayoutManager(requireContext()) + binding.results.adapter = it + } + + binding.search.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + + override fun onQueryTextSubmit(query: String): Boolean { + binding.search.clearFocus() + noSearchYet.value = false + viewModel.query.postValue(query) + + return true + } + + override fun onQueryTextChange(newText: String) = true + }) + } + + override fun onDestroy() { + super.onDestroy() + // Empty the research to prevent result recall the next time + viewModel.query.value = "" + } + + private suspend fun refreshDownloadedTrack(download: Download) { + if (download.state == Download.STATE_COMPLETED) { + download.getMetadata()?.let { info -> + adapter.tracks.withIndex().associate { it.value to it.index } + .filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match -> + withContext(Dispatchers.Main) { + adapter.tracks[match.second].downloaded = true + adapter.notifyItemChanged( + adapter.getPositionOf( + SearchAdapter.ResultType.Track, + match.second + ) + ) + } + } + } + } + } + + inner class SearchResultClickListener : SearchAdapter.OnSearchResultClickListener { + override fun onArtistClick(holder: View?, artist: Artist) { + findNavController().navigate(SearchFragmentDirections.searchToAlbums(artist)) + } + + override fun onAlbumClick(holder: View?, album: Album) { + findNavController().navigate(SearchFragmentDirections.searchToTracks(album)) + } + } +} diff --git a/app/src/main/java/audio/funkwhale/ffa/fragments/TracksFragment.kt b/app/src/main/java/audio/funkwhale/ffa/fragments/TracksFragment.kt index e13881d..370a291 100644 --- a/app/src/main/java/audio/funkwhale/ffa/fragments/TracksFragment.kt +++ b/app/src/main/java/audio/funkwhale/ffa/fragments/TracksFragment.kt @@ -8,14 +8,13 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu -import androidx.core.os.bundleOf import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import audio.funkwhale.ffa.R import audio.funkwhale.ffa.adapters.FavoriteListener import audio.funkwhale.ffa.adapters.TracksAdapter import audio.funkwhale.ffa.databinding.FragmentTracksBinding -import audio.funkwhale.ffa.model.Album import audio.funkwhale.ffa.model.Track import audio.funkwhale.ffa.repositories.FavoritedRepository import audio.funkwhale.ffa.repositories.FavoritesRepository @@ -43,7 +42,7 @@ import kotlinx.coroutines.withContext import org.koin.java.KoinJavaComponent.inject class TracksFragment : FFAFragment() { - + private val args by navArgs() private val exoDownloadManager: DownloadManager by inject(DownloadManager::class.java) override val recycler: RecyclerView get() = binding.tracks @@ -54,37 +53,12 @@ class TracksFragment : FFAFragment() { private lateinit var favoritesRepository: FavoritesRepository private lateinit var favoritedRepository: FavoritedRepository - private var albumId = 0 - private var albumArtist = "" - private var albumTitle = "" - private var albumCover = "" - - companion object { - fun new(album: Album): TracksFragment { - return TracksFragment().apply { - arguments = bundleOf( - "albumId" to album.id, - "albumArtist" to album.artist.name, - "albumTitle" to album.title, - "albumCover" to album.cover() - ) - } - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - arguments?.apply { - albumId = getInt("albumId") - albumArtist = getString("albumArtist") ?: "" - albumTitle = getString("albumTitle") ?: "" - albumCover = getString("albumCover") ?: "" - } - favoritesRepository = FavoritesRepository(context) favoritedRepository = FavoritedRepository(context) - repository = TracksRepository(context, albumId) + repository = TracksRepository(context, args.album.id) adapter = TracksAdapter(layoutInflater, context, FavoriteListener(favoritesRepository)) @@ -144,15 +118,15 @@ class TracksFragment : FFAFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - CoverArt.withContext(layoutInflater.context, maybeNormalizeUrl(albumCover)) + CoverArt.withContext(layoutInflater.context, maybeNormalizeUrl(args.album.cover())) .noFade() .fit() .centerCrop() .transform(RoundedCornersTransformation(16, 0)) .into(binding.cover) - binding.artist.text = albumArtist - binding.title.text = albumTitle + binding.artist.text = args.album.artist.name + binding.title.text = args.album.title } override fun onResume() { diff --git a/app/src/main/java/audio/funkwhale/ffa/model/Album.kt b/app/src/main/java/audio/funkwhale/ffa/model/Album.kt index cf71c32..eb99bd7 100644 --- a/app/src/main/java/audio/funkwhale/ffa/model/Album.kt +++ b/app/src/main/java/audio/funkwhale/ffa/model/Album.kt @@ -1,13 +1,18 @@ package audio.funkwhale.ffa.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class Album( val id: Int, val artist: Artist, val title: String, val cover: Covers?, val release_date: String? -) : SearchResult { - data class Artist(val name: String) +) : SearchResult, Parcelable { + @Parcelize + data class Artist(val name: String) : Parcelable override fun cover() = cover?.urls?.original override fun title() = title diff --git a/app/src/main/java/audio/funkwhale/ffa/model/Artist.kt b/app/src/main/java/audio/funkwhale/ffa/model/Artist.kt index ce76ca8..8b358f3 100644 --- a/app/src/main/java/audio/funkwhale/ffa/model/Artist.kt +++ b/app/src/main/java/audio/funkwhale/ffa/model/Artist.kt @@ -1,17 +1,21 @@ package audio.funkwhale.ffa.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import java.util.Calendar.DAY_OF_YEAR import java.util.GregorianCalendar +@Parcelize data class Artist( val id: Int, val name: String, val albums: List? -) : SearchResult { +) : SearchResult, Parcelable { + @Parcelize data class Album( val title: String, val cover: Covers? - ) + ) : Parcelable override fun cover(): String? = albums?.mapNotNull { it.cover?.urls?.original }?.let { covers -> if (covers.isEmpty()) { @@ -21,6 +25,7 @@ data class Artist( val index = GregorianCalendar().get(DAY_OF_YEAR) % covers.size covers.getOrNull(index) } + override fun title() = name override fun subtitle() = "Artist" } diff --git a/app/src/main/java/audio/funkwhale/ffa/model/CoverUrls.kt b/app/src/main/java/audio/funkwhale/ffa/model/CoverUrls.kt index 8efb18c..820f3b6 100644 --- a/app/src/main/java/audio/funkwhale/ffa/model/CoverUrls.kt +++ b/app/src/main/java/audio/funkwhale/ffa/model/CoverUrls.kt @@ -1,3 +1,7 @@ package audio.funkwhale.ffa.model -data class CoverUrls(val original: String) +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CoverUrls(val original: String) : Parcelable diff --git a/app/src/main/java/audio/funkwhale/ffa/model/Covers.kt b/app/src/main/java/audio/funkwhale/ffa/model/Covers.kt index 5271778..111eed2 100644 --- a/app/src/main/java/audio/funkwhale/ffa/model/Covers.kt +++ b/app/src/main/java/audio/funkwhale/ffa/model/Covers.kt @@ -1,3 +1,7 @@ package audio.funkwhale.ffa.model -data class Covers(val urls: CoverUrls) +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Covers(val urls: CoverUrls) : Parcelable diff --git a/app/src/main/java/audio/funkwhale/ffa/model/Playlist.kt b/app/src/main/java/audio/funkwhale/ffa/model/Playlist.kt index 725a3ba..21fcdf9 100644 --- a/app/src/main/java/audio/funkwhale/ffa/model/Playlist.kt +++ b/app/src/main/java/audio/funkwhale/ffa/model/Playlist.kt @@ -1,9 +1,13 @@ package audio.funkwhale.ffa.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class Playlist( val id: Int, val name: String, val album_covers: List, val tracks_count: Int, val duration: Int -) +) : Parcelable diff --git a/app/src/main/java/audio/funkwhale/ffa/model/Track.kt b/app/src/main/java/audio/funkwhale/ffa/model/Track.kt index 5a80fe6..a70b91d 100644 --- a/app/src/main/java/audio/funkwhale/ffa/model/Track.kt +++ b/app/src/main/java/audio/funkwhale/ffa/model/Track.kt @@ -1,8 +1,12 @@ package audio.funkwhale.ffa.model +import android.os.Parcelable import audio.funkwhale.ffa.utils.containsIgnoringCase import com.preference.PowerPreference +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +@Parcelize data class Track( val id: Int = 0, val title: String, @@ -13,10 +17,17 @@ data class Track( val uploads: List = listOf(), val copyright: String? = null, val license: String? = null -) : SearchResult { +) : SearchResult, Parcelable { + @IgnoredOnParcel var current: Boolean = false + + @IgnoredOnParcel var favorite: Boolean = false + + @IgnoredOnParcel var cached: Boolean = false + + @IgnoredOnParcel var downloaded: Boolean = false companion object { @@ -30,7 +41,8 @@ data class Track( ) } - data class Upload(val listen_url: String, val duration: Int, val bitrate: Int) + @Parcelize + data class Upload(val listen_url: String, val duration: Int, val bitrate: Int) : Parcelable fun matchesFilter(filter: String): Boolean { return title.containsIgnoringCase(filter) || diff --git a/app/src/main/java/audio/funkwhale/ffa/utils/Extensions.kt b/app/src/main/java/audio/funkwhale/ffa/utils/Extensions.kt index d649766..9a81a6d 100644 --- a/app/src/main/java/audio/funkwhale/ffa/utils/Extensions.kt +++ b/app/src/main/java/audio/funkwhale/ffa/utils/Extensions.kt @@ -3,8 +3,8 @@ package audio.funkwhale.ffa.utils import android.content.Context import android.os.Build import android.util.Log -import androidx.fragment.app.Fragment -import audio.funkwhale.ffa.fragments.BrowseFragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData import audio.funkwhale.ffa.model.DownloadInfo import audio.funkwhale.ffa.repositories.Repository import com.github.kittinunf.fuel.core.FuelError @@ -34,14 +34,6 @@ inline fun Flow>.untilNetwork( } } -fun Fragment.onViewPager(block: Fragment.() -> Unit) { - for (f in activity?.supportFragmentManager?.fragments ?: listOf()) { - if (f is BrowseFragment) { - f.block() - } - } -} - fun Int.onApi(block: () -> T) { if (Build.VERSION.SDK_INT >= this) { block() @@ -107,3 +99,53 @@ fun Date.format(): String { fun String?.containsIgnoringCase(candidate: String): Boolean = this != null && this.lowercase().contains(candidate.lowercase()) + +inline fun LiveData.mergeWith( + u: LiveData, + v: LiveData, + crossinline block: (valT: T, valU: U, valV: V) -> R +): LiveData = MediatorLiveData().apply { + addSource(this@mergeWith) { + if (u.value != null && v.value != null) { + postValue(block(it, u.value!!, v.value!!)) + } + } + addSource(u) { + if (this@mergeWith.value != null && u.value != null) { + postValue(block(this@mergeWith.value!!, it, v.value!!)) + } + } + addSource(v) { + if (this@mergeWith.value != null && u.value != null) { + postValue(block(this@mergeWith.value!!, u.value!!, it)) + } + } +} + +inline fun LiveData.mergeWith( + u: LiveData, + v: LiveData, + w: LiveData, + crossinline block: (valT: T, valU: U, valV: V, valW: W) -> R +): LiveData = MediatorLiveData().apply { + addSource(this@mergeWith) { + if (u.value != null && v.value != null && w.value != null) { + postValue(block(it, u.value!!, v.value!!, w.value!!)) + } + } + addSource(u) { + if (this@mergeWith.value != null && v.value != null && w.value != null) { + postValue(block(this@mergeWith.value!!, it, v.value!!, w.value!!)) + } + } + addSource(v) { + if (this@mergeWith.value != null && u.value != null && w.value != null) { + postValue(block(this@mergeWith.value!!, u.value!!, it, w.value!!)) + } + } + addSource(w) { + if (this@mergeWith.value != null && u.value != null && v.value != null) { + postValue(block(this@mergeWith.value!!, u.value!!, v.value!!, it)) + } + } +} diff --git a/app/src/main/java/audio/funkwhale/ffa/viewmodel/SearchViewModel.kt b/app/src/main/java/audio/funkwhale/ffa/viewmodel/SearchViewModel.kt new file mode 100644 index 0000000..949ae41 --- /dev/null +++ b/app/src/main/java/audio/funkwhale/ffa/viewmodel/SearchViewModel.kt @@ -0,0 +1,118 @@ +package audio.funkwhale.ffa.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +import audio.funkwhale.ffa.FFA +import audio.funkwhale.ffa.model.Album +import audio.funkwhale.ffa.model.Artist +import audio.funkwhale.ffa.model.Track +import audio.funkwhale.ffa.repositories.AlbumsSearchRepository +import audio.funkwhale.ffa.repositories.ArtistsSearchRepository +import audio.funkwhale.ffa.repositories.Repository +import audio.funkwhale.ffa.repositories.TracksSearchRepository +import audio.funkwhale.ffa.utils.mergeWith +import audio.funkwhale.ffa.utils.untilNetwork +import kotlinx.coroutines.Dispatchers +import java.net.URLEncoder +import java.util.Locale + +class SearchViewModel(app: Application) : AndroidViewModel(app), Observer { + private val artistResultsLoading = MutableLiveData(false) + private val albumResultsLoading = MutableLiveData(false) + private val tackResultsLoading = MutableLiveData(false) + + private val artistsRepository = + ArtistsSearchRepository(getApplication().applicationContext, "") + private val albumsRepository = + AlbumsSearchRepository(getApplication().applicationContext, "") + private val tracksRepository = + TracksSearchRepository(getApplication().applicationContext, "") + + private val dedupQuery: LiveData + + val query = MutableLiveData("") + + val artistResults: LiveData> = MutableLiveData(listOf()) + val albumResults: LiveData> = MutableLiveData(listOf()) + val trackResults: LiveData> = MutableLiveData(listOf()) + + val isLoadingData: LiveData = artistResultsLoading.mergeWith( + albumResultsLoading, tackResultsLoading + ) { b1, b2, b3 -> b1 || b2 || b3 } + + val hasResults: LiveData = isLoadingData.mergeWith( + artistResults, albumResults, trackResults + ) { b, r1, r2, r3 -> b || r1.isNotEmpty() || r2.isNotEmpty() || r3.isNotEmpty() } + + init { + dedupQuery = query.map { it.trim().lowercase(Locale.ROOT) }.distinctUntilChanged() + dedupQuery.observeForever(this) + } + + override fun onChanged(token: String) { + if (token.isBlank()) { // Empty search + (artistResults as MutableLiveData).postValue(listOf()) + (albumResults as MutableLiveData).postValue(listOf()) + (trackResults as MutableLiveData).postValue(listOf()) + return + } + + artistResultsLoading.postValue(true) + albumResultsLoading.postValue(true) + tackResultsLoading.postValue(true) + + val encoded = URLEncoder.encode(token, "UTF-8") + + (artistResults as MutableLiveData).postValue(listOf()) + artistsRepository.apply { + query = encoded + fetch(Repository.Origin.Network.origin).untilNetwork( + viewModelScope, + Dispatchers.IO + ) { data, _, _, hasMore -> + artistResults.postValue(artistResults.value!! + data) + if (!hasMore) { + artistResultsLoading.postValue(false) + } + } + } + + (albumResults as MutableLiveData).postValue(listOf()) + albumsRepository.apply { + query = encoded + fetch(Repository.Origin.Network.origin).untilNetwork( + viewModelScope, + Dispatchers.IO + ) { data, _, _, hasMore -> + albumResults.postValue(albumResults.value!! + data) + if (!hasMore) { + albumResultsLoading.postValue(false) + } + } + } + + (trackResults as MutableLiveData).postValue(listOf()) + tracksRepository.apply { + query = encoded + fetch(Repository.Origin.Network.origin).untilNetwork( + viewModelScope, + Dispatchers.IO + ) { data, _, _, hasMore -> + trackResults.postValue(trackResults.value!! + data) + if (!hasMore) { + tackResultsLoading.postValue(false) + } + } + } + } + + override fun onCleared() { + dedupQuery.removeObserver(this) + } +} diff --git a/app/src/main/res/anim/delayed_fade_out.xml b/app/src/main/res/anim/delayed_fade_out.xml new file mode 100644 index 0000000..36e2900 --- /dev/null +++ b/app/src/main/res/anim/delayed_fade_out.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml new file mode 100644 index 0000000..112489b --- /dev/null +++ b/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml new file mode 100644 index 0000000..bb26e5d --- /dev/null +++ b/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/anim/none.xml b/app/src/main/res/anim/none.xml new file mode 100644 index 0000000..5dbb9fa --- /dev/null +++ b/app/src/main/res/anim/none.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/anim/slide_down.xml b/app/src/main/res/anim/slide_down.xml new file mode 100644 index 0000000..79046ff --- /dev/null +++ b/app/src/main/res/anim/slide_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/anim/slide_up.xml b/app/src/main/res/anim/slide_up.xml new file mode 100644 index 0000000..48febe5 --- /dev/null +++ b/app/src/main/res/anim/slide_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 4f95463..4834d5c 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -11,13 +11,16 @@ android:baselineAligned="false" android:orientation="horizontal"> - + - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml new file mode 100644 index 0000000..0216f42 --- /dev/null +++ b/app/src/main/res/layout/fragment_search.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/main_nav.xml b/app/src/main/res/navigation/main_nav.xml new file mode 100644 index 0000000..6d79150 --- /dev/null +++ b/app/src/main/res/navigation/main_nav.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/config.xml b/app/src/main/res/values/config.xml new file mode 100644 index 0000000..b2ea5d4 --- /dev/null +++ b/app/src/main/res/values/config.xml @@ -0,0 +1,4 @@ + + + 300 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b4e30c..b99a05d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -127,4 +127,7 @@ Downloading %1$d track Downloading %1$d tracks + + Hello blank fragment + Playlist diff --git a/build.gradle.kts b/build.gradle.kts index 54d9de5..1060077 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,8 @@ buildscript { + extra.apply{ + set("navVersion", "2.5.2") + set("lifecycleVersion", "2.5.1") + } repositories { google() @@ -6,20 +10,22 @@ buildscript { gradlePluginPortal() } + val navVersion: String by extra + dependencies { classpath("com.android.tools.build:gradle:7.3.1") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0") classpath("com.github.bjoernq:unmockplugin:0.7.9") classpath("com.github.ben-manes:gradle-versions-plugin:0.44.0") classpath("org.jacoco:org.jacoco.core:0.8.8") + classpath("androidx.navigation:navigation-safe-args-gradle-plugin:$navVersion") } } allprojects { - repositories { google() - jcenter() + mavenCentral() maven(url = "https://jitpack.io") } } diff --git a/changes/changelog.d/107.bugfix b/changes/changelog.d/107.bugfix new file mode 100644 index 0000000..25dade7 --- /dev/null +++ b/changes/changelog.d/107.bugfix @@ -0,0 +1 @@ +Make the mini player overlay stay on top (contributed by @christophehenry)