detail: add framework for song details

Add the basic framework for a song detail view.
This commit is contained in:
OxygenCobalt 2022-06-11 13:29:06 -06:00
parent 277e5a151f
commit 3f85678d99
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 387 additions and 2 deletions

View file

@ -17,9 +17,16 @@
package org.oxycblt.auxio.detail
import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.recycler.DiscHeader
import org.oxycblt.auxio.detail.recycler.SortHeader
@ -27,11 +34,13 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
@ -42,9 +51,15 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author OxygenCobalt
*/
class DetailViewModel : ViewModel(), MusicStore.Callback {
data class DetailSong(val song: Song, val bitrateKbps: Int?, val sampleRate: Int?)
private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val _currentSong = MutableStateFlow<DetailSong?>(null)
val currentSong: StateFlow<DetailSong?>
get() = _currentSong
private val _currentAlbum = MutableStateFlow<Album?>(null)
val currentAlbum: StateFlow<Album?>
get() = _currentAlbum
@ -88,6 +103,13 @@ class DetailViewModel : ViewModel(), MusicStore.Callback {
currentGenre.value?.let(::refreshGenreData)
}
fun setSongId(context: Context, id: Long) {
if (_currentSong.value?.run { song.id } == id) return
val library = unlikelyToBeNull(musicStore.library)
val song = requireNotNull(library.songs.find { it.id == id }) { "Invalid song id provided" }
generateDetailSong(context, song)
}
fun setAlbumId(id: Long) {
if (_currentAlbum.value?.id == id) return
val library = unlikelyToBeNull(musicStore.library)
@ -120,6 +142,41 @@ class DetailViewModel : ViewModel(), MusicStore.Callback {
musicStore.addCallback(this)
}
private fun generateDetailSong(context: Context, song: Song) {
viewModelScope.launch {
_currentSong.value =
withContext(Dispatchers.IO) {
val extractor = MediaExtractor()
try {
extractor.setDataSource(context, song.uri, emptyMap())
} catch (e: Exception) {
logW("Unable to extract song attributes.")
logW(e.stackTraceToString())
return@withContext DetailSong(song, null, null)
}
val format = extractor.getTrackFormat(0)
val bitrate =
try {
format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000 // bps -> kbps
} catch (e: Exception) {
null
}
val sampleRate =
try {
format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
} catch (e: Exception) {
null
}
DetailSong(song, bitrate, sampleRate)
}
}
}
private fun refreshAlbumData(album: Album) {
logD("Refreshing album data")
val data = mutableListOf<Item>(album)
@ -166,6 +223,15 @@ class DetailViewModel : ViewModel(), MusicStore.Callback {
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
// TODO: Add when we have a context
// val song = currentSong.value
// if (song != null) {
// val newSong = library.sanitize(song.song)
// if (newSong != null) {
// generateDetailSong(newSong)
// }
// }
val album = currentAlbum.value
if (album != null) {
val newAlbum = library.sanitize(album).also { _currentAlbum.value = it }

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.graphics.drawable.RippleDrawable
import android.os.Build
import android.text.method.MovementMethod
import android.util.AttributeSet
import android.view.View
import androidx.annotation.AttrRes
import androidx.core.graphics.drawable.DrawableCompat.setTint
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.textfield.TextInputEditText
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrStateListSafe
class ReadOnlyTextInput : TextInputEditText {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(
context: Context,
attrs: AttributeSet?,
@AttrRes defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
focusable = View.FOCUSABLE_AUTO
}
}
override fun getFreezesText(): Boolean = false
override fun getDefaultEditable(): Boolean = false
override fun getDefaultMovementMethod(): MovementMethod? = null
}

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.launch
class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
private val detailModel: DetailViewModel by activityViewModels()
override fun onCreateBinding(inflater: LayoutInflater) =
DialogSongDetailBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) {
super.onConfigDialog(builder)
builder.setTitle(R.string.lbl_props).setPositiveButton(R.string.lbl_ok, null)
}
override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
detailModel.setSongId(requireContext(), requireNotNull(arguments).getLong(ARG_ID))
launch { detailModel.currentSong.collect(::updateSong) }
}
private fun updateSong(song: DetailViewModel.DetailSong?) {
val binding = requireBinding()
if (song != null) {
binding.detailContainer.isGone = false
binding.detailFileName.setText(song.song.fileName)
if (song.bitrateKbps != null) {
binding.detailBitrate.setText(getString(R.string.fmt_bitrate, song.bitrateKbps))
} else {
binding.detailBitrate.setText(R.string.def_bitrate)
}
if (song.sampleRate != null) {
binding.detailSampleRate.setText(
getString(R.string.fmt_sample_rate, song.sampleRate))
} else {
binding.detailSampleRate.setText(R.string.def_sample_rate)
}
} else {
binding.detailContainer.isGone = true
}
}
companion object {
fun from(song: Song): SongDetailDialog {
val instance = SongDetailDialog()
instance.arguments = Bundle().apply { putLong(ARG_ID, song.id) }
return instance
}
const val TAG = BuildConfig.APPLICATION_ID + ".tag.SONG_DETAILS"
private const val ARG_ID = BuildConfig.APPLICATION_ID + ".arg.SONG_ID"
}
}

View file

@ -226,7 +226,7 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() {
val buffer = replaceOutputBuffer(size)
if (volume == 1f) {
// No need to apply ReplayGain, do a memmove using put instead of
// No need to apply ReplayGain, do a mem move using put instead of
// a for loop (the latter is not efficient)
buffer.put(inputBuffer.slice())
} else {

View file

@ -206,6 +206,7 @@ class PlaybackService :
}
override fun onPlayerError(error: PlaybackException) {
// TODO: Replace with no skipping and a notification instead
// If there's any issue, just go to the next song.
playbackManager.next()
}

View file

@ -26,6 +26,7 @@ import androidx.core.view.children
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.SongDetailDialog
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
@ -59,7 +60,7 @@ fun Fragment.newMenu(anchor: View, data: Item, flag: Int = ActionMenu.FLAG_NONE)
* TODO: Add multi-select
*/
class ActionMenu(
activity: AppCompatActivity,
private val activity: AppCompatActivity,
anchor: View,
private val data: Item,
private val flag: Int
@ -180,6 +181,12 @@ class ActionMenu(
navModel.exploreNavigateTo(data.artist)
}
}
R.id.action_song_detail -> {
if (data is Song) {
SongDetailDialog.from(data)
.show(activity.supportFragmentManager, SongDetailDialog.TAG)
}
}
}
}

