Keep the player always on top

This commit is contained in:
Ryan Harg 2023-01-10 12:56:20 +00:00
parent bdbe14278e
commit c10b3d4a75
37 changed files with 857 additions and 783 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
*.iml *.iml
**.gradle **/.gradle
/local.properties /local.properties
/.idea /.idea
.DS_Store .DS_Store

View File

@ -5,12 +5,15 @@ import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
id("androidx.navigation.safeargs.kotlin")
id("kotlin-parcelize")
id("org.jlleitschuh.gradle.ktlint") version "11.0.0" id("org.jlleitschuh.gradle.ktlint") version "11.0.0"
id("com.gladed.androidgitversion") version "0.4.14" id("com.gladed.androidgitversion") version "0.4.14"
id("com.github.triplet.play") version "3.7.0" id("com.github.triplet.play") version "3.7.0"
id("de.mobilej.unmock") id("de.mobilej.unmock")
id("com.github.ben-manes.versions") id("com.github.ben-manes.versions")
id("org.jetbrains.kotlin.android")
jacoco jacoco
} }
@ -48,6 +51,7 @@ android {
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
dataBinding = true
} }
packagingOptions { packagingOptions {
@ -158,6 +162,9 @@ play {
} }
dependencies { 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(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0")
@ -166,7 +173,8 @@ dependencies {
implementation("androidx.appcompat:appcompat:1.4.2") implementation("androidx.appcompat:appcompat:1.4.2")
implementation("androidx.core:core-ktx:1.9.0") 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.coordinatorlayout:coordinatorlayout:1.2.0")
implementation("androidx.preference:preference-ktx:1.2.0") implementation("androidx.preference:preference-ktx:1.2.0")
implementation("androidx.recyclerview:recyclerview:1.2.1") implementation("androidx.recyclerview:recyclerview:1.2.1")
@ -189,7 +197,7 @@ dependencies {
isTransitive = false 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:2.3.1")
implementation("com.github.kittinunf.fuel:fuel-coroutines:2.3.1") implementation("com.github.kittinunf.fuel:fuel-coroutines:2.3.1")
implementation("com.github.kittinunf.fuel:fuel-android: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("jp.wasabeef:picasso-transformations:2.4.0")
implementation("net.openid:appauth:0.11.1") 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("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.3") testImplementation("io.mockk:mockk:1.13.3")
testImplementation("androidx.test:core:1.5.0") testImplementation("androidx.test:core:1.5.0")
@ -206,6 +218,7 @@ dependencies {
testImplementation("org.robolectric:robolectric:4.9.2") testImplementation("org.robolectric:robolectric:4.9.2")
androidTestImplementation("io.mockk:mockk-android:1.13.3") androidTestImplementation("io.mockk:mockk-android:1.13.3")
androidTestImplementation("androidx.navigation:navigation-testing:$navVersion")
} }
project.afterEvaluate { project.afterEvaluate {

View File

@ -44,10 +44,6 @@
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<activity
android:name=".activities.SearchActivity"
android:launchMode="singleTop" />
<activity <activity
android:name=".activities.DownloadsActivity" android:name=".activities.DownloadsActivity"
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />

View File

@ -16,23 +16,23 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.SeekBar import android.widget.SeekBar
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import audio.funkwhale.ffa.FFA import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.R import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.ActivityMainBinding import audio.funkwhale.ffa.databinding.ActivityMainBinding
import audio.funkwhale.ffa.fragments.AddToPlaylistDialog import audio.funkwhale.ffa.fragments.AddToPlaylistDialog
import audio.funkwhale.ffa.fragments.AlbumsFragment import audio.funkwhale.ffa.fragments.BrowseFragmentDirections
import audio.funkwhale.ffa.fragments.ArtistsFragment
import audio.funkwhale.ffa.fragments.BrowseFragment
import audio.funkwhale.ffa.fragments.LandscapeQueueFragment import audio.funkwhale.ffa.fragments.LandscapeQueueFragment
import audio.funkwhale.ffa.fragments.QueueFragment import audio.funkwhale.ffa.fragments.QueueFragment
import audio.funkwhale.ffa.fragments.TrackInfoDetailsFragment import audio.funkwhale.ffa.fragments.TrackInfoDetailsFragment
@ -89,40 +89,47 @@ class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private val oAuth: OAuth by inject(OAuth::class.java) 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
AppContext.init(this) AppContext.init(this)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setSupportActionBar(binding.appbar) setSupportActionBar(binding.appbar)
onBackPressedDispatcher.addCallback(this) {
if (binding.nowPlaying.isOpened()) {
binding.nowPlaying.close()
} else {
navigation.navigateUp()
}
}
when (intent.action) { when (intent.action) {
MediaControlsManager.NOTIFICATION_ACTION_OPEN_QUEUE.toString() -> launchDialog(QueueFragment()) MediaControlsManager.NOTIFICATION_ACTION_OPEN_QUEUE.toString() -> launchDialog(QueueFragment())
} }
supportFragmentManager
.beginTransaction()
.replace(R.id.container, BrowseFragment())
.commit()
watchEventBus() watchEventBus()
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
(binding.container as? DisableableFrameLayout)?.setShouldRegisterTouch { _ -> findViewById<DisableableFrameLayout?>(R.id.container)?.apply {
if (binding.nowPlaying.isOpened()) { setShouldRegisterTouch {
binding.nowPlaying.close() if (binding.nowPlaying.isOpened()) {
binding.nowPlaying.close()
return@setShouldRegisterTouch false false
} else {
true
}
} }
true
} }
favoritedRepository.update(this, lifecycleScope) 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 { override fun onPrepareOptionsMenu(menu: Menu): Boolean {
this.menu = menu this.menu = menu
@ -226,18 +224,11 @@ class MainActivity : AppCompatActivity() {
when (item.itemId) { when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
binding.nowPlaying.close() binding.nowPlaying.close()
navigation.popBackStack(R.id.browseFragment, false)
(supportFragmentManager.fragments.last() as? BrowseFragment)?.let {
it.selectTabAt(0)
return true
}
launchFragment(BrowseFragment())
} }
R.id.nav_queue -> launchDialog(QueueFragment()) 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 -> { R.id.nav_all_music, R.id.nav_my_music, R.id.nav_followed -> {
menu?.let { menu -> menu?.let { menu ->
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW) item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW)
@ -300,26 +291,8 @@ class MainActivity : AppCompatActivity() {
return true return true
} }
private fun launchFragment(fragment: Fragment) { private fun launchDialog(fragment: DialogFragment) =
supportFragmentManager.fragments.lastOrNull()?.also { oldFragment -> fragment.show(supportFragmentManager.beginTransaction(), "")
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, "")
}
}
@SuppressLint("NewApi") @SuppressLint("NewApi")
private fun watchEventBus() { private fun watchEventBus() {
@ -343,7 +316,7 @@ class MainActivity : AppCompatActivity() {
} }
} else if (event is Event.PlaybackStopped) { } else if (event is Event.PlaybackStopped) {
if (binding.nowPlaying.visibility == View.VISIBLE) { 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 it.bottomMargin = it.bottomMargin / 2
} }
@ -368,15 +341,17 @@ class MainActivity : AppCompatActivity() {
} else if (event is Event.StateChanged) { } else if (event is Event.StateChanged) {
when (event.playing) { when (event.playing) {
true -> { true -> {
binding.nowPlayingContainer?.nowPlayingToggle?.icon = getDrawable(R.drawable.pause) binding.nowPlayingContainer?.nowPlayingToggle?.icon =
AppCompatResources.getDrawable(this@MainActivity, R.drawable.pause)
binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon = binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon =
getDrawable(R.drawable.pause) AppCompatResources.getDrawable(this@MainActivity, R.drawable.pause)
} }
false -> { false -> {
binding.nowPlayingContainer?.nowPlayingToggle?.icon = getDrawable(R.drawable.play) binding.nowPlayingContainer?.nowPlayingToggle?.icon =
AppCompatResources.getDrawable(this@MainActivity, R.drawable.play)
binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon = binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon =
getDrawable(R.drawable.play) AppCompatResources.getDrawable(this@MainActivity, R.drawable.play)
} }
} }
} else if (event is Event.QueueChanged) { } else if (event is Event.QueueChanged) {
@ -459,7 +434,7 @@ class MainActivity : AppCompatActivity() {
.setListener(null) .setListener(null)
.start() .start()
(binding.container.layoutParams as? ViewGroup.MarginLayoutParams)?.let { (binding.navHostFragment?.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.bottomMargin = it.bottomMargin * 2 it.bottomMargin = it.bottomMargin * 2
} }
@ -534,12 +509,11 @@ class MainActivity : AppCompatActivity() {
setOnMenuItemClickListener { setOnMenuItemClickListener {
when (it.itemId) { when (it.itemId) {
R.id.track_info_artist -> ArtistsFragment.openAlbums( R.id.track_info_artist -> BrowseFragmentDirections.browseToAlbums(
this@MainActivity,
track.artist, 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) R.id.track_info_details -> TrackInfoDetailsFragment.new(track)
.show(supportFragmentManager, "dialog") .show(supportFragmentManager, "dialog")
} }

View File

@ -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)
}
}
}

View File

@ -7,10 +7,10 @@ import android.graphics.PorterDuffColorFilter
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Build import android.os.Build
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import audio.funkwhale.ffa.R import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.databinding.RowSearchHeaderBinding 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.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.onApi import audio.funkwhale.ffa.utils.onApi
import audio.funkwhale.ffa.utils.toast import audio.funkwhale.ffa.utils.toast
import audio.funkwhale.ffa.viewmodel.SearchViewModel
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
class SearchAdapter( class SearchAdapter(
private val layoutInflater: LayoutInflater, viewModel: SearchViewModel,
private val context: Context?, private val fragment: Fragment,
private val listener: OnSearchResultClickListener, private val listener: OnSearchResultClickListener,
private val favoriteListener: FavoriteListener private val favoriteListener: FavoriteListener
) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() { ) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() {
@ -50,12 +52,27 @@ class SearchAdapter(
val sectionCount = 3 val sectionCount = 3
var artists: MutableList<Artist> = mutableListOf() var artists = listOf<Artist>()
var albums: MutableList<Album> = mutableListOf() var albums = listOf<Album>()
var tracks: MutableList<Track> = mutableListOf() var tracks = listOf<Track>()
var currentTrack: Track? = null 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 getItemCount() = sectionCount + artists.size + albums.size + tracks.size
override fun getItemId(position: Int): Long { override fun getItemId(position: Int): Long {
@ -67,7 +84,7 @@ class SearchAdapter(
} }
ResultType.Artist.ordinal -> artists[position].id.toLong() 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 -> ResultType.Track.ordinal ->
tracks[position - artists.size - albums.size - sectionCount].id.toLong() tracks[position - artists.size - albums.size - sectionCount].id.toLong()
else -> 0 else -> 0
@ -86,12 +103,12 @@ class SearchAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return when (viewType) { return when (viewType) {
ResultType.Header.ordinal -> { ResultType.Header.ordinal -> {
searchHeaderBinding = RowSearchHeaderBinding.inflate(layoutInflater, parent, false) searchHeaderBinding = RowSearchHeaderBinding.inflate(fragment.layoutInflater, parent, false)
SearchHeaderViewHolder(searchHeaderBinding, context) SearchHeaderViewHolder(searchHeaderBinding, fragment.requireContext())
} }
else -> { else -> {
rowTrackBinding = RowTrackBinding.inflate(layoutInflater, parent, false) rowTrackBinding = RowTrackBinding.inflate(fragment.layoutInflater, parent, false)
RowTrackViewHolder(rowTrackBinding, context).also { RowTrackViewHolder(rowTrackBinding, fragment.requireContext()).also {
rowTrackBinding.root.setOnClickListener(it) rowTrackBinding.root.setOnClickListener(it)
} }
} }
@ -105,47 +122,45 @@ class SearchAdapter(
val rowTrackViewHolder = holder as? RowTrackViewHolder val rowTrackViewHolder = holder as? RowTrackViewHolder
if (resultType == ResultType.Header.ordinal) { if (resultType == ResultType.Header.ordinal) {
context?.let { context -> if (position == 0) {
if (position == 0) { searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.artists)
searchHeaderViewHolder?.title?.text = context.getString(R.string.artists) holder.itemView.visibility = View.VISIBLE
holder.itemView.visibility = View.VISIBLE holder.itemView.layoutParams = RecyclerView.LayoutParams(
holder.itemView.layoutParams = RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
ViewGroup.LayoutParams.WRAP_CONTENT )
)
if (artists.isEmpty()) { if (artists.isEmpty()) {
holder.itemView.visibility = View.GONE holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0) holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
}
} }
}
if (position == (artists.size + 1)) { if (position == (artists.size + 1)) {
searchHeaderViewHolder?.title?.text = context.getString(R.string.albums) searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.albums)
holder.itemView.visibility = View.VISIBLE holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams( holder.itemView.layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
) )
if (albums.isEmpty()) { if (albums.isEmpty()) {
holder.itemView.visibility = View.GONE holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0) holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
}
} }
}
if (position == (artists.size + albums.size + 2)) { if (position == (artists.size + albums.size + 2)) {
searchHeaderViewHolder?.title?.text = context.getString(R.string.tracks) searchHeaderViewHolder?.title?.text = fragment.requireContext().getString(R.string.tracks)
holder.itemView.visibility = View.VISIBLE holder.itemView.visibility = View.VISIBLE
holder.itemView.layoutParams = RecyclerView.LayoutParams( holder.itemView.layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
) )
if (tracks.isEmpty()) { if (tracks.isEmpty()) {
holder.itemView.visibility = View.GONE holder.itemView.visibility = View.GONE
holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0) holder.itemView.layoutParams = RecyclerView.LayoutParams(0, 0)
}
} }
} }
@ -174,7 +189,7 @@ class SearchAdapter(
else -> tracks[position] else -> tracks[position]
} }
CoverArt.withContext(layoutInflater.context, maybeNormalizeUrl(item.cover())) CoverArt.withContext(fragment.layoutInflater.context, maybeNormalizeUrl(item.cover()))
.fit() .fit()
.transform(RoundedCornersTransformation(16, 0)) .transform(RoundedCornersTransformation(16, 0))
.into(rowTrackViewHolder?.cover) .into(rowTrackViewHolder?.cover)
@ -216,90 +231,91 @@ class SearchAdapter(
} }
ResultType.Track.ordinal -> { ResultType.Track.ordinal -> {
(item as? Track)?.let { track -> (item as? Track)?.let { track ->
context?.let { context -> if (track == currentTrack || track.current) {
if (track == currentTrack || track.current) { searchHeaderViewHolder?.title?.setTypeface(
searchHeaderViewHolder?.title?.setTypeface( searchHeaderViewHolder.title.typeface,
searchHeaderViewHolder.title.typeface, Typeface.BOLD
Typeface.BOLD )
) rowTrackViewHolder?.artist?.setTypeface(
rowTrackViewHolder?.artist?.setTypeface( rowTrackViewHolder.artist.typeface,
rowTrackViewHolder.artist.typeface, Typeface.BOLD
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) { when (track.cached || track.downloaded) {
true -> rowTrackViewHolder?.favorite?.setColorFilter( true -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
context.getColor(R.color.colorFavorite) R.drawable.downloaded, 0, 0, 0
) )
false -> rowTrackViewHolder?.favorite?.setColorFilter( false -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds(
context.getColor(R.color.colorSelected) 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 { if (track.downloaded) {
favoriteListener.let { rowTrackViewHolder?.title?.compoundDrawables?.forEach {
favoriteListener.onToggleFavorite(track.id, !track.favorite) it?.colorFilter =
PorterDuffColorFilter(
tracks[position - artists.size - albums.size - sectionCount].favorite = fragment.requireContext().getColor(R.color.downloaded),
!track.favorite PorterDuff.Mode.SRC_IN
)
notifyItemChanged(position)
}
} }
}
when (track.cached || track.downloaded) { rowTrackViewHolder?.actions?.setOnClickListener {
true -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds( PopupMenu(
R.drawable.downloaded, 0, 0, 0 fragment.requireContext(),
) rowTrackViewHolder.actions,
false -> rowTrackViewHolder?.title?.setCompoundDrawablesWithIntrinsicBounds( Gravity.START,
0, 0, 0, 0 R.attr.actionOverflowMenuStyle,
) 0
} ).apply {
inflate(R.menu.row_track)
if (track.cached && !track.downloaded) { setOnMenuItemClickListener {
rowTrackViewHolder?.title?.compoundDrawables?.forEach { when (it.itemId) {
it?.colorFilter = R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track)))
PorterDuffColorFilter(context.getColor(R.color.cached), PorterDuff.Mode.SRC_IN) 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))
if (track.downloaded) {
rowTrackViewHolder?.title?.compoundDrawables?.forEach {
it?.colorFilter =
PorterDuffColorFilter(
context.getColor(R.color.downloaded),
PorterDuff.Mode.SRC_IN
) )
} R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(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
} }
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) { ViewHolder(binding.root, context) {
val title = binding.title 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 { ViewHolder(binding.root, context), View.OnClickListener {
val title = binding.title val title = binding.title
val cover = binding.cover val cover = binding.cover

View File

@ -1,36 +1,27 @@
package audio.funkwhale.ffa.fragments package audio.funkwhale.ffa.fragments
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.transition.Fade
import androidx.transition.Slide
import audio.funkwhale.ffa.R import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.activities.MainActivity
import audio.funkwhale.ffa.adapters.AlbumsAdapter import audio.funkwhale.ffa.adapters.AlbumsAdapter
import audio.funkwhale.ffa.databinding.FragmentAlbumsBinding import audio.funkwhale.ffa.databinding.FragmentAlbumsBinding
import audio.funkwhale.ffa.model.Album import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.repositories.AlbumsRepository import audio.funkwhale.ffa.repositories.AlbumsRepository
import audio.funkwhale.ffa.repositories.ArtistTracksRepository import audio.funkwhale.ffa.repositories.ArtistTracksRepository
import audio.funkwhale.ffa.repositories.Repository import audio.funkwhale.ffa.repositories.Repository
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.Command import audio.funkwhale.ffa.utils.Command
import audio.funkwhale.ffa.utils.CommandBus import audio.funkwhale.ffa.utils.CommandBus
import audio.funkwhale.ffa.utils.CoverArt import audio.funkwhale.ffa.utils.CoverArt
import audio.funkwhale.ffa.utils.maybeNormalizeUrl import audio.funkwhale.ffa.utils.maybeNormalizeUrl
import audio.funkwhale.ffa.utils.onViewPager
import com.preference.PowerPreference import com.preference.PowerPreference
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
@ -45,77 +36,22 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
override val recycler: RecyclerView get() = binding.albums override val recycler: RecyclerView get() = binding.albums
override val alwaysRefresh = false override val alwaysRefresh = false
private val args by navArgs<AlbumsFragmentArgs>()
private val artistArt: String get() = when {
!args.cover.isNullOrBlank() -> args.cover!!
else -> args.artist.cover() ?: ""
}
private var _binding: FragmentAlbumsBinding? = null private var _binding: FragmentAlbumsBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private lateinit var artistTracksRepository: ArtistTracksRepository 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
arguments?.apply {
artistId = getInt("artistId")
artistName = getString("artistName") ?: ""
artistArt = getString("artistArt") ?: ""
}
adapter = AlbumsAdapter(layoutInflater, context, OnAlbumClickListener()) adapter = AlbumsAdapter(layoutInflater, context, OnAlbumClickListener())
repository = AlbumsRepository(context, artistId) repository = AlbumsRepository(context, args.artist.id)
artistTracksRepository = ArtistTracksRepository(context, artistId) artistTracksRepository = ArtistTracksRepository(context, args.artist.id)
} }
override fun onCreateView( override fun onCreateView(
@ -151,7 +87,7 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
.into(cover) .into(cover)
} }
binding.artist.text = artistName binding.artist.text = args.artist.name
} }
override fun onResume() { override fun onResume() {
@ -209,7 +145,7 @@ class AlbumsFragment : FFAFragment<Album, AlbumsAdapter>() {
inner class OnAlbumClickListener : AlbumsAdapter.OnAlbumClickListener { inner class OnAlbumClickListener : AlbumsAdapter.OnAlbumClickListener {
override fun onClick(view: View?, album: Album) { override fun onClick(view: View?, album: Album) {
openTracks(context, album, fragment = this@AlbumsFragment) findNavController().navigate(AlbumsFragmentDirections.albumsToTracks(album))
} }
} }
} }

View File

@ -4,18 +4,13 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView 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.adapters.AlbumsGridAdapter
import audio.funkwhale.ffa.databinding.FragmentAlbumsGridBinding import audio.funkwhale.ffa.databinding.FragmentAlbumsGridBinding
import audio.funkwhale.ffa.model.Album import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.repositories.AlbumsRepository import audio.funkwhale.ffa.repositories.AlbumsRepository
import audio.funkwhale.ffa.utils.AppContext
class AlbumsGridFragment : FFAFragment<Album, AlbumsGridAdapter>() { class AlbumsGridFragment : FFAFragment<Album, AlbumsGridAdapter>() {
@ -49,29 +44,7 @@ class AlbumsGridFragment : FFAFragment<Album, AlbumsGridAdapter>() {
inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener { inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener {
override fun onClick(view: View?, album: Album) { override fun onClick(view: View?, album: Album) {
(context as? MainActivity)?.let { activity -> findNavController().navigate(BrowseFragmentDirections.browseToTracks(album))
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()
}
} }
} }
} }

View File

@ -1,27 +1,17 @@
package audio.funkwhale.ffa.fragments package audio.funkwhale.ffa.fragments
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import androidx.navigation.fragment.findNavController
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView 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.adapters.ArtistsAdapter
import audio.funkwhale.ffa.databinding.FragmentArtistsBinding import audio.funkwhale.ffa.databinding.FragmentArtistsBinding
import audio.funkwhale.ffa.model.Artist import audio.funkwhale.ffa.model.Artist
import audio.funkwhale.ffa.repositories.ArtistsRepository import audio.funkwhale.ffa.repositories.ArtistsRepository
import audio.funkwhale.ffa.utils.AppContext
import audio.funkwhale.ffa.utils.onViewPager
class ArtistsFragment : FFAFragment<Artist, ArtistsAdapter>() { class ArtistsFragment : FFAFragment<Artist, ArtistsAdapter>() {
private var _binding: FragmentArtistsBinding? = null private var _binding: FragmentArtistsBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
@ -50,49 +40,9 @@ class ArtistsFragment : FFAFragment<Artist, ArtistsAdapter>() {
_binding = null _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 { inner class OnArtistClickListener : ArtistsAdapter.OnArtistClickListener {
override fun onClick(holder: View?, artist: Artist) { override fun onClick(holder: View?, artist: Artist) {
openAlbums(context, artist, fragment = this@ArtistsFragment) findNavController().navigate(BrowseFragmentDirections.browseToAlbums(artist))
} }
} }
} }

View File

@ -14,13 +14,6 @@ class BrowseFragment : Fragment() {
private var _binding: FragmentBrowseBinding? = null private var _binding: FragmentBrowseBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private var adapter: BrowseTabsAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = BrowseTabsAdapter(this)
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -30,10 +23,11 @@ class BrowseFragment : Fragment() {
return binding.root.apply { return binding.root.apply {
binding.tabs.getTabAt(0)?.select() binding.tabs.getTabAt(0)?.select()
val adapter = BrowseTabsAdapter(this@BrowseFragment)
binding.pager.adapter = adapter binding.pager.adapter = adapter
binding.pager.offscreenPageLimit = 3 binding.pager.offscreenPageLimit = 3
TabLayoutMediator(binding.tabs, binding.pager) { tab, position -> TabLayoutMediator(binding.tabs, binding.pager) { tab, position ->
tab.text = adapter?.tabText(position) tab.text = adapter.tabText(position)
}.attach() }.attach()
} }
} }
@ -42,8 +36,4 @@ class BrowseFragment : Fragment() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
fun selectTabAt(position: Int) {
binding.tabs.getTabAt(position)?.select()
}
} }

View File

@ -6,14 +6,13 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import audio.funkwhale.ffa.R import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.adapters.FavoriteListener import audio.funkwhale.ffa.adapters.FavoriteListener
import audio.funkwhale.ffa.adapters.PlaylistTracksAdapter import audio.funkwhale.ffa.adapters.PlaylistTracksAdapter
import audio.funkwhale.ffa.databinding.FragmentTracksBinding import audio.funkwhale.ffa.databinding.FragmentTracksBinding
import audio.funkwhale.ffa.model.Playlist
import audio.funkwhale.ffa.model.PlaylistTrack import audio.funkwhale.ffa.model.PlaylistTrack
import audio.funkwhale.ffa.model.Track import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.repositories.FavoritesRepository import audio.funkwhale.ffa.repositories.FavoritesRepository
@ -33,43 +32,19 @@ import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>() { class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>() {
override val recycler: RecyclerView get() = binding.tracks override val recycler: RecyclerView get() = binding.tracks
private val args by navArgs<PlaylistTracksFragmentArgs>()
private var _binding: FragmentTracksBinding? = null private var _binding: FragmentTracksBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
lateinit var favoritesRepository: FavoritesRepository lateinit var favoritesRepository: FavoritesRepository
lateinit var playlistsRepository: ManagementPlaylistsRepository 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
arguments?.apply {
albumId = getInt("albumId")
albumArtist = getString("albumArtist") ?: ""
albumTitle = getString("albumTitle") ?: ""
albumCover = getString("albumCover") ?: ""
}
favoritesRepository = FavoritesRepository(context) favoritesRepository = FavoritesRepository(context)
playlistsRepository = ManagementPlaylistsRepository(context) playlistsRepository = ManagementPlaylistsRepository(context)
@ -79,7 +54,7 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
FavoriteListener(favoritesRepository), FavoriteListener(favoritesRepository),
PlaylistListener() PlaylistListener()
) )
repository = PlaylistTracksRepository(context, albumId) repository = PlaylistTracksRepository(context, args.playlist.id)
watchEventBus() watchEventBus()
} }
@ -105,8 +80,8 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
binding.cover.visibility = View.INVISIBLE binding.cover.visibility = View.INVISIBLE
binding.covers.visibility = View.VISIBLE binding.covers.visibility = View.VISIBLE
binding.artist.text = "Playlist" binding.artist.text = getString(R.string.playlist)
binding.title.text = albumTitle binding.title.text = args.playlist.name
} }
override fun onResume() { override fun onResume() {
@ -216,12 +191,12 @@ class PlaylistTracksFragment : FFAFragment<PlaylistTrack, PlaylistTracksAdapter>
inner class PlaylistListener : PlaylistTracksAdapter.OnPlaylistListener { inner class PlaylistListener : PlaylistTracksAdapter.OnPlaylistListener {
override fun onMoveTrack(from: Int, to: Int) { 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) { override fun onRemoveTrackFromPlaylist(track: Track, index: Int) {
lifecycleScope.launch(Main) { lifecycleScope.launch(Main) {
playlistsRepository.remove(albumId, index) playlistsRepository.remove(args.playlist.id, index)
update() update()
} }
} }

View File

@ -4,17 +4,12 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView 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.adapters.PlaylistsAdapter
import audio.funkwhale.ffa.databinding.FragmentPlaylistsBinding import audio.funkwhale.ffa.databinding.FragmentPlaylistsBinding
import audio.funkwhale.ffa.model.Playlist import audio.funkwhale.ffa.model.Playlist
import audio.funkwhale.ffa.repositories.PlaylistsRepository import audio.funkwhale.ffa.repositories.PlaylistsRepository
import audio.funkwhale.ffa.utils.AppContext
class PlaylistsFragment : FFAFragment<Playlist, PlaylistsAdapter>() { class PlaylistsFragment : FFAFragment<Playlist, PlaylistsAdapter>() {
@ -48,29 +43,7 @@ class PlaylistsFragment : FFAFragment<Playlist, PlaylistsAdapter>() {
inner class OnPlaylistClickListener : PlaylistsAdapter.OnPlaylistClickListener { inner class OnPlaylistClickListener : PlaylistsAdapter.OnPlaylistClickListener {
override fun onClick(holder: View?, playlist: Playlist) { override fun onClick(holder: View?, playlist: Playlist) {
(context as? MainActivity)?.let { activity -> findNavController().navigate(BrowseFragmentDirections.browseToPlaylistTracks(playlist))
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()
}
} }
} }
} }

View File

@ -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<SearchViewModel>()
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))
}
}
}

View File

@ -8,14 +8,13 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import audio.funkwhale.ffa.R import audio.funkwhale.ffa.R
import audio.funkwhale.ffa.adapters.FavoriteListener import audio.funkwhale.ffa.adapters.FavoriteListener
import audio.funkwhale.ffa.adapters.TracksAdapter import audio.funkwhale.ffa.adapters.TracksAdapter
import audio.funkwhale.ffa.databinding.FragmentTracksBinding import audio.funkwhale.ffa.databinding.FragmentTracksBinding
import audio.funkwhale.ffa.model.Album
import audio.funkwhale.ffa.model.Track import audio.funkwhale.ffa.model.Track
import audio.funkwhale.ffa.repositories.FavoritedRepository import audio.funkwhale.ffa.repositories.FavoritedRepository
import audio.funkwhale.ffa.repositories.FavoritesRepository import audio.funkwhale.ffa.repositories.FavoritesRepository
@ -43,7 +42,7 @@ import kotlinx.coroutines.withContext
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
class TracksFragment : FFAFragment<Track, TracksAdapter>() { class TracksFragment : FFAFragment<Track, TracksAdapter>() {
private val args by navArgs<TracksFragmentArgs>()
private val exoDownloadManager: DownloadManager by inject(DownloadManager::class.java) private val exoDownloadManager: DownloadManager by inject(DownloadManager::class.java)
override val recycler: RecyclerView get() = binding.tracks override val recycler: RecyclerView get() = binding.tracks
@ -54,37 +53,12 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
private lateinit var favoritesRepository: FavoritesRepository private lateinit var favoritesRepository: FavoritesRepository
private lateinit var favoritedRepository: FavoritedRepository 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
arguments?.apply {
albumId = getInt("albumId")
albumArtist = getString("albumArtist") ?: ""
albumTitle = getString("albumTitle") ?: ""
albumCover = getString("albumCover") ?: ""
}
favoritesRepository = FavoritesRepository(context) favoritesRepository = FavoritesRepository(context)
favoritedRepository = FavoritedRepository(context) favoritedRepository = FavoritedRepository(context)
repository = TracksRepository(context, albumId) repository = TracksRepository(context, args.album.id)
adapter = TracksAdapter(layoutInflater, context, FavoriteListener(favoritesRepository)) adapter = TracksAdapter(layoutInflater, context, FavoriteListener(favoritesRepository))
@ -144,15 +118,15 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
CoverArt.withContext(layoutInflater.context, maybeNormalizeUrl(albumCover)) CoverArt.withContext(layoutInflater.context, maybeNormalizeUrl(args.album.cover()))
.noFade() .noFade()
.fit() .fit()
.centerCrop() .centerCrop()
.transform(RoundedCornersTransformation(16, 0)) .transform(RoundedCornersTransformation(16, 0))
.into(binding.cover) .into(binding.cover)
binding.artist.text = albumArtist binding.artist.text = args.album.artist.name
binding.title.text = albumTitle binding.title.text = args.album.title
} }
override fun onResume() { override fun onResume() {

View File

@ -1,13 +1,18 @@
package audio.funkwhale.ffa.model package audio.funkwhale.ffa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class Album( data class Album(
val id: Int, val id: Int,
val artist: Artist, val artist: Artist,
val title: String, val title: String,
val cover: Covers?, val cover: Covers?,
val release_date: String? val release_date: String?
) : SearchResult { ) : SearchResult, Parcelable {
data class Artist(val name: String) @Parcelize
data class Artist(val name: String) : Parcelable
override fun cover() = cover?.urls?.original override fun cover() = cover?.urls?.original
override fun title() = title override fun title() = title

View File

@ -1,17 +1,21 @@
package audio.funkwhale.ffa.model package audio.funkwhale.ffa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.util.Calendar.DAY_OF_YEAR import java.util.Calendar.DAY_OF_YEAR
import java.util.GregorianCalendar import java.util.GregorianCalendar
@Parcelize
data class Artist( data class Artist(
val id: Int, val id: Int,
val name: String, val name: String,
val albums: List<Album>? val albums: List<Album>?
) : SearchResult { ) : SearchResult, Parcelable {
@Parcelize
data class Album( data class Album(
val title: String, val title: String,
val cover: Covers? val cover: Covers?
) ) : Parcelable
override fun cover(): String? = albums?.mapNotNull { it.cover?.urls?.original }?.let { covers -> override fun cover(): String? = albums?.mapNotNull { it.cover?.urls?.original }?.let { covers ->
if (covers.isEmpty()) { if (covers.isEmpty()) {
@ -21,6 +25,7 @@ data class Artist(
val index = GregorianCalendar().get(DAY_OF_YEAR) % covers.size val index = GregorianCalendar().get(DAY_OF_YEAR) % covers.size
covers.getOrNull(index) covers.getOrNull(index)
} }
override fun title() = name override fun title() = name
override fun subtitle() = "Artist" override fun subtitle() = "Artist"
} }

View File

@ -1,3 +1,7 @@
package audio.funkwhale.ffa.model 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

View File

@ -1,3 +1,7 @@
package audio.funkwhale.ffa.model 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

View File

@ -1,9 +1,13 @@
package audio.funkwhale.ffa.model package audio.funkwhale.ffa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class Playlist( data class Playlist(
val id: Int, val id: Int,
val name: String, val name: String,
val album_covers: List<String>, val album_covers: List<String>,
val tracks_count: Int, val tracks_count: Int,
val duration: Int val duration: Int
) ) : Parcelable

View File

@ -1,8 +1,12 @@
package audio.funkwhale.ffa.model package audio.funkwhale.ffa.model
import android.os.Parcelable
import audio.funkwhale.ffa.utils.containsIgnoringCase import audio.funkwhale.ffa.utils.containsIgnoringCase
import com.preference.PowerPreference import com.preference.PowerPreference
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
data class Track( data class Track(
val id: Int = 0, val id: Int = 0,
val title: String, val title: String,
@ -13,10 +17,17 @@ data class Track(
val uploads: List<Upload> = listOf(), val uploads: List<Upload> = listOf(),
val copyright: String? = null, val copyright: String? = null,
val license: String? = null val license: String? = null
) : SearchResult { ) : SearchResult, Parcelable {
@IgnoredOnParcel
var current: Boolean = false var current: Boolean = false
@IgnoredOnParcel
var favorite: Boolean = false var favorite: Boolean = false
@IgnoredOnParcel
var cached: Boolean = false var cached: Boolean = false
@IgnoredOnParcel
var downloaded: Boolean = false var downloaded: Boolean = false
companion object { 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 { fun matchesFilter(filter: String): Boolean {
return title.containsIgnoringCase(filter) || return title.containsIgnoringCase(filter) ||

View File

@ -3,8 +3,8 @@ package audio.funkwhale.ffa.utils
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData
import audio.funkwhale.ffa.fragments.BrowseFragment import androidx.lifecycle.MediatorLiveData
import audio.funkwhale.ffa.model.DownloadInfo import audio.funkwhale.ffa.model.DownloadInfo
import audio.funkwhale.ffa.repositories.Repository import audio.funkwhale.ffa.repositories.Repository
import com.github.kittinunf.fuel.core.FuelError import com.github.kittinunf.fuel.core.FuelError
@ -34,14 +34,6 @@ inline fun <D> Flow<Repository.Response<D>>.untilNetwork(
} }
} }
fun Fragment.onViewPager(block: Fragment.() -> Unit) {
for (f in activity?.supportFragmentManager?.fragments ?: listOf()) {
if (f is BrowseFragment) {
f.block()
}
}
}
fun <T> Int.onApi(block: () -> T) { fun <T> Int.onApi(block: () -> T) {
if (Build.VERSION.SDK_INT >= this) { if (Build.VERSION.SDK_INT >= this) {
block() block()
@ -107,3 +99,53 @@ fun Date.format(): String {
fun String?.containsIgnoringCase(candidate: String): Boolean = fun String?.containsIgnoringCase(candidate: String): Boolean =
this != null && this.lowercase().contains(candidate.lowercase()) this != null && this.lowercase().contains(candidate.lowercase())
inline fun <T, U, V, R> LiveData<T>.mergeWith(
u: LiveData<U>,
v: LiveData<V>,
crossinline block: (valT: T, valU: U, valV: V) -> R
): LiveData<R> = MediatorLiveData<R>().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 <T, U, V, W, R> LiveData<T>.mergeWith(
u: LiveData<U>,
v: LiveData<V>,
w: LiveData<W>,
crossinline block: (valT: T, valU: U, valV: V, valW: W) -> R
): LiveData<R> = MediatorLiveData<R>().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))
}
}
}

View File

@ -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<String> {
private val artistResultsLoading = MutableLiveData(false)
private val albumResultsLoading = MutableLiveData(false)
private val tackResultsLoading = MutableLiveData(false)
private val artistsRepository =
ArtistsSearchRepository(getApplication<FFA>().applicationContext, "")
private val albumsRepository =
AlbumsSearchRepository(getApplication<FFA>().applicationContext, "")
private val tracksRepository =
TracksSearchRepository(getApplication<FFA>().applicationContext, "")
private val dedupQuery: LiveData<String>
val query = MutableLiveData("")
val artistResults: LiveData<List<Artist>> = MutableLiveData(listOf())
val albumResults: LiveData<List<Album>> = MutableLiveData(listOf())
val trackResults: LiveData<List<Track>> = MutableLiveData(listOf())
val isLoadingData: LiveData<Boolean> = artistResultsLoading.mergeWith(
albumResultsLoading, tackResultsLoading
) { b1, b2, b3 -> b1 || b2 || b3 }
val hasResults: LiveData<Boolean> = 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)
}
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:startOffset="@integer/transitionDuration"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:duration="@integer/transitionDuration"
/>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:fromAlpha="0.0"
android:toAlpha="1.0"
android:duration="@integer/transitionDuration"
/>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:toAlpha="0.0"
android:fromAlpha="1.0"
android:duration="@integer/transitionDuration"
/>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:fromAlpha="1.0"
android:toAlpha="1.0"
/>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<translate
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:fromYDelta="0"
android:toYDelta="100%"
android:duration="@integer/transitionDuration"
/>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<translate
xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:interpolator/accelerate_decelerate"
android:fromYDelta="100%"
android:toYDelta="0"
android:duration="@integer/transitionDuration"
/>

View File

@ -11,13 +11,16 @@
android:baselineAligned="false" android:baselineAligned="false"
android:orientation="horizontal"> android:orientation="horizontal">
<FrameLayout <androidx.fragment.app.FragmentContainerView
android:id="@+id/container" android:id="@+id/nav_host_fragment"
android:layout_width="0dp" android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_height="match_parent" android:layout_width="0dp"
android:layout_marginBottom="?attr/actionBarSize" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> android:layout_marginBottom="?attr/actionBarSize"
app:defaultNavHost="true"
app:navGraph="@navigation/main_nav"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<FrameLayout <FrameLayout
android:id="@+id/landscape_queue" android:id="@+id/landscape_queue"

View File

@ -6,11 +6,14 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/surface"> android:background="@color/surface">
<audio.funkwhale.ffa.views.DisableableFrameLayout <androidx.fragment.app.FragmentContainerView
android:id="@+id/container" android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginBottom="?attr/actionBarSize" android:layout_marginBottom="?attr/actionBarSize"
app:defaultNavHost="true"
app:navGraph="@navigation/main_nav"
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<audio.funkwhale.ffa.views.NowPlayingView <audio.funkwhale.ffa.views.NowPlayingView

View File

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:context=".activities.SearchActivity">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="0dp"
android:elevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.SearchView
android:id="@+id/search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:iconifiedByDefault="false"
app:queryBackground="@android:color/transparent"
app:queryHint="@string/search_placeholder" />
<ProgressBar
android:id="@+id/search_spinner"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="-12dp"
android:layout_marginBottom="-12dp"
android:indeterminate="true"
android:visibility="invisible" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<TextView
android:id="@+id/search_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:drawablePadding="16dp"
android:text="@string/search_welcome"
android:textAlignment="center"
android:textSize="14sp"
app:drawableTint="#525252"
app:drawableTopCompat="@drawable/funkwhaleshape" />
<TextView
android:id="@+id/search_no_results"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
app:drawableTopCompat="@drawable/funkwhaleshape"
android:drawablePadding="16dp"
app:drawableTint="#525252"
android:text="@string/search_no_results"
android:textAlignment="center"
android:textSize="14sp"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/results"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:itemCount="10"
tools:listitem="@layout/row_track" />
</LinearLayout>
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="androidx.lifecycle.LiveData" />
<import type="android.view.View" />
<variable name="noSearchYet" type="LiveData&lt;Boolean>" />
<variable name="isLoadingData" type="LiveData&lt;Boolean>" />
<variable name="hasResults" type="LiveData&lt;Boolean>" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/surface">
<LinearLayout
android:id="@+id/search_bar_and_messages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="0dp"
android:elevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.SearchView
android:id="@+id/search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:iconifiedByDefault="false"
app:queryBackground="@android:color/transparent"
app:queryHint="@string/search_placeholder" />
<ProgressBar
android:id="@+id/search_spinner"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="-12dp"
android:layout_marginBottom="-12dp"
android:indeterminate="true"
android:visibility="@{isLoadingData ? View.VISIBLE : View.INVISIBLE, default=invisible}" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<TextView
android:id="@+id/search_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/search_welcome"
android:textAlignment="center"
android:textSize="14sp"
android:visibility="@{noSearchYet ? View.VISIBLE : View.GONE, default=visible}"
app:drawableTint="#525252"
app:drawableTopCompat="@drawable/funkwhaleshape" />
<TextView
android:id="@+id/search_no_results"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/search_no_results"
android:textAlignment="center"
android:textSize="14sp"
android:visibility="@{noSearchYet || hasResults ? View.GONE : View.VISIBLE, default=gone}"
app:drawableTint="#525252"
app:drawableTopCompat="@drawable/funkwhaleshape" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/results"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/surface"
app:layout_constraintTop_toBottomOf="@+id/search_bar_and_messages"
app:layout_constraintBottom_toBottomOf="parent"
tools:itemCount="10"
tools:listitem="@layout/row_track" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_nav"
app:startDestination="@id/browseFragment">
<fragment
android:id="@+id/browseFragment"
android:name="audio.funkwhale.ffa.fragments.BrowseFragment"
android:label="BrowseFragment">
<action
android:id="@+id/browseToSearch"
app:destination="@id/searchFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
<action
android:id="@+id/browseToAlbums"
app:destination="@id/albumsFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
<action
android:id="@+id/browseToTracks"
app:destination="@id/tracksFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
<action
android:id="@+id/browseToArtists"
app:destination="@id/artistsFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
<action
android:id="@+id/browseToPlaylistTracks"
app:destination="@id/playlistTracksFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
</fragment>
<fragment
android:id="@+id/playlistTracksFragment"
android:name="audio.funkwhale.ffa.fragments.PlaylistTracksFragment"
android:label="PlaylistTracksFragment" >
<argument
android:name="playlist"
app:argType="audio.funkwhale.ffa.model.Playlist" />
</fragment>
<fragment
android:id="@+id/tracksFragment"
android:name="audio.funkwhale.ffa.fragments.TracksFragment"
android:label="TracksFragment" >
<argument
android:name="album"
app:argType="audio.funkwhale.ffa.model.Album" />
</fragment>
<fragment
android:id="@+id/albumsFragment"
android:name="audio.funkwhale.ffa.fragments.AlbumsFragment"
android:label="AlbumsFragment" >
<argument
android:name="artist"
app:argType="audio.funkwhale.ffa.model.Artist" />
<argument
android:name="cover"
app:argType="string"
app:nullable="true"
android:defaultValue="@null" />
<action
android:id="@+id/albumsToTracks"
app:destination="@id/tracksFragment" />
</fragment>
<fragment
android:id="@+id/searchFragment"
android:name="audio.funkwhale.ffa.fragments.SearchFragment"
android:label="SearchFragment" >
<action
android:id="@+id/searchToAlbums"
app:destination="@id/albumsFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
<action
android:id="@+id/searchToTracks"
app:destination="@id/tracksFragment"
app:enterAnim="@anim/slide_up"
app:exitAnim="@anim/delayed_fade_out"
app:popEnterAnim="@anim/none"
app:popExitAnim="@anim/slide_down" />
</fragment>
<fragment
android:id="@+id/artistsFragment"
android:name="audio.funkwhale.ffa.fragments.ArtistsFragment"
android:label="ArtistsFragment" />
</navigation>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="transitionDuration">300</integer>
</resources>

View File

@ -127,4 +127,7 @@
<item quantity="one">Downloading %1$d track</item> <item quantity="one">Downloading %1$d track</item>
<item quantity="other">Downloading %1$d tracks</item> <item quantity="other">Downloading %1$d tracks</item>
</plurals> </plurals>
<!-- TODO: Remove or change this placeholder text -->
<string name="hello_blank_fragment">Hello blank fragment</string>
<string name="playlist">Playlist</string>
</resources> </resources>

View File

@ -1,4 +1,8 @@
buildscript { buildscript {
extra.apply{
set("navVersion", "2.5.2")
set("lifecycleVersion", "2.5.1")
}
repositories { repositories {
google() google()
@ -6,20 +10,22 @@ buildscript {
gradlePluginPortal() gradlePluginPortal()
} }
val navVersion: String by extra
dependencies { dependencies {
classpath("com.android.tools.build:gradle:7.3.1") classpath("com.android.tools.build:gradle:7.3.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0")
classpath("com.github.bjoernq:unmockplugin:0.7.9") classpath("com.github.bjoernq:unmockplugin:0.7.9")
classpath("com.github.ben-manes:gradle-versions-plugin:0.44.0") classpath("com.github.ben-manes:gradle-versions-plugin:0.44.0")
classpath("org.jacoco:org.jacoco.core:0.8.8") classpath("org.jacoco:org.jacoco.core:0.8.8")
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:$navVersion")
} }
} }
allprojects { allprojects {
repositories { repositories {
google() google()
jcenter() mavenCentral()
maven(url = "https://jitpack.io") maven(url = "https://jitpack.io")
} }
} }

View File

@ -0,0 +1 @@
Make the mini player overlay stay on top (contributed by @christophehenry)