Add playback fragment

Add a fragment to show songs that are currently being played.
This commit is contained in:
OxygenCobalt 2020-10-05 15:54:53 -06:00
parent 21ff93d5f0
commit a72cb48c71
18 changed files with 266 additions and 103 deletions

View file

@ -17,8 +17,8 @@ TODOs surrounded with !s are things I tried to do, but failed for reasons includ
/songs/
- Search when LibraryFragment isnt enabled
- ? Sorting ?
- ? Search ?
- ? Fast Scrolling ?
/library/
@ -28,9 +28,14 @@ TODOs surrounded with !s are things I tried to do, but failed for reasons includ
- ? Add Nested Nav to Library ViewPager fragment [Hold fire on this until everything else is added, there could be sneaky bugs later on if you add it now] ?
- ! Move Adapter functionality to ListAdapter [RecyclerView scrolls to middle/bottom when data is re-sorted] !
/playback/
-
/other/
- Highlight recycler items when they are being played
/bugs/
- Fix issue where fast navigations will cause the app to not display anything
To be added:
/prefs/
/playback/
/prefs/

View file

@ -47,9 +47,9 @@ dependencies {
// --- SUPPORT ---
// General
implementation 'androidx.core:core-ktx:1.3.1'
implementation 'androidx.activity:activity:1.2.0-alpha08'
implementation 'androidx.fragment:fragment:1.3.0-alpha08'
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.activity:activity:1.2.0-beta01'
implementation 'androidx.fragment:fragment:1.3.0-beta01'
// Layout
implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
@ -71,7 +71,7 @@ dependencies {
implementation 'io.coil-kt:coil:0.13.0'
// Material
implementation 'com.google.android.material:material:1.3.0-alpha02'
implementation 'com.google.android.material:material:1.3.0-alpha03'
// Lint
ktlint "com.pinterest:ktlint:0.37.2"

View file

@ -41,7 +41,7 @@ class MainFragment : Fragment() {
val binding = FragmentMainBinding.inflate(inflater)
// If musicModel was cleared while the app was closed [Likely due to Auxio being suspended
// in the background], then navigate back to loading to reload the music.
// in the background], then navigate back to LoadingFragment to reload the music.
if (musicModel.response.value == null) {
findNavController().navigate(MainFragmentDirections.actionReturnToLoading())
@ -57,7 +57,7 @@ class MainFragment : Fragment() {
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
binding.lifecycleOwner = this
binding.mainViewPager.adapter = PagerAdapter()
// Link the ViewPager & Tab View

View file

@ -13,6 +13,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentAlbumDetailBinding
import org.oxycblt.auxio.detail.adapters.DetailSongAdapter
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.recycler.ClickListener
import org.oxycblt.auxio.theme.applyDivider
import org.oxycblt.auxio.theme.disable
@ -21,6 +22,7 @@ class AlbumDetailFragment : Fragment() {
private val args: AlbumDetailFragmentArgs by navArgs()
private val detailModel: DetailViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
@ -45,7 +47,7 @@ class AlbumDetailFragment : Fragment() {
val songAdapter = DetailSongAdapter(
ClickListener {
Log.d(this::class.simpleName, it.name)
playbackModel.updateSong(it)
}
)

View file

@ -17,13 +17,15 @@ import androidx.transition.TransitionManager
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentLibraryBinding
import org.oxycblt.auxio.library.recycler.LibraryAdapter
import org.oxycblt.auxio.library.recycler.SearchAdapter
import org.oxycblt.auxio.library.adapters.LibraryAdapter
import org.oxycblt.auxio.library.adapters.SearchAdapter
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.recycler.ShowMode
import org.oxycblt.auxio.theme.applyColor
import org.oxycblt.auxio.theme.applyDivider
@ -36,6 +38,7 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
}
private val libraryModel: LibraryViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
@ -173,6 +176,13 @@ class LibraryFragment : Fragment(), SearchView.OnQueryTextListener {
}
private fun navToItem(baseModel: BaseModel) {
// If the item is a song [That was selected through search], then update the playback
// to that song instead of doing any naviagation
if (baseModel is Song) {
playbackModel.updateSong(baseModel)
return
}
if (!libraryModel.isNavigating) {
libraryModel.updateNavigationStatus(true)

View file

@ -1,4 +1,4 @@
package org.oxycblt.auxio.library.recycler
package org.oxycblt.auxio.library.adapters
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
@ -13,7 +13,8 @@ import org.oxycblt.auxio.recycler.viewholders.ArtistViewHolder
import org.oxycblt.auxio.recycler.viewholders.GenreViewHolder
// A ListAdapter that can contain three different types of ViewHolders depending
// the showmode given. It cannot display multiple types of viewholders *at once*.
// the ShowMode given.
// It cannot display multiple ViewHolders *at once* however. That's what SearchAdapter is for.
class LibraryAdapter(
private val showMode: ShowMode,
private val doOnClick: (BaseModel) -> Unit

View file

@ -1,4 +1,4 @@
package org.oxycblt.auxio.library.recycler
package org.oxycblt.auxio.library.adapters
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter

View file

@ -20,62 +20,44 @@ import org.oxycblt.auxio.music.processing.MusicLoaderResponse
class LoadingFragment : Fragment(R.layout.fragment_loading) {
// LoadingFragment is [hopefully] going to be the first one to have to create musicModel,
// so pass a factory instance so that the model has access to the application resources.
private val musicModel: MusicViewModel by activityViewModels {
MusicViewModel.Factory(requireActivity().application)
}
private lateinit var binding: FragmentLoadingBinding
private lateinit var permLauncher: ActivityResultLauncher<String>
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentLoadingBinding.inflate(inflater)
binding.lifecycleOwner = this
binding.musicModel = musicModel
musicModel.response.observe(
viewLifecycleOwner,
{ response ->
onMusicLoadResponse(response)
}
)
musicModel.doReload.observe(
viewLifecycleOwner,
{ retry ->
onRetry(retry)
}
)
musicModel.doGrant.observe(
viewLifecycleOwner,
{ grant ->
onGrant(grant)
}
)
val binding = FragmentLoadingBinding.inflate(inflater)
// Set up the permission launcher, as its disallowed outside of onCreate.
permLauncher =
val permLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted: Boolean ->
// If its actually granted, restart the loading process again.
if (granted) {
wipeViews()
wipeViews(binding)
musicModel.reload()
}
}
// --- UI SETUP ---
binding.lifecycleOwner = this
binding.musicModel = musicModel
// --- VIEWMODEL SETUP ---
musicModel.response.observe(viewLifecycleOwner) { onMusicLoadResponse(it, binding) }
musicModel.doReload.observe(viewLifecycleOwner) { onRetry(it, binding) }
musicModel.doGrant.observe(viewLifecycleOwner) { onGrant(it, permLauncher) }
// Force an error screen if the permissions are denied or the prompt needs to be shown.
if (checkPerms()) {
onNoPerms()
onNoPerms(binding)
} else {
musicModel.go()
}
@ -96,31 +78,31 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
) == PackageManager.PERMISSION_DENIED
}
private fun onMusicLoadResponse(response: MusicLoaderResponse?) {
private fun onMusicLoadResponse(
response: MusicLoaderResponse?,
binding: FragmentLoadingBinding
) {
if (response == MusicLoaderResponse.DONE) {
findNavController().navigate(
LoadingFragmentDirections.actionToMain()
)
} else {
binding.let { binding ->
binding.loadingErrorText.text =
if (response == MusicLoaderResponse.NO_MUSIC)
getString(R.string.error_no_music)
else
getString(R.string.error_music_load_failed)
binding.loadingErrorText.text =
if (response == MusicLoaderResponse.NO_MUSIC)
getString(R.string.error_no_music)
else
getString(R.string.error_music_load_failed)
// If the response wasn't a success, then show the specific error message
// depending on which error response was given, along with a retry button
binding.loadingBar.visibility = View.GONE
binding.loadingErrorText.visibility = View.VISIBLE
binding.loadingErrorIcon.visibility = View.VISIBLE
binding.loadingRetryButton.visibility = View.VISIBLE
}
// If the response wasn't a success, then show the specific error message
// depending on which error response was given, along with a retry button
binding.loadingBar.visibility = View.GONE
binding.loadingErrorText.visibility = View.VISIBLE
binding.loadingErrorIcon.visibility = View.VISIBLE
binding.loadingRetryButton.visibility = View.VISIBLE
}
}
private fun onNoPerms() {
private fun onNoPerms(binding: FragmentLoadingBinding) {
// If there are no perms, switch out the view elements as if an error screen was being
// shown, but show the label that Auxio needs to read external storage to function,
// along with a GRANT button
@ -133,15 +115,15 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
binding.loadingErrorText.text = getString(R.string.error_no_perms)
}
private fun onRetry(retry: Boolean) {
private fun onRetry(retry: Boolean, binding: FragmentLoadingBinding) {
if (retry) {
wipeViews()
wipeViews(binding)
musicModel.doneWithReload()
}
}
private fun onGrant(grant: Boolean) {
private fun onGrant(grant: Boolean, permLauncher: ActivityResultLauncher<String>) {
if (grant) {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
@ -150,7 +132,7 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
}
// Wipe views and switch back to the plain ProgressBar
private fun wipeViews() {
private fun wipeViews(binding: FragmentLoadingBinding) {
binding.loadingBar.visibility = View.VISIBLE
binding.loadingErrorText.visibility = View.GONE
binding.loadingErrorIcon.visibility = View.GONE

View file

@ -13,9 +13,9 @@ sealed class BaseModel {
data class Song(
override val id: Long = -1,
override var name: String,
val albumId: Long,
val track: Int,
val duration: Long,
val albumId: Long = -1,
val track: Int = -1,
val duration: Long = 0,
) : BaseModel() {
lateinit var album: Album

View file

@ -0,0 +1,52 @@
package org.oxycblt.auxio.playback
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.databinding.FragmentCompactPlaybackBinding
import org.oxycblt.auxio.music.MusicViewModel
class CompactPlaybackFragment : Fragment() {
private val musicModel: MusicViewModel by activityViewModels {
MusicViewModel.Factory(requireActivity().application)
}
private val playbackModel: PlaybackViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val binding = FragmentCompactPlaybackBinding.inflate(inflater)
binding.lifecycleOwner = this
// Put a placeholder song in the binding & hide the playback fragment initially,
// as for some reason the attach event doesn't register anymore w/LiveData
binding.song = musicModel.songs.value!![0]
binding.root.visibility = View.GONE
playbackModel.currentSong.observe(viewLifecycleOwner) {
if (it == null) {
Log.d(this::class.simpleName, "Hiding playback bar due to no song being played.")
binding.root.visibility = View.GONE
} else {
Log.d(this::class.simpleName, "Updating song display to ${it.name}")
binding.song = it
binding.root.visibility = View.VISIBLE
}
}
Log.d(this::class.simpleName, "Fragment Created")
return binding.root
}
}

View file

@ -0,0 +1,15 @@
package org.oxycblt.auxio.playback
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.oxycblt.auxio.music.Song
class PlaybackViewModel : ViewModel() {
private val mCurrentSong = MutableLiveData<Song>()
val currentSong: LiveData<Song> get() = mCurrentSong
fun updateSong(song: Song) {
mCurrentSong.value = song
}
}

View file

@ -9,15 +9,17 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.databinding.FragmentSongsBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.recycler.ClickListener
import org.oxycblt.auxio.theme.applyDivider
class SongsFragment : Fragment() {
private val musicModel: MusicViewModel by activityViewModels {
MusicViewModel.Factory(requireActivity().application)
}
private val playbackModel: PlaybackViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -31,7 +33,7 @@ class SongsFragment : Fragment() {
adapter = SongAdapter(
musicModel.songs.value!!,
ClickListener { song ->
Log.d(this::class.simpleName, song.name)
playbackModel.updateSong(song)
}
)
applyDivider()

View file

@ -87,7 +87,7 @@ fun resolveAttr(context: Context, @AttrRes attr: Int): Int {
}
// Apply a color to a Menu Item
fun MenuItem.applyColor(@ColorRes color: Int) {
fun MenuItem.applyColor(@ColorInt color: Int) {
SpannableString(title).apply {
setSpan(ForegroundColorSpan(color), 0, length, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE)
title = this

View file

@ -0,0 +1,78 @@
<?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"
tools:context=".playback.CompactPlaybackFragment">
<data>
<variable
name="song"
type="org.oxycblt.auxio.music.Song" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:animateLayoutChanges="true"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ProgressBar
android:id="@+id/song_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="@dimen/playback_progress_size"
android:clickable="false"
android:progressBackgroundTint="?android:attr/colorControlNormal"
android:progressTint="?android:attr/colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:progress="70" />
<ImageView
android:id="@+id/album_cover"
android:layout_width="@dimen/cover_size_compact"
android:layout_height="@dimen/cover_size_compact"
android:contentDescription="@{@string/description_album_cover(song.name)}"
android:layout_margin="@dimen/margin_smallish"
app:coverArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/song_progress"
tools:src="@drawable/ic_song" />
<TextView
android:id="@+id/song_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_semibold"
android:text="@{song.name}"
android:layout_marginStart="@dimen/margin_smallish"
android:textAppearance="@style/TextAppearance.SmallHeader"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
app:layout_constraintBottom_toTopOf="@+id/song_info"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Song Name" />
<TextView
android:id="@+id/song_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_smallish"
android:ellipsize="marquee"
android:singleLine="true"
android:marqueeRepeatLimit="marquee_forever"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:text="@{@string/format_info(song.album.name, song.album.artist.name)}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toBottomOf="@+id/song_name"
tools:text="Artist / Album" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -15,6 +15,13 @@
android:layout_height="0dp"
android:layout_weight="1" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/playback_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:name="org.oxycblt.auxio.playback.CompactPlaybackFragment"
android:elevation="@dimen/elevation_normal" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/main_tabs"
android:layout_width="match_parent"
@ -29,7 +36,8 @@
app:tabIndicator="@drawable/indicator"
app:tabIndicatorColor="?android:attr/colorPrimary"
app:tabMode="fixed"
app:tabRippleColor="@color/selection_color" />
app:tabRippleColor="@color/selection_color"
tools:background="@color/control_color" />
</LinearLayout>
</layout>

View file

@ -12,10 +12,10 @@
<action
android:id="@+id/action_to_main"
app:destination="@id/main_fragment"
app:enterAnim="@anim/fragment_fade_enter"
app:exitAnim="@anim/fragment_fade_exit"
app:popEnterAnim="@anim/fragment_fade_enter"
app:popExitAnim="@anim/fragment_fade_exit"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:popUpTo="@id/loading_fragment"
app:popUpToInclusive="true"
app:launchSingleTop="true" />
@ -27,24 +27,24 @@
tools:layout="@layout/fragment_main">
<action
android:id="@+id/action_show_artist"
app:enterAnim="@anim/fragment_fade_enter"
app:exitAnim="@anim/fragment_fade_exit"
app:popEnterAnim="@anim/fragment_fade_enter"
app:popExitAnim="@anim/fragment_fade_exit"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:destination="@id/artist_detail_fragment" />
<action
android:id="@+id/action_show_album"
app:enterAnim="@anim/fragment_fade_enter"
app:exitAnim="@anim/fragment_fade_exit"
app:popEnterAnim="@anim/fragment_fade_enter"
app:popExitAnim="@anim/fragment_fade_exit"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:destination="@id/album_detail_fragment" />
<action
android:id="@+id/action_show_genre"
app:enterAnim="@anim/fragment_fade_enter"
app:exitAnim="@anim/fragment_fade_exit"
app:popEnterAnim="@anim/fragment_fade_enter"
app:popExitAnim="@anim/fragment_fade_exit"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:destination="@id/genreDetailFragment" />
<action
android:id="@+id/action_return_to_loading"
@ -62,10 +62,10 @@
app:argType="long" />
<action
android:id="@+id/action_show_album"
app:enterAnim="@anim/fragment_fade_enter"
app:exitAnim="@anim/fragment_fade_exit"
app:popEnterAnim="@anim/fragment_fade_enter"
app:popExitAnim="@anim/fragment_fade_exit"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:destination="@id/album_detail_fragment" />
</fragment>
<fragment
@ -81,10 +81,10 @@
app:argType="boolean" />
<action
android:id="@+id/action_show_parent_artist"
app:enterAnim="@anim/fragment_fade_enter"
app:exitAnim="@anim/fragment_fade_exit"
app:popEnterAnim="@anim/fragment_fade_enter"
app:popExitAnim="@anim/fragment_fade_exit"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:destination="@id/artist_detail_fragment" />
</fragment>
<fragment
@ -94,10 +94,10 @@
tools:layout="@layout/fragment_genre_detail">
<action
android:id="@+id/action_show_artist"
app:enterAnim="@anim/fragment_fade_enter"
app:exitAnim="@anim/fragment_fade_exit"
app:popEnterAnim="@anim/fragment_fade_enter"
app:popExitAnim="@anim/fragment_fade_exit"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim"
app:destination="@id/artist_detail_fragment" />
<argument
android:name="genreId"

View file

@ -5,12 +5,14 @@
<dimen name="padding_medium">16dp</dimen>
<dimen name="margin_small">8dp</dimen>
<dimen name="margin_smallish">10dp</dimen>
<dimen name="margin_medium">16dp</dimen>
<dimen name="status_icon_size">48dp</dimen>
<dimen name="tab_menu_size">40dp</dimen>
<dimen name="cover_size_small">36dp</dimen>
<dimen name="cover_size_compact">44dp</dimen>
<dimen name="cover_size_normal">56dp</dimen>
<dimen name="cover_size_large">68dp</dimen>
@ -26,5 +28,7 @@
<dimen name="divider_ripple_size">18dp</dimen>
<dimen name="playback_progress_size">2dp</dimen>
<dimen name="elevation_normal">4dp</dimen>
</resources>

View file

@ -27,6 +27,10 @@
<item name="android:textSize">@dimen/detail_header_size_max</item>
</style>
<style name="TextAppearance.SmallHeader" parent="TextAppearance.MaterialComponents.Body2">
<item name="android:fontFamily">@font/inter_semibold</item>
</style>
<style name="AppThemeOverlay.Popup" parent="ThemeOverlay.AppCompat.DayNight">
<item name="android:colorBackground">@color/background</item>
<item name="colorControlHighlight">@color/selection_color</item>