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 package org.oxycblt.auxio.detail
import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.recycler.DiscHeader import org.oxycblt.auxio.detail.recycler.DiscHeader
import org.oxycblt.auxio.detail.recycler.SortHeader 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.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.Header import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -42,9 +51,15 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class DetailViewModel : ViewModel(), MusicStore.Callback { class DetailViewModel : ViewModel(), MusicStore.Callback {
data class DetailSong(val song: Song, val bitrateKbps: Int?, val sampleRate: Int?)
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private val _currentSong = MutableStateFlow<DetailSong?>(null)
val currentSong: StateFlow<DetailSong?>
get() = _currentSong
private val _currentAlbum = MutableStateFlow<Album?>(null) private val _currentAlbum = MutableStateFlow<Album?>(null)
val currentAlbum: StateFlow<Album?> val currentAlbum: StateFlow<Album?>
get() = _currentAlbum get() = _currentAlbum
@ -88,6 +103,13 @@ class DetailViewModel : ViewModel(), MusicStore.Callback {
currentGenre.value?.let(::refreshGenreData) 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) { fun setAlbumId(id: Long) {
if (_currentAlbum.value?.id == id) return if (_currentAlbum.value?.id == id) return
val library = unlikelyToBeNull(musicStore.library) val library = unlikelyToBeNull(musicStore.library)
@ -120,6 +142,41 @@ class DetailViewModel : ViewModel(), MusicStore.Callback {
musicStore.addCallback(this) 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) { private fun refreshAlbumData(album: Album) {
logD("Refreshing album data") logD("Refreshing album data")
val data = mutableListOf<Item>(album) val data = mutableListOf<Item>(album)
@ -166,6 +223,15 @@ class DetailViewModel : ViewModel(), MusicStore.Callback {
override fun onLibraryChanged(library: MusicStore.Library?) { override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) { 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 val album = currentAlbum.value
if (album != null) { if (album != null) {
val newAlbum = library.sanitize(album).also { _currentAlbum.value = it } 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) val buffer = replaceOutputBuffer(size)
if (volume == 1f) { 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) // a for loop (the latter is not efficient)
buffer.put(inputBuffer.slice()) buffer.put(inputBuffer.slice())
} else { } else {

View file

@ -206,6 +206,7 @@ class PlaybackService :
} }
override fun onPlayerError(error: PlaybackException) { 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. // If there's any issue, just go to the next song.
playbackManager.next() playbackManager.next()
} }

View file

@ -26,6 +26,7 @@ import androidx.core.view.children
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.SongDetailDialog
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre 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 * TODO: Add multi-select
*/ */
class ActionMenu( class ActionMenu(
activity: AppCompatActivity, private val activity: AppCompatActivity,
anchor: View, anchor: View,
private val data: Item, private val data: Item,
private val flag: Int private val flag: Int
@ -180,6 +181,12 @@ class ActionMenu(
navModel.exploreNavigateTo(data.artist) 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) 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. * Convenience method for getting a [Drawable] safely.
* @param drawable The drawable resource * @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 <item
android:id="@+id/action_go_album" android:id="@+id/action_go_album"
android:title="@string/lbl_go_album" /> android:title="@string/lbl_go_album" />
<item
android:id="@+id/action_song_detail"
android:title="@string/lbl_song_detail" />
</menu> </menu>

View file

@ -47,6 +47,15 @@
<string name="lbl_go_artist">Go to artist</string> <string name="lbl_go_artist">Go to artist</string>
<string name="lbl_go_album">Go to album</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> <string name="lbl_state_saved">State saved</string>
@ -160,6 +169,8 @@
<string name="def_date">No Date</string> <string name="def_date">No Date</string>
<string name="def_track">No Track Number</string> <string name="def_track">No Track Number</string>
<string name="def_playback">No music playing</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_song">Song Name</string>
<string name="def_widget_artist">Artist 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_pos">+%.1f dB</string>
<string name="fmt_db_neg">-%.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> <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="materialAlertDialogTheme">@style/Theme.Auxio.Dialog</item>
<item name="sliderStyle">@style/Widget.Auxio.Slider</item> <item name="sliderStyle">@style/Widget.Auxio.Slider</item>
<item name="linearProgressIndicatorStyle">@style/Widget.Auxio.LinearProgressIndicator</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="textAppearanceDisplayLarge">@style/TextAppearance.Auxio.DisplayLarge</item>
<item name="textAppearanceDisplayMedium">@style/TextAppearance.Auxio.DisplayMedium</item> <item name="textAppearanceDisplayMedium">@style/TextAppearance.Auxio.DisplayMedium</item>

View file

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