From 3f85678d9955aef7fd97f5e9a610df463c2ff81a Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sat, 11 Jun 2022 13:29:06 -0600 Subject: [PATCH] detail: add framework for song details Add the basic framework for a song detail view. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 66 +++++++++ .../oxycblt/auxio/detail/ReadOnlyTextInput.kt | 57 ++++++++ .../oxycblt/auxio/detail/SongDetailDialog.kt | 83 +++++++++++ .../replaygain/ReplayGainAudioProcessor.kt | 2 +- .../auxio/playback/system/PlaybackService.kt | 1 + .../java/org/oxycblt/auxio/ui/ActionMenu.kt | 9 +- .../org/oxycblt/auxio/util/ContextUtil.kt | 22 +++ .../main/res/layout/dialog_song_detail.xml | 131 ++++++++++++++++++ app/src/main/res/menu/menu_song_actions.xml | 3 + app/src/main/res/values/strings.xml | 13 ++ app/src/main/res/values/styles_core.xml | 1 + app/src/main/res/xml/prefs_main.xml | 1 + 12 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt create mode 100644 app/src/main/res/layout/dialog_song_detail.xml diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index 6e05ffa5e..d78b981a7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -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(null) + val currentSong: StateFlow + get() = _currentSong + private val _currentAlbum = MutableStateFlow(null) val currentAlbum: StateFlow 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(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 } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt new file mode 100644 index 000000000..e9b87567c --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt @@ -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 . + */ + +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 +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt new file mode 100644 index 000000000..d5b468bbb --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -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 . + */ + +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() { + 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" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 4628436c0..5e48f7fb8 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -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 { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 6faf73ec6..5391e06b4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -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() } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt b/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt index 0865bdaa1..91bdceea8 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ActionMenu.kt @@ -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) + } + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt index 950ec46ee..5bc0db2b7 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt @@ -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 diff --git a/app/src/main/res/layout/dialog_song_detail.xml b/app/src/main/res/layout/dialog_song_detail.xml new file mode 100644 index 000000000..c9f0c3ce1 --- /dev/null +++ b/app/src/main/res/layout/dialog_song_detail.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_song_actions.xml b/app/src/main/res/menu/menu_song_actions.xml index 1a795ddb5..28c508681 100644 --- a/app/src/main/res/menu/menu_song_actions.xml +++ b/app/src/main/res/menu/menu_song_actions.xml @@ -12,4 +12,7 @@ + \ 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 6590d6924..c18a68c13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,15 @@ Go to artist Go to album + View properties + + File properties + File name + Relative path + Format + Size + Bitrate + Sample rate State saved @@ -160,6 +169,8 @@ No Date No Track Number No music playing + No Bitrate + No Sample Rate Song Name Artist Name @@ -187,6 +198,8 @@ +%.1f dB -%.1f dB + %d KB/s + %d Hz Loading your music library… (%1$d/%2$d) diff --git a/app/src/main/res/values/styles_core.xml b/app/src/main/res/values/styles_core.xml index 29daeac92..a54e3b5aa 100644 --- a/app/src/main/res/values/styles_core.xml +++ b/app/src/main/res/values/styles_core.xml @@ -33,6 +33,7 @@ @style/Theme.Auxio.Dialog @style/Widget.Auxio.Slider @style/Widget.Auxio.LinearProgressIndicator + @style/Widget.Material3.TextInputLayout.OutlinedBox @style/TextAppearance.Auxio.DisplayLarge @style/TextAppearance.Auxio.DisplayMedium diff --git a/app/src/main/res/xml/prefs_main.xml b/app/src/main/res/xml/prefs_main.xml index 2458059f7..4f10ecd1c 100644 --- a/app/src/main/res/xml/prefs_main.xml +++ b/app/src/main/res/xml/prefs_main.xml @@ -54,6 +54,7 @@ app:summary="@string/set_quality_covers_desc" app:title="@string/set_quality_covers" /> +