View file

@ -131,6 +131,28 @@ fun Context.getAttrColorSafe(@AttrRes attr: Int): Int {
return getColorSafe(color)
}
/**
* Convenience method for getting a color attribute safely.
* @param attr The color attribute
* @return The attribute requested, or black if an error occurred.
*/
@ColorInt
fun Context.getAttrStateListSafe(@AttrRes attr: Int): ColorStateList {
// First resolve the attribute into its ID
val resolvedAttr = TypedValue()
theme.resolveAttribute(attr, resolvedAttr, true)
// Then convert it to a proper color
val color =
if (resolvedAttr.resourceId != 0) {
resolvedAttr.resourceId
} else {
resolvedAttr.data
}
return getColorStateListSafe(color)
}
/**
* Convenience method for getting a [Drawable] safely.
* @param drawable The drawable resource

View file

@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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"
android:paddingTop="@dimen/spacing_medium">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/detail_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="@dimen/spacing_mid_large"
android:paddingEnd="@dimen/spacing_mid_large"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/lbl_file_name"
app:expandedHintEnabled="false">
<org.oxycblt.auxio.detail.ReadOnlyTextInput
android:id="@+id/detail_file_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="file.mp3" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium"
android:hint="@string/lbl_relative_path"
app:expandedHintEnabled="false">
<org.oxycblt.auxio.detail.ReadOnlyTextInput
android:id="@+id/detail_relative_path"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="/path/to" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium"
android:hint="@string/lbl_format"
app:expandedHintEnabled="false">
<org.oxycblt.auxio.detail.ReadOnlyTextInput
android:id="@+id/detail_format"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="MP3" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium"
app:expandedHintEnabled="false"
android:hint="@string/lbl_size">
<org.oxycblt.auxio.detail.ReadOnlyTextInput
android:id="@+id/detail_size"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="16 MB" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium"
android:hint="@string/lbl_sort_duration"
app:expandedHintEnabled="false">
<org.oxycblt.auxio.detail.ReadOnlyTextInput
android:id="@+id/detail_duration"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="3:20" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium"
android:hint="@string/lbl_bitrate"
app:expandedHintEnabled="false">
<org.oxycblt.auxio.detail.ReadOnlyTextInput
android:id="@+id/detail_bitrate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="320 kb/s" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium"
android:hint="@string/lbl_sample_rate"
app:expandedHintEnabled="false">
<org.oxycblt.auxio.detail.ReadOnlyTextInput
android:id="@+id/detail_sample_rate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="44100 Hz" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</FrameLayout>

View file

@ -12,4 +12,7 @@
<item
android:id="@+id/action_go_album"
android:title="@string/lbl_go_album" />
<item
android:id="@+id/action_song_detail"
android:title="@string/lbl_song_detail" />
</menu>

View file

@ -47,6 +47,15 @@
<string name="lbl_go_artist">Go to artist</string>
<string name="lbl_go_album">Go to album</string>
<string name="lbl_song_detail">View properties</string>
<string name="lbl_props">File properties</string>
<string name="lbl_file_name">File name</string>
<string name="lbl_relative_path">Relative path</string>
<string name="lbl_format">Format</string>
<string name="lbl_size">Size</string>
<string name="lbl_bitrate">Bitrate</string>
<string name="lbl_sample_rate">Sample rate</string>
<string name="lbl_state_saved">State saved</string>
@ -160,6 +169,8 @@
<string name="def_date">No Date</string>
<string name="def_track">No Track Number</string>
<string name="def_playback">No music playing</string>
<string name="def_bitrate">No Bitrate</string>
<string name="def_sample_rate">No Sample Rate</string>
<string name="def_widget_song">Song Name</string>
<string name="def_widget_artist">Artist Name</string>
@ -187,6 +198,8 @@
<string name="fmt_db_pos">+%.1f dB</string>
<string name="fmt_db_neg">-%.1f dB</string>
<string name="fmt_bitrate">%d KB/s</string>
<string name="fmt_sample_rate">%d Hz</string>
<string name="fmt_indexing">Loading your music library… (%1$d/%2$d)</string>

View file

@ -33,6 +33,7 @@
<item name="materialAlertDialogTheme">@style/Theme.Auxio.Dialog</item>
<item name="sliderStyle">@style/Widget.Auxio.Slider</item>
<item name="linearProgressIndicatorStyle">@style/Widget.Auxio.LinearProgressIndicator</item>
<item name="textInputStyle">@style/Widget.Material3.TextInputLayout.OutlinedBox</item>
<item name="textAppearanceDisplayLarge">@style/TextAppearance.Auxio.DisplayLarge</item>
<item name="textAppearanceDisplayMedium">@style/TextAppearance.Auxio.DisplayMedium</item>

View file

@ -54,6 +54,7 @@
app:summary="@string/set_quality_covers_desc"
app:title="@string/set_quality_covers" />
<!-- FIXME: Should not be dependent on cover option -->
<org.oxycblt.auxio.settings.pref.M3SwitchPreference
app:defaultValue="false"
app:dependency="KEY_SHOW_COVERS"