diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c0d7898..58c5bd8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,7 @@ plugins { id("kotlin-android") id("androidx.navigation.safeargs.kotlin") id("kotlin-parcelize") + id("kotlin-kapt") id("org.jlleitschuh.gradle.ktlint") version "11.2.0" id("com.gladed.androidgitversion") version "0.4.14" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 62f7deb..9bfd708 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,13 +22,11 @@ android:name=".activities.SplashActivity" android:launchMode="singleInstance" android:noHistory="true" - android:screenOrientation="portrait" android:exported="true"> - @@ -40,9 +38,7 @@ android:launchMode="singleInstance" android:screenOrientation="portrait" /> - + (R.id.container)?.apply { - setShouldRegisterTouch { - if (binding.nowPlaying.isOpened()) { - binding.nowPlaying.close() - false - } else { - true - } - } - } + binding.nowPlaying.getFragment().apply { + favoritedRepository.update(requireContext(), lifecycleScope) - favoritedRepository.update(this, lifecycleScope) + startService(Intent(requireContext(), PlayerService::class.java)) + DownloadService.start(requireContext(), PinService::class.java) - startService(Intent(this, PlayerService::class.java)) - DownloadService.start(this, PinService::class.java) + CommandBus.send(Command.RefreshService) - CommandBus.send(Command.RefreshService) - - lifecycleScope.launch(IO) { - Userinfo.get(this@MainActivity, oAuth) - } - - with(binding) { - - nowPlayingContainer?.nowPlayingToggle?.setOnClickListener { - CommandBus.send(Command.ToggleState) - } - - nowPlayingContainer?.nowPlayingNext?.setOnClickListener { - CommandBus.send(Command.NextTrack) - } - - nowPlayingContainer?.nowPlayingDetailsPrevious?.setOnClickListener { - CommandBus.send(Command.PreviousTrack) - } - - nowPlayingContainer?.nowPlayingDetailsNext?.setOnClickListener { - CommandBus.send(Command.NextTrack) - } - - nowPlayingContainer?.nowPlayingDetailsToggle?.setOnClickListener { - CommandBus.send(Command.ToggleState) - } - - binding.nowPlayingContainer?.nowPlayingDetailsProgress?.setOnSeekBarChangeListener( - object : SeekBar.OnSeekBarChangeListener { - override fun onStopTrackingTouch(view: SeekBar?) {} - - override fun onStartTrackingTouch(view: SeekBar?) {} - - override fun onProgressChanged(view: SeekBar?, progress: Int, fromUser: Boolean) { - if (fromUser) { - CommandBus.send(Command.Seek(progress)) - } - } - }) - - landscapeQueue?.let { - supportFragmentManager.beginTransaction() - .replace(R.id.landscape_queue, LandscapeQueueFragment()).commit() + lifecycleScope.launch(IO) { + Userinfo.get(this@MainActivity, oAuth) } } } @@ -223,7 +176,7 @@ class MainActivity : AppCompatActivity() { override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { android.R.id.home -> { - binding.nowPlaying.close() + binding.nowPlayingBottomSheet.close() navigation.popBackStack(R.id.browseFragment, false) } @@ -298,70 +251,22 @@ class MainActivity : AppCompatActivity() { private fun watchEventBus() { lifecycleScope.launch(Main) { EventBus.get().collect { event -> - if (event is Event.LogOut) { - FFA.get().deleteAllData(this@MainActivity) - startActivity( - Intent(this@MainActivity, LoginActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NO_HISTORY - } - ) - - finish() - } else if (event is Event.PlaybackError) { - toast(event.message) - } else if (event is Event.Buffering) { - when (event.value) { - true -> binding.nowPlayingContainer?.nowPlayingBuffering?.visibility = View.VISIBLE - false -> binding.nowPlayingContainer?.nowPlayingBuffering?.visibility = View.GONE - } - } else if (event is Event.PlaybackStopped) { - if (binding.nowPlaying.visibility == View.VISIBLE) { - (binding.navHostFragment.layoutParams as? ViewGroup.MarginLayoutParams)?.let { - it.bottomMargin = it.bottomMargin / 2 - } - - binding.landscapeQueue?.let { landscape_queue -> - (landscape_queue.layoutParams as? ViewGroup.MarginLayoutParams)?.let { - it.bottomMargin = it.bottomMargin / 2 + when(event) { + is Event.LogOut -> logout() + is Event.PlaybackError -> toast(event.message) + is Event.PlaybackStopped -> binding.nowPlayingBottomSheet.hide() + is Event.TrackFinished -> incrementListenCount(event.track) + is Event.QueueChanged -> { + if(binding.nowPlayingBottomSheet.isHidden) binding.nowPlayingBottomSheet.show() + findViewById(R.id.nav_queue)?.let { view -> + ObjectAnimator.ofFloat(view, View.ROTATION, 0f, 360f).let { + it.duration = 500 + it.interpolator = AccelerateDecelerateInterpolator() + it.start() } } - - binding.nowPlaying.animate() - .alpha(0.0f) - .setDuration(400) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animator: Animator) { - binding.nowPlaying.visibility = View.GONE - } - }) - .start() - } - } else if (event is Event.TrackFinished) { - incrementListenCount(event.track) - } else if (event is Event.StateChanged) { - when (event.playing) { - true -> { - binding.nowPlayingContainer?.nowPlayingToggle?.icon = - AppCompatResources.getDrawable(this@MainActivity, R.drawable.pause) - binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon = - AppCompatResources.getDrawable(this@MainActivity, R.drawable.pause) - } - - false -> { - binding.nowPlayingContainer?.nowPlayingToggle?.icon = - AppCompatResources.getDrawable(this@MainActivity, R.drawable.play) - binding.nowPlayingContainer?.nowPlayingDetailsToggle?.icon = - AppCompatResources.getDrawable(this@MainActivity, R.drawable.play) - } - } - } else if (event is Event.QueueChanged) { - findViewById(R.id.nav_queue)?.let { view -> - ObjectAnimator.ofFloat(view, View.ROTATION, 0f, 360f).let { - it.duration = 500 - it.interpolator = AccelerateDecelerateInterpolator() - it.start() - } } + else -> {} } } } @@ -402,24 +307,6 @@ class MainActivity : AppCompatActivity() { } } } - - lifecycleScope.launch(Main) { - ProgressBus.get().collect { (current, duration, percent) -> - binding.nowPlayingContainer?.nowPlayingProgress?.progress = percent - binding.nowPlayingContainer?.nowPlayingDetailsProgress?.progress = percent - - val currentMins = (current / 1000) / 60 - val currentSecs = (current / 1000) % 60 - - val durationMins = duration / 60 - val durationSecs = duration % 60 - - binding.nowPlayingContainer?.nowPlayingDetailsProgressCurrent?.text = - "%02d:%02d".format(currentMins, currentSecs) - binding.nowPlayingContainer?.nowPlayingDetailsProgressDuration?.text = - "%02d:%02d".format(durationMins, durationSecs) - } - } } private fun refreshCurrentTrack(track: Track?) { @@ -444,175 +331,6 @@ class MainActivity : AppCompatActivity() { } } } - - binding.nowPlayingContainer?.nowPlayingTitle?.text = track.title - binding.nowPlayingContainer?.nowPlayingAlbum?.text = track.artist.name - - binding.nowPlayingContainer?.nowPlayingDetailsTitle?.text = track.title - binding.nowPlayingContainer?.nowPlayingDetailsArtist?.text = track.artist.name - - val lic = this.layoutInflater.context - - CoverArt.withContext(lic, maybeNormalizeUrl(track.cover())) - .fit() - .centerCrop() - .into(binding.nowPlayingContainer?.nowPlayingCover) - - binding.nowPlayingContainer?.nowPlayingDetailsCover?.let { nowPlayingDetailsCover -> - CoverArt.withContext(lic, maybeNormalizeUrl(track.cover())) - .fit() - .centerCrop() - .transform(RoundedCornersTransformation(16, 0)) - .into(nowPlayingDetailsCover) - } - - if (binding.nowPlayingContainer?.nowPlayingCover == null) { - lifecycleScope.launch(Default) { - val width = DisplayMetrics().apply { - windowManager.defaultDisplay.getMetrics(this) - }.widthPixels - - val backgroundCover = CoverArt.withContext(lic, maybeNormalizeUrl(track.cover())) - .get() - .run { Bitmap.createScaledBitmap(this, width, width, false).toDrawable(resources) } - .apply { - alpha = 20 - gravity = Gravity.CENTER - } - - withContext(Main) { - binding.nowPlayingContainer?.nowPlayingDetails?.background = backgroundCover - } - } - } - - binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.let { now_playing_details_repeat -> - changeRepeatMode(FFACache.getLine(this@MainActivity, "repeat")?.toInt() ?: 0) - - now_playing_details_repeat.setOnClickListener { - val current = FFACache.getLine(this@MainActivity, "repeat")?.toInt() ?: 0 - - changeRepeatMode((current + 1) % 3) - } - } - - binding.nowPlayingContainer?.nowPlayingDetailsInfo?.let { nowPlayingDetailsInfo -> - nowPlayingDetailsInfo.setOnClickListener { - PopupMenu( - this@MainActivity, - nowPlayingDetailsInfo, - Gravity.START, - R.attr.actionOverflowMenuStyle, - 0 - ).apply { - inflate(R.menu.track_info) - - setOnMenuItemClickListener { - when (it.itemId) { - R.id.track_info_artist -> BrowseFragmentDirections.browseToAlbums( - track.artist, - track.album?.cover() - ) - R.id.track_info_album -> track.album?.let(BrowseFragmentDirections::browseToTracks) - R.id.track_info_details -> TrackInfoDetailsFragment.new(track) - .show(supportFragmentManager, "dialog") - } - - binding.nowPlaying.close() - - true - } - - show() - } - } - } - - binding.nowPlayingContainer?.nowPlayingDetailsFavorite?.let { now_playing_details_favorite -> - favoritedRepository.fetch().untilNetwork(lifecycleScope, IO) { favorites, _, _, _ -> - lifecycleScope.launch(Main) { - track.favorite = favorites.contains(track.id) - - when (track.favorite) { - true -> now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite)) - false -> now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground)) - } - } - } - - now_playing_details_favorite.setOnClickListener { - when (track.favorite) { - true -> { - favoriteRepository.deleteFavorite(track.id) - now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground)) - } - - false -> { - favoriteRepository.addFavorite(track.id) - now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite)) - } - } - - track.favorite = !track.favorite - - favoriteRepository.fetch(Repository.Origin.Network.origin) - } - - binding.nowPlayingContainer?.nowPlayingDetailsAddToPlaylist?.setOnClickListener { - CommandBus.send(Command.AddToPlaylist(listOf(track))) - } - } - } - } - - private fun changeRepeatMode(index: Int) { - when (index) { - // From no repeat to repeat all - 0 -> { - FFACache.set(this@MainActivity, "repeat", "0") - - binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setImageResource(R.drawable.repeat) - binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setColorFilter( - ContextCompat.getColor( - this, - R.color.controlForeground - ) - ) - binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.alpha = 0.2f - - CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_OFF)) - } - - // From repeat all to repeat one - 1 -> { - FFACache.set(this@MainActivity, "repeat", "1") - - binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setImageResource(R.drawable.repeat) - binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setColorFilter( - ContextCompat.getColor( - this, - R.color.controlForeground - ) - ) - binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.alpha = 1.0f - - CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_ALL)) - } - - // From repeat one to no repeat - 2 -> { - FFACache.set(this@MainActivity, "repeat", "2") - binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setImageResource(R.drawable.repeat_one) - binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.setColorFilter( - ContextCompat.getColor( - this, - R.color.controlForeground - ) - ) - binding.nowPlayingContainer?.nowPlayingDetailsRepeat?.alpha = 1.0f - - CommandBus.send(Command.SetRepeatMode(Player.REPEAT_MODE_ONE)) - } } } @@ -633,4 +351,15 @@ class MainActivity : AppCompatActivity() { } } } + + private fun logout() { + FFA.get().deleteAllData(this@MainActivity) + startActivity( + Intent(this@MainActivity, LoginActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NO_HISTORY + } + ) + + finish() + } } diff --git a/app/src/main/java/audio/funkwhale/ffa/adapters/AlbumsGridAdapter.kt b/app/src/main/java/audio/funkwhale/ffa/adapters/AlbumsGridAdapter.kt index 2e00174..e376617 100644 --- a/app/src/main/java/audio/funkwhale/ffa/adapters/AlbumsGridAdapter.kt +++ b/app/src/main/java/audio/funkwhale/ffa/adapters/AlbumsGridAdapter.kt @@ -41,7 +41,6 @@ class AlbumsGridAdapter( CoverArt.withContext(layoutInflater.context, maybeNormalizeUrl(album.cover())) .fit() - .placeholder(R.drawable.cover) .transform(RoundedCornersTransformation(16, 0)) .into(holder.cover) diff --git a/app/src/main/java/audio/funkwhale/ffa/adapters/BrowseTabsAdapter.kt b/app/src/main/java/audio/funkwhale/ffa/adapters/BrowseTabsAdapter.kt index b8cb5ba..65caed3 100644 --- a/app/src/main/java/audio/funkwhale/ffa/adapters/BrowseTabsAdapter.kt +++ b/app/src/main/java/audio/funkwhale/ffa/adapters/BrowseTabsAdapter.kt @@ -9,29 +9,16 @@ import audio.funkwhale.ffa.fragments.FavoritesFragment import audio.funkwhale.ffa.fragments.PlaylistsFragment import audio.funkwhale.ffa.fragments.RadiosFragment -class BrowseTabsAdapter(val context: Fragment) : - FragmentStateAdapter(context) { - var tabs = mutableListOf() - +class BrowseTabsAdapter(val context: Fragment) : FragmentStateAdapter(context) { override fun getItemCount() = 5 - override fun createFragment(position: Int): Fragment { - tabs.getOrNull(position)?.let { - return it - } - - val fragment = when (position) { - 0 -> ArtistsFragment() - 1 -> AlbumsGridFragment() - 2 -> PlaylistsFragment() - 3 -> RadiosFragment() - 4 -> FavoritesFragment() - else -> ArtistsFragment() - } - - tabs.add(position, fragment) - - return fragment + override fun createFragment(position: Int): Fragment = when (position) { + 0 -> ArtistsFragment() + 1 -> AlbumsGridFragment() + 2 -> PlaylistsFragment() + 3 -> RadiosFragment() + 4 -> FavoritesFragment() + else -> ArtistsFragment() } fun tabText(position: Int): String { diff --git a/app/src/main/java/audio/funkwhale/ffa/fragments/NowPlayingFragment.kt b/app/src/main/java/audio/funkwhale/ffa/fragments/NowPlayingFragment.kt new file mode 100644 index 0000000..539d652 --- /dev/null +++ b/app/src/main/java/audio/funkwhale/ffa/fragments/NowPlayingFragment.kt @@ -0,0 +1,250 @@ +package audio.funkwhale.ffa.fragments + +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.widget.Button +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.PopupMenu +import androidx.customview.widget.Openable +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import audio.funkwhale.ffa.MainNavDirections +import audio.funkwhale.ffa.R +import audio.funkwhale.ffa.databinding.FragmentNowPlayingBinding +import audio.funkwhale.ffa.model.Track +import audio.funkwhale.ffa.repositories.FavoritedRepository +import audio.funkwhale.ffa.repositories.FavoritesRepository +import audio.funkwhale.ffa.repositories.Repository +import audio.funkwhale.ffa.utils.* +import audio.funkwhale.ffa.viewmodel.NowPlayingViewModel +import audio.funkwhale.ffa.views.NowPlayingBottomSheet +import jp.wasabeef.picasso.transformations.RoundedCornersTransformation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class NowPlayingFragment : Fragment(R.layout.fragment_now_playing) { + private val binding by lazy { FragmentNowPlayingBinding.bind(requireView()) } + private val viewModel by viewModels() + private val favoriteRepository by lazy { FavoritesRepository(requireContext()) } + private val favoritedRepository by lazy { FavoritedRepository(requireContext()) } + + private val bottomSheet: BottomSheetIneractable? by lazy { + var view = this.view?.parent + while (view != null) { + if(view is BottomSheetIneractable) return@lazy view + view = view.parent + } + null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.lifecycleOwner = viewLifecycleOwner + + viewModel.currentTrack.distinctUntilChanged().observe(viewLifecycleOwner, ::onTrackChange) + + with(binding.controls) { + currentTrackTitle = viewModel.currentTrackTitle + currentTrackArtist = viewModel.currentTrackArtist + isCurrentTrackFavorite = viewModel.isCurrentTrackFavorite + repeatModeResource = viewModel.repeatModeResource + repeatModeAlpha = viewModel.repeatModeAlpha + currentProgressText = viewModel.currentProgressText + currentDurationText = viewModel.currentDurationText + isPlaying = viewModel.isPlaying + progress = viewModel.progress + + nowPlayingDetailsPrevious.setOnClickListener { + CommandBus.send(Command.PreviousTrack) + } + + nowPlayingDetailsNext.setOnClickListener { + CommandBus.send(Command.NextTrack) + } + + nowPlayingDetailsToggle.setOnClickListener { + CommandBus.send(Command.ToggleState) + } + + nowPlayingDetailsRepeat.setOnClickListener { toggleRepeatMode() } + nowPlayingDetailsProgress.setOnSeekBarChangeListener(OnSeekBarChanged()) + nowPlayingDetailsFavorite.setOnClickListener { onFavorite() } + nowPlayingDetailsAddToPlaylist.setOnClickListener { onAddToPlaylist() } + } + + with(binding.header) { + isBuffering = viewModel.isBuffering + isPlaying = viewModel.isPlaying + progress = viewModel.progress + currentTrackTitle = viewModel.currentTrackTitle + currentTrackArtist = viewModel.currentTrackArtist + + + nowPlayingNext.setOnClickListener { + CommandBus.send(Command.NextTrack) + } + + nowPlayingToggle.setOnClickListener { + CommandBus.send(Command.ToggleState) + } + } + + binding.nowPlayingDetailsInfo.setOnClickListener { openInfoMenu() } + + lifecycleScope.launch(Dispatchers.Main) { + CommandBus.get().collect { onCommand(it) } + } + + lifecycleScope.launch(Dispatchers.Main) { + EventBus.get().collect { onEvent(it) } + } + + lifecycleScope.launch(Dispatchers.Main) { + ProgressBus.get().collect { onProgress(it) } + } + } + + + private fun toggleRepeatMode() { + val cachedRepeatMode = FFACache.getLine(requireContext(), "repeat").toIntOrElse(0) + val iteratedRepeatMode = (cachedRepeatMode + 1) % 3 + FFACache.set(requireContext(), "repeat", "$iteratedRepeatMode") + CommandBus.send(Command.SetRepeatMode(iteratedRepeatMode)) + } + + private fun onAddToPlaylist() { + val currentTrack = viewModel.currentTrack.value ?: return + CommandBus.send(Command.AddToPlaylist(listOf(currentTrack))) + } + + private fun onCommand(command: Command) = when (command) { + is Command.RefreshTrack -> refreshCurrentTrack(command.track) + is Command.SetRepeatMode -> viewModel.repeatMode.postValue(command.mode) + else -> {} + } + + private fun onEvent(event: Event): Unit = when (event) { + is Event.Buffering -> viewModel.isBuffering.postValue(event.value) + is Event.StateChanged -> viewModel.isPlaying.postValue(event.playing) + else -> {} + } + + private fun onFavorite() { + val currentTrack = viewModel.currentTrack.value ?: return + + if (currentTrack.favorite) favoriteRepository.deleteFavorite(currentTrack.id) + else favoriteRepository.addFavorite(currentTrack.id) + + currentTrack.favorite = !currentTrack.favorite + // Trigger UI refresh + viewModel.currentTrack.postValue(viewModel.currentTrack.value) + + favoritedRepository.fetch(Repository.Origin.Network.origin) + } + + private fun onProgress(state: Triple) { + val (current, duration, percent) = state + + val currentMins = (current / 1000) / 60 + val currentSecs = (current / 1000) % 60 + + val durationMins = duration / 60 + val durationSecs = duration % 60 + + viewModel.progress.postValue(percent) + viewModel.currentProgressText.postValue("%02d:%02d".format(currentMins, currentSecs)) + viewModel.currentDurationText.postValue("%02d:%02d".format(durationMins, durationSecs)) + } + + private fun onTrackChange(track: Track?) { + if (track == null) { + binding.header.nowPlayingCover.setImageResource(R.drawable.cover) + binding.nowPlayingDetailCover.setImageResource(R.drawable.cover) + return + } + + CoverArt.withContext(requireContext(), maybeNormalizeUrl(track.album?.cover())) + .fit() + .centerCrop() + .into(binding.nowPlayingDetailCover) + + CoverArt.withContext(requireContext(), maybeNormalizeUrl(track.album?.cover())) + .fit() + .centerCrop() + .transform(RoundedCornersTransformation(16, 0)) + .into(binding.header.nowPlayingCover) + } + + private fun openInfoMenu() { + val currentTrack = viewModel.currentTrack.value ?: return + + PopupMenu( + requireContext(), + binding.nowPlayingDetailsInfo, + Gravity.START, + R.attr.actionOverflowMenuStyle, + 0 + ).apply { + inflate(R.menu.track_info) + + setOnMenuItemClickListener { + bottomSheet?.close() + + when (it.itemId) { + R.id.track_info_artist -> findNavController().navigate( + MainNavDirections.globalBrowseToAlbums( + currentTrack.artist, + currentTrack.album?.cover() + ) + ) + R.id.track_info_album -> currentTrack.album?.let { album -> + findNavController().navigate(MainNavDirections.globalBrowseTracks(album)) + } + R.id.track_info_details -> TrackInfoDetailsFragment.new(currentTrack).show( + requireActivity().supportFragmentManager, "dialog" + ) + } + + true + } + + show() + } + } + + private fun refreshCurrentTrack(track: Track?) { + viewModel.currentTrack.postValue(track) + + val cachedRepeatMode = FFACache.getLine(requireContext(), "repeat").toIntOrElse(0) + viewModel.repeatMode.postValue(cachedRepeatMode % 3) + + // At this point, a non-null track is required + + if (track == null) return + + favoritedRepository.fetch().untilNetwork(lifecycleScope, Dispatchers.IO) { favorites, _, _, _ -> + lifecycleScope.launch(Dispatchers.Main) { + track.favorite = favorites.contains(track.id) + // Trigger UI refresh + viewModel.currentTrack.postValue(viewModel.currentTrack.value) + } + } + } + + inner class OnSeekBarChanged : OnSeekBarChangeListener { + override fun onStopTrackingTouch(view: SeekBar?) {} + + override fun onStartTrackingTouch(view: SeekBar?) {} + + override fun onProgressChanged(view: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + CommandBus.send(Command.Seek(progress)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/audio/funkwhale/ffa/utils/BottomSheetIneractable.kt b/app/src/main/java/audio/funkwhale/ffa/utils/BottomSheetIneractable.kt new file mode 100644 index 0000000..f864591 --- /dev/null +++ b/app/src/main/java/audio/funkwhale/ffa/utils/BottomSheetIneractable.kt @@ -0,0 +1,10 @@ +package audio.funkwhale.ffa.utils + +import androidx.customview.widget.Openable + +interface BottomSheetIneractable: Openable { + val isHidden: Boolean + fun show() + fun hide() + fun toggle() +} \ No newline at end of file diff --git a/app/src/main/java/audio/funkwhale/ffa/utils/CoverArt.kt b/app/src/main/java/audio/funkwhale/ffa/utils/CoverArt.kt index 3cd73d9..85b137f 100644 --- a/app/src/main/java/audio/funkwhale/ffa/utils/CoverArt.kt +++ b/app/src/main/java/audio/funkwhale/ffa/utils/CoverArt.kt @@ -2,7 +2,9 @@ package audio.funkwhale.ffa.utils import android.content.Context import android.net.Uri +import android.transition.CircularPropagation import android.util.Log +import androidx.swiperefreshlayout.widget.CircularProgressDrawable import audio.funkwhale.ffa.BuildConfig import audio.funkwhale.ffa.R import com.squareup.picasso.Downloader @@ -252,9 +254,10 @@ open class CoverArt private constructor() { * The primary entrypoint for the codebase. */ fun withContext(context: Context, url: String?): RequestCreator { - return buildPicasso(context) - .load(url) - .placeholder(R.drawable.cover) + val request = buildPicasso(context).load(url) + if(url == null) request.placeholder(R.drawable.cover) + else request.placeholder(CircularProgressDrawable(context)) + return request.error(R.drawable.cover) } } } 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 9a81a6d..2e53712 100644 --- a/app/src/main/java/audio/funkwhale/ffa/utils/Extensions.kt +++ b/app/src/main/java/audio/funkwhale/ffa/utils/Extensions.kt @@ -149,3 +149,5 @@ inline fun LiveData.mergeWith( } } } + +public fun String?.toIntOrElse(default: Int): Int = this?.toIntOrNull(radix = 10) ?: default \ No newline at end of file diff --git a/app/src/main/java/audio/funkwhale/ffa/utils/ViewBindings.kt b/app/src/main/java/audio/funkwhale/ffa/utils/ViewBindings.kt new file mode 100644 index 0000000..6a12db7 --- /dev/null +++ b/app/src/main/java/audio/funkwhale/ffa/utils/ViewBindings.kt @@ -0,0 +1,25 @@ +package audio.funkwhale.ffa.utils + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.util.Log +import android.widget.ImageButton +import androidx.annotation.ColorRes +import androidx.appcompat.widget.AppCompatImageView +import androidx.databinding.BindingAdapter + + +@BindingAdapter("srcCompat") +fun setImageViewResource(imageView: AppCompatImageView, resource: Any?) = when (resource) { + is Bitmap -> imageView.setImageBitmap(resource) + is Int -> imageView.setImageResource(resource) + is Drawable -> imageView.setImageDrawable(resource) + else -> imageView.setImageDrawable(ColorDrawable(Color.TRANSPARENT)) +} + +@BindingAdapter("tint") +fun setTint(imageView: ImageButton, @ColorRes resource: Int) = resource.let { + imageView.setColorFilter(resource) +} \ No newline at end of file diff --git a/app/src/main/java/audio/funkwhale/ffa/viewmodel/NowPlayingViewModel.kt b/app/src/main/java/audio/funkwhale/ffa/viewmodel/NowPlayingViewModel.kt new file mode 100644 index 0000000..4a972ae --- /dev/null +++ b/app/src/main/java/audio/funkwhale/ffa/viewmodel/NowPlayingViewModel.kt @@ -0,0 +1,56 @@ +package audio.funkwhale.ffa.viewmodel + +import android.app.Application +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.drawable.toDrawable +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import audio.funkwhale.ffa.FFA +import audio.funkwhale.ffa.R +import audio.funkwhale.ffa.model.Track +import audio.funkwhale.ffa.utils.CoverArt +import audio.funkwhale.ffa.utils.maybeNormalizeUrl +import com.google.android.exoplayer2.Player +import com.squareup.picasso.Picasso +import com.squareup.picasso.Target + +class NowPlayingViewModel(app: Application) : AndroidViewModel(app) { + val isBuffering = MutableLiveData(false) + val isPlaying = MutableLiveData(false) + val repeatMode = MutableLiveData(0) + val progress = MutableLiveData(0) + val currentTrack = MutableLiveData(null) + val currentProgressText = MutableLiveData("") + val currentDurationText = MutableLiveData("") + + // Calling distinctUntilChanged() prevents triggering an event when the track hasn't changed + val currentTrackTitle = currentTrack.distinctUntilChanged().map { it?.title ?: "" } + val currentTrackArtist = currentTrack.distinctUntilChanged().map { it?.artist?.name ?: "" } + // Not calling distinctUntilChanged() here as we need to process every event + val isCurrentTrackFavorite = currentTrack.map { + it?.favorite ?: false + } + + val repeatModeResource = repeatMode.distinctUntilChanged().map { + when (it) { + Player.REPEAT_MODE_ONE -> AppCompatResources.getDrawable(context, R.drawable.repeat_one) + else -> AppCompatResources.getDrawable(context, R.drawable.repeat) + } + } + + val repeatModeAlpha = repeatMode.distinctUntilChanged().map { + when (it) { + Player.REPEAT_MODE_OFF -> 0.2f + else -> 1f + } + } + + private val context: Context + get() = getApplication().applicationContext +} diff --git a/app/src/main/java/audio/funkwhale/ffa/views/NowPlayingBottomSheet.kt b/app/src/main/java/audio/funkwhale/ffa/views/NowPlayingBottomSheet.kt new file mode 100644 index 0000000..b0e079f --- /dev/null +++ b/app/src/main/java/audio/funkwhale/ffa/views/NowPlayingBottomSheet.kt @@ -0,0 +1,67 @@ +package audio.funkwhale.ffa.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import androidx.coordinatorlayout.widget.CoordinatorLayout +import audio.funkwhale.ffa.R +import audio.funkwhale.ffa.utils.BottomSheetIneractable +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.card.MaterialCardView + +class NowPlayingBottomSheet @JvmOverloads constructor ( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : MaterialCardView(context, attrs), BottomSheetIneractable { + val behavior = BottomSheetBehavior(context, attrs) + + private val targetHeaderId: Int + + + init { + context.theme.obtainStyledAttributes(attrs, R.styleable.NowPlaying, defStyleAttr, 0).use { + targetHeaderId = it.getResourceId(R.styleable.NowPlaying_target_header, NO_ID) + } + } + + override fun setLayoutParams(params: ViewGroup.LayoutParams?) { + super.setLayoutParams(params) + (params as CoordinatorLayout.LayoutParams).behavior = behavior + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + findViewById(targetHeaderId)?.apply { + behavior.setPeekHeight(this.measuredHeight, false) + this.setOnClickListener { this@NowPlayingBottomSheet.toggle() } + } ?: hide() + } + + // Bottom sheet interactions + override val isHidden: Boolean get() = behavior.state == BottomSheetBehavior.STATE_HIDDEN + + override fun isOpen(): Boolean = behavior.state == BottomSheetBehavior.STATE_EXPANDED + + override fun open() { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + + override fun close() { + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + + override fun show() { + behavior.isHideable = false + close() + } + + override fun hide() { + behavior.isHideable = true + behavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + override fun toggle() { + if (isHidden) return + if (isOpen) close() else open() + } +} diff --git a/app/src/main/java/audio/funkwhale/ffa/views/NowPlayingView.kt b/app/src/main/java/audio/funkwhale/ffa/views/NowPlayingView.kt deleted file mode 100644 index 7ad7374..0000000 --- a/app/src/main/java/audio/funkwhale/ffa/views/NowPlayingView.kt +++ /dev/null @@ -1,255 +0,0 @@ -package audio.funkwhale.ffa.views - -import android.animation.ValueAnimator -import android.content.Context -import android.util.AttributeSet -import android.util.TypedValue -import android.view.GestureDetector -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewTreeObserver -import android.view.animation.DecelerateInterpolator -import audio.funkwhale.ffa.R -import audio.funkwhale.ffa.databinding.PartialNowPlayingBinding -import com.google.android.material.card.MaterialCardView -import kotlin.math.abs -import kotlin.math.min - -class NowPlayingView : MaterialCardView { - val activity: Context - var gestureDetector: GestureDetector? = null - var gestureDetectorCallback: OnGestureDetection? = null - - private val binding = - PartialNowPlayingBinding.inflate(LayoutInflater.from(context), this, true) - - constructor(context: Context) : super(context) { - activity = context - } - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - activity = context - } - - constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style) { - activity = context - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - binding.nowPlayingRoot.measure( - widthMeasureSpec, - MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec), MeasureSpec.UNSPECIFIED) - ) - } - - override fun onVisibilityChanged(changedView: View, visibility: Int) { - super.onVisibilityChanged(changedView, visibility) - - if (visibility == View.VISIBLE && gestureDetector == null) { - viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - gestureDetectorCallback = OnGestureDetection() - gestureDetector = GestureDetector(context, gestureDetectorCallback!!) - - setOnTouchListener { _, motionEvent -> - val ret = gestureDetector?.onTouchEvent(motionEvent) ?: false - - if (motionEvent.actionMasked == MotionEvent.ACTION_UP) { - if (gestureDetectorCallback?.isScrolling == true) { - gestureDetectorCallback?.onUp() - } - } - performClick() - ret - } - - viewTreeObserver.removeOnGlobalLayoutListener(this) - } - }) - } - } - - fun isOpened(): Boolean = gestureDetectorCallback?.isOpened() ?: false - - fun close() { - gestureDetectorCallback?.close() - } - - inner class OnGestureDetection : GestureDetector.SimpleOnGestureListener() { - private var maxHeight = 0 - private var minHeight = 0 - private var maxMargin = 0 - - private var initialTouchY = 0f - private var lastTouchY = 0f - - var isScrolling = false - private var flingAnimator: ValueAnimator? = null - - init { - (layoutParams as? MarginLayoutParams)?.let { - maxMargin = it.marginStart - } - - minHeight = TypedValue().let { - activity.theme.resolveAttribute(R.attr.actionBarSize, it, true) - - TypedValue.complexToDimensionPixelSize(it.data, resources.displayMetrics) - } - - maxHeight = binding.nowPlayingDetails.measuredHeight + (2 * maxMargin) - } - - override fun onDown(e: MotionEvent): Boolean { - initialTouchY = e.rawY - lastTouchY = e.rawY - - return true - } - - fun onUp(): Boolean { - isScrolling = false - - layoutParams.let { - val offsetToMax = maxHeight - height - val offsetToMin = height - minHeight - - flingAnimator = - if (offsetToMin < offsetToMax) ValueAnimator.ofInt(it.height, minHeight) - else ValueAnimator.ofInt(it.height, maxHeight) - - animateFling(500) - - return true - } - } - - override fun onFling( - firstMotionEvent: MotionEvent, - secondMotionEvent: MotionEvent, - velocityX: Float, - velocityY: Float - ): Boolean { - isScrolling = false - - layoutParams.let { - val diff = - if (velocityY < 0) maxHeight - it.height - else it.height - minHeight - - flingAnimator = - if (velocityY < 0) ValueAnimator.ofInt(it.height, maxHeight) - else ValueAnimator.ofInt(it.height, minHeight) - - animateFling(min(abs((diff.toFloat() / velocityY * 1000).toLong()), 600)) - } - - return true - } - - override fun onScroll( - firstMotionEvent: MotionEvent, - secondMotionEvent: MotionEvent, - distanceX: Float, - distanceY: Float - ): Boolean { - isScrolling = true - - layoutParams.let { - val newHeight = it.height + lastTouchY - secondMotionEvent.rawY - val progress = (newHeight - minHeight) / (maxHeight - minHeight) - val newMargin = maxMargin - (maxMargin * progress) - - (layoutParams as? MarginLayoutParams)?.let { params -> - params.marginStart = newMargin.toInt() - params.marginEnd = newMargin.toInt() - params.bottomMargin = newMargin.toInt() - } - - layoutParams = layoutParams.apply { - when { - newHeight <= minHeight -> { - height = minHeight - return true - } - newHeight >= maxHeight -> { - height = maxHeight - return true - } - else -> height = newHeight.toInt() - } - } - - binding.summary.alpha = 1f - progress - - binding.summary.layoutParams = binding.summary.layoutParams.apply { - height = (minHeight * (1f - progress)).toInt() - } - } - - lastTouchY = secondMotionEvent.rawY - - return true - } - - override fun onSingleTapUp(e: MotionEvent): Boolean { - layoutParams.let { - if (height != minHeight) return true - - flingAnimator = ValueAnimator.ofInt(it.height, maxHeight) - - animateFling(300) - } - - return true - } - - fun isOpened(): Boolean = layoutParams.height == maxHeight - - fun close(): Boolean { - layoutParams.let { - if (it.height == minHeight) return true - - flingAnimator = ValueAnimator.ofInt(it.height, minHeight) - - animateFling(300) - } - - return true - } - - private fun animateFling(dur: Long) { - flingAnimator?.apply { - duration = dur - interpolator = DecelerateInterpolator() - - addUpdateListener { valueAnimator -> - layoutParams = layoutParams.apply { - val newHeight = valueAnimator.animatedValue as Int - val progress = (newHeight.toFloat() - minHeight) / (maxHeight - minHeight) - val newMargin = maxMargin - (maxMargin * progress) - - (layoutParams as? MarginLayoutParams)?.let { - it.marginStart = newMargin.toInt() - it.marginEnd = newMargin.toInt() - it.bottomMargin = newMargin.toInt() - } - - height = newHeight - - binding.summary.alpha = 1f - progress - - binding.summary.layoutParams = binding.summary.layoutParams.apply { - height = (minHeight * (1f - progress)).toInt() - } - } - } - - start() - } - } - } -} diff --git a/app/src/main/java/audio/funkwhale/ffa/views/SquareImageView.kt b/app/src/main/java/audio/funkwhale/ffa/views/SquareImageView.kt index 586131a..e148e10 100644 --- a/app/src/main/java/audio/funkwhale/ffa/views/SquareImageView.kt +++ b/app/src/main/java/audio/funkwhale/ffa/views/SquareImageView.kt @@ -4,7 +4,7 @@ import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView -class SquareImageView : AppCompatImageView { +open class SquareImageView : AppCompatImageView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style) @@ -12,6 +12,8 @@ class SquareImageView : AppCompatImageView { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) - setMeasuredDimension(measuredWidth, measuredWidth) + val dimension = if(measuredWidth == 0 && measuredHeight > 0) measuredHeight else measuredWidth + + setMeasuredDimension(dimension, dimension) } } diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 4834d5c..30c86da 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -1,64 +1,82 @@ - - + android:layout_height="0dp" + android:background="@color/surface" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_weight="10"> + - + tools:layout="@layout/fragment_artists" /> - + + + - - - + android:layout_height="0dp" + app:layout_constraintBottom_toTopOf="@id/appbar_wrapper" + app:layout_constraintTop_toTopOf="parent"> + + + + - - - - - + app:layout_constraintBottom_toBottomOf="parent"> - + + + diff --git a/app/src/main/res/layout-land/fragment_now_playing.xml b/app/src/main/res/layout-land/fragment_now_playing.xml new file mode 100644 index 0000000..4480aaf --- /dev/null +++ b/app/src/main/res/layout-land/fragment_now_playing.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/partial_now_playing.xml b/app/src/main/res/layout-land/partial_now_playing.xml deleted file mode 100644 index 03e6229..0000000 --- a/app/src/main/res/layout-land/partial_now_playing.xml +++ /dev/null @@ -1,251 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 246e22a..a732247 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,50 +1,65 @@ - + + - + android:layout_height="0dp" + android:background="@color/surface" + app:layout_constraintVertical_weight="10" + app:layout_constraintTop_toTopOf="parent"> + android:id="@+id/nav_host_fragment" + android:name="androidx.navigation.fragment.NavHostFragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:defaultNavHost="true" + app:layout_behavior="@string/appbar_scrolling_view_behavior" + app:navGraph="@navigation/main_nav" + tools:layout="@layout/fragment_artists" /> + - + + + android:layout_height="match_parent" + android:name="audio.funkwhale.ffa.fragments.NowPlayingFragment" + tools:layout="@layout/fragment_now_playing"/> + + - - - + - - + android:id="@+id/appbar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="bottom" + android:theme="@style/AppTheme.AppBar" + app:backgroundTint="@color/elevatedSurface" + app:layout_insetEdge="bottom" + app:navigationIcon="@drawable/funkwhaleshape" + tools:menu="@menu/toolbar" /> + + diff --git a/app/src/main/res/layout/fragment_now_playing.xml b/app/src/main/res/layout/fragment_now_playing.xml new file mode 100644 index 0000000..e22e1d0 --- /dev/null +++ b/app/src/main/res/layout/fragment_now_playing.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/partial_now_playing.xml b/app/src/main/res/layout/partial_now_playing.xml deleted file mode 100644 index 560b798..0000000 --- a/app/src/main/res/layout/partial_now_playing.xml +++ /dev/null @@ -1,283 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/partial_now_playing_controls.xml b/app/src/main/res/layout/partial_now_playing_controls.xml new file mode 100644 index 0000000..4196c71 --- /dev/null +++ b/app/src/main/res/layout/partial_now_playing_controls.xml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/partial_now_playing_header.xml b/app/src/main/res/layout/partial_now_playing_header.xml new file mode 100644 index 0000000..960c43f --- /dev/null +++ b/app/src/main/res/layout/partial_now_playing_header.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main_nav.xml b/app/src/main/res/navigation/main_nav.xml index 6d79150..1869352 100644 --- a/app/src/main/res/navigation/main_nav.xml +++ b/app/src/main/res/navigation/main_nav.xml @@ -1,103 +1,119 @@ + 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"> - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..2a5fe06 --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + \ 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 f36bc4a..51195e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,8 @@ Toggle playback Previous track Next track + Repeat mode + Add to favorties This track could not be played %d album