detail: split off song property extraction
Split off the song property extraction code into a music package class. This is part of an initiative to remove non-parameter uses of contexts in ViewModels.
This commit is contained in:
parent
6c604a9aa5
commit
a6f2d82107
12 changed files with 213 additions and 189 deletions
|
@ -31,8 +31,8 @@ object IntegerTable {
|
|||
const val VIEW_TYPE_ARTIST = 0xA002
|
||||
/** GenreViewHolder */
|
||||
const val VIEW_TYPE_GENRE = 0xA003
|
||||
/** HeaderViewHolder */
|
||||
const val VIEW_TYPE_HEADER = 0xA004
|
||||
/** BasicHeaderViewHolder */
|
||||
const val VIEW_TYPE_BASIC_HEADER = 0xA004
|
||||
/** SortHeaderViewHolder */
|
||||
const val VIEW_TYPE_SORT_HEADER = 0xA005
|
||||
/** AlbumDetailViewHolder */
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
* 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 androidx.annotation.StringRes
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.storage.MimeType
|
||||
|
||||
/**
|
||||
* A header variation that displays a button to open a sort menu.
|
||||
* @param titleRes The string resource to use as the header title
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class SortHeader(@StringRes val titleRes: Int) : Item
|
||||
|
||||
/**
|
||||
* The properties of a [Song]'s file.
|
||||
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
|
||||
* @param sampleRateHz The sample rate, in hertz.
|
||||
* @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class SongProperties(
|
||||
val bitrateKbps: Int?,
|
||||
val sampleRateHz: Int?,
|
||||
val resolvedMimeType: MimeType
|
||||
)
|
|
@ -18,8 +18,6 @@
|
|||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.app.Application
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
@ -30,13 +28,14 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.detail.recycler.SortHeader
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.extractor.AudioInfo
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.music.library.Sort
|
||||
import org.oxycblt.auxio.music.storage.MimeType
|
||||
import org.oxycblt.auxio.music.tags.Disc
|
||||
import org.oxycblt.auxio.music.tags.ReleaseType
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
|
@ -54,6 +53,7 @@ class DetailViewModel(application: Application) :
|
|||
private val musicStore = MusicStore.getInstance()
|
||||
private val musicSettings = MusicSettings.from(application)
|
||||
private val playbackSettings = PlaybackSettings.from(application)
|
||||
private val audioInfoProvider = AudioInfo.Provider.from(application)
|
||||
|
||||
private var currentSongJob: Job? = null
|
||||
|
||||
|
@ -64,9 +64,9 @@ class DetailViewModel(application: Application) :
|
|||
val currentSong: StateFlow<Song?>
|
||||
get() = _currentSong
|
||||
|
||||
private val _songProperties = MutableStateFlow<SongProperties?>(null)
|
||||
/** The [SongProperties] of the currently shown [Song]. Null if not loaded yet. */
|
||||
val songProperties: StateFlow<SongProperties?> = _songProperties
|
||||
private val _songAudioInfo = MutableStateFlow<AudioInfo?>(null)
|
||||
/** The [AudioInfo] of the currently shown [Song]. Null if not loaded yet. */
|
||||
val songAudioInfo: StateFlow<AudioInfo?> = _songAudioInfo
|
||||
|
||||
// --- ALBUM ---
|
||||
|
||||
|
@ -156,7 +156,7 @@ class DetailViewModel(application: Application) :
|
|||
|
||||
val song = currentSong.value
|
||||
if (song != null) {
|
||||
_currentSong.value = library.sanitize(song)?.also(::loadProperties)
|
||||
_currentSong.value = library.sanitize(song)?.also(::refreshAudioInfo)
|
||||
logD("Updated song to ${currentSong.value}")
|
||||
}
|
||||
|
||||
|
@ -181,7 +181,7 @@ class DetailViewModel(application: Application) :
|
|||
|
||||
/**
|
||||
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and
|
||||
* [songProperties] will be updated to align with the new [Song].
|
||||
* [songAudioInfo] will be updated to align with the new [Song].
|
||||
* @param uid The UID of the [Song] to load. Must be valid.
|
||||
*/
|
||||
fun setSongUid(uid: Music.UID) {
|
||||
|
@ -190,7 +190,7 @@ class DetailViewModel(application: Application) :
|
|||
return
|
||||
}
|
||||
logD("Opening Song [uid: $uid]")
|
||||
_currentSong.value = requireMusic<Song>(uid)?.also(::loadProperties)
|
||||
_currentSong.value = requireMusic<Song>(uid)?.also(::refreshAudioInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -238,83 +238,21 @@ class DetailViewModel(application: Application) :
|
|||
private fun <T : Music> requireMusic(uid: Music.UID) = musicStore.library?.find<T>(uid)
|
||||
|
||||
/**
|
||||
* Start a new job to load a given [Song]'s [SongProperties]. Result is pushed to
|
||||
* [songProperties].
|
||||
* Start a new job to load a given [Song]'s [AudioInfo]. Result is pushed to [songAudioInfo].
|
||||
* @param song The song to load.
|
||||
*/
|
||||
private fun loadProperties(song: Song) {
|
||||
private fun refreshAudioInfo(song: Song) {
|
||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||
currentSongJob?.cancel()
|
||||
_songProperties.value = null
|
||||
_songAudioInfo.value = null
|
||||
currentSongJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val properties = this@DetailViewModel.loadPropertiesImpl(song)
|
||||
val info = audioInfoProvider.extract(song)
|
||||
yield()
|
||||
_songProperties.value = properties
|
||||
_songAudioInfo.value = info
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPropertiesImpl(song: Song): SongProperties {
|
||||
// While we would use ExoPlayer to extract this information, it doesn't support
|
||||
// common data like bit rate in progressive data sources due to there being no
|
||||
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
|
||||
val extractor = MediaExtractor()
|
||||
|
||||
try {
|
||||
extractor.setDataSource(context, song.uri, emptyMap())
|
||||
} catch (e: Exception) {
|
||||
// Can feasibly fail with invalid file formats. Note that this isn't considered
|
||||
// an error condition in the UI, as there is still plenty of other song information
|
||||
// that we can show.
|
||||
logW("Unable to extract song attributes.")
|
||||
logW(e.stackTraceToString())
|
||||
return SongProperties(null, null, song.mimeType)
|
||||
}
|
||||
|
||||
// Get the first track from the extractor (This is basically always the only
|
||||
// track we need to analyze).
|
||||
val format = extractor.getTrackFormat(0)
|
||||
|
||||
// Accessing fields can throw an exception if the fields are not present, and
|
||||
// the new method for using default values is not available on lower API levels.
|
||||
// So, we are forced to handle the exception and map it to a saner null value.
|
||||
val bitrate =
|
||||
try {
|
||||
// Convert bytes-per-second to kilobytes-per-second.
|
||||
format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000
|
||||
} catch (e: NullPointerException) {
|
||||
logD("Unable to extract bit rate field")
|
||||
null
|
||||
}
|
||||
|
||||
val sampleRate =
|
||||
try {
|
||||
format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
} catch (e: NullPointerException) {
|
||||
logE("Unable to extract sample rate field")
|
||||
null
|
||||
}
|
||||
|
||||
val resolvedMimeType =
|
||||
if (song.mimeType.fromFormat != null) {
|
||||
// ExoPlayer was already able to populate the format.
|
||||
song.mimeType
|
||||
} else {
|
||||
// ExoPlayer couldn't populate the format somehow, populate it here.
|
||||
val formatMimeType =
|
||||
try {
|
||||
format.getString(MediaFormat.KEY_MIME)
|
||||
} catch (e: NullPointerException) {
|
||||
logE("Unable to extract mime type field")
|
||||
null
|
||||
}
|
||||
|
||||
MimeType(song.mimeType.fromExtension, formatMimeType)
|
||||
}
|
||||
|
||||
return SongProperties(bitrate, sampleRate, resolvedMimeType)
|
||||
}
|
||||
|
||||
private fun refreshAlbumList(album: Album) {
|
||||
logD("Refreshing album data")
|
||||
val data = mutableListOf<Item>(album)
|
||||
|
@ -368,7 +306,7 @@ class DetailViewModel(application: Application) :
|
|||
logD("Release groups for this artist: ${byReleaseGroup.keys}")
|
||||
|
||||
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
|
||||
data.add(Header(entry.key.headerTitleRes))
|
||||
data.add(BasicHeader(entry.key.headerTitleRes))
|
||||
data.addAll(entry.value)
|
||||
}
|
||||
|
||||
|
@ -386,7 +324,7 @@ class DetailViewModel(application: Application) :
|
|||
logD("Refreshing genre data")
|
||||
val data = mutableListOf<Item>(genre)
|
||||
// Genre is guaranteed to always have artists and songs.
|
||||
data.add(Header(R.string.lbl_artists))
|
||||
data.add(BasicHeader(R.string.lbl_artists))
|
||||
data.addAll(genre.artists)
|
||||
data.add(SortHeader(R.string.lbl_songs))
|
||||
data.addAll(genreSongSort.songs(genre.songs))
|
||||
|
|
|
@ -27,6 +27,7 @@ import androidx.navigation.fragment.navArgs
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.extractor.AudioInfo
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
|
@ -54,10 +55,10 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
super.onBindingCreated(binding, savedInstanceState)
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setSongUid(args.itemUid)
|
||||
collectImmediately(detailModel.currentSong, detailModel.songProperties, ::updateSong)
|
||||
collectImmediately(detailModel.currentSong, detailModel.songAudioInfo, ::updateSong)
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?, properties: SongProperties?) {
|
||||
private fun updateSong(song: Song?, info: AudioInfo?) {
|
||||
if (song == null) {
|
||||
// Song we were showing no longer exists.
|
||||
findNavController().navigateUp()
|
||||
|
@ -65,28 +66,27 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
}
|
||||
|
||||
val binding = requireBinding()
|
||||
if (properties != null) {
|
||||
// Finished loading Song properties, populate and show the list of Song information.
|
||||
if (info != null) {
|
||||
// Finished loading song audio info, populate and show the list of Song information.
|
||||
binding.detailLoading.isInvisible = true
|
||||
binding.detailContainer.isInvisible = false
|
||||
|
||||
val context = requireContext()
|
||||
binding.detailFileName.setText(song.path.name)
|
||||
binding.detailRelativeDir.setText(song.path.parent.resolveName(context))
|
||||
binding.detailFormat.setText(properties.resolvedMimeType.resolveName(context))
|
||||
binding.detailFormat.setText(info.resolvedMimeType.resolveName(context))
|
||||
binding.detailSize.setText(Formatter.formatFileSize(context, song.size))
|
||||
binding.detailDuration.setText(song.durationMs.formatDurationMs(true))
|
||||
|
||||
if (properties.bitrateKbps != null) {
|
||||
binding.detailBitrate.setText(
|
||||
getString(R.string.fmt_bitrate, properties.bitrateKbps))
|
||||
if (info.bitrateKbps != null) {
|
||||
binding.detailBitrate.setText(getString(R.string.fmt_bitrate, info.bitrateKbps))
|
||||
} else {
|
||||
binding.detailBitrate.setText(R.string.def_bitrate)
|
||||
}
|
||||
|
||||
if (properties.sampleRateHz != null) {
|
||||
if (info.sampleRateHz != null) {
|
||||
binding.detailSampleRate.setText(
|
||||
getString(R.string.fmt_sample_rate, properties.sampleRateHz))
|
||||
getString(R.string.fmt_sample_rate, info.sampleRateHz))
|
||||
} else {
|
||||
binding.detailSampleRate.setText(R.string.def_sample_rate)
|
||||
}
|
||||
|
|
|
@ -19,12 +19,13 @@ package org.oxycblt.auxio.detail.recycler
|
|||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
|
||||
import org.oxycblt.auxio.detail.SortHeader
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
|
@ -52,21 +53,21 @@ abstract class DetailAdapter(
|
|||
override fun getItemViewType(position: Int) =
|
||||
when (getItem(position)) {
|
||||
// Implement support for headers and sort headers
|
||||
is Header -> HeaderViewHolder.VIEW_TYPE
|
||||
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
|
||||
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
||||
else -> super.getItemViewType(position)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
when (viewType) {
|
||||
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
|
||||
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
|
||||
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
|
||||
else -> error("Invalid item type $viewType")
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = getItem(position)) {
|
||||
is Header -> (holder as HeaderViewHolder).bind(item)
|
||||
is BasicHeader -> (holder as BasicHeaderViewHolder).bind(item)
|
||||
is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
|
||||
}
|
||||
}
|
||||
|
@ -74,7 +75,7 @@ abstract class DetailAdapter(
|
|||
override fun isItemFullWidth(position: Int): Boolean {
|
||||
// Headers should be full-width in all configurations.
|
||||
val item = getItem(position)
|
||||
return item is Header || item is SortHeader
|
||||
return item is BasicHeader || item is SortHeader
|
||||
}
|
||||
|
||||
/** An extended [SelectableListListener] for [DetailAdapter] implementations. */
|
||||
|
@ -105,8 +106,8 @@ abstract class DetailAdapter(
|
|||
object : SimpleDiffCallback<Item>() {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
return when {
|
||||
oldItem is Header && newItem is Header ->
|
||||
HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is BasicHeader && newItem is BasicHeader ->
|
||||
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is SortHeader && newItem is SortHeader ->
|
||||
SortHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
else -> false
|
||||
|
@ -117,8 +118,15 @@ abstract class DetailAdapter(
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a
|
||||
* button opening a menu for sorting. Use [from] to create an instance.
|
||||
* A header variation that displays a button to open a sort menu.
|
||||
* @param titleRes The string resource to use as the header title
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class SortHeader(@StringRes override val titleRes: Int) : Header
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [BasicHeader] that adds
|
||||
* a button opening a menu for sorting. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
|
||||
|
|
|
@ -24,6 +24,16 @@ interface Item
|
|||
|
||||
/**
|
||||
* A "header" used for delimiting groups of data.
|
||||
* @param titleRes The string resource used for the header's title.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class Header(@StringRes val titleRes: Int) : Item
|
||||
interface Header : Item {
|
||||
/** The string resource used for the header's title. */
|
||||
val titleRes: Int
|
||||
}
|
||||
|
||||
/**
|
||||
* A basic header with no additional actions.
|
||||
* @param titleRes The string resource used for the header's title.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class BasicHeader(@StringRes override val titleRes: Int) : Header
|
||||
|
|
|
@ -24,7 +24,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.ItemHeaderBinding
|
||||
import org.oxycblt.auxio.databinding.ItemParentBinding
|
||||
import org.oxycblt.auxio.databinding.ItemSongBinding
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
|
@ -241,23 +241,23 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Header]. Use [from] to create an instance.
|
||||
* A [RecyclerView.ViewHolder] that displays a [BasicHeader]. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
|
||||
class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param header The new [Header] to bind.
|
||||
* @param basicHeader The new [BasicHeader] to bind.
|
||||
*/
|
||||
fun bind(header: Header) {
|
||||
logD(binding.context.getString(header.titleRes))
|
||||
binding.title.text = binding.context.getString(header.titleRes)
|
||||
fun bind(basicHeader: BasicHeader) {
|
||||
logD(binding.context.getString(basicHeader.titleRes))
|
||||
binding.title.text = binding.context.getString(basicHeader.titleRes)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Unique ID for this ViewHolder type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_HEADER
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_BASIC_HEADER
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
|
@ -265,13 +265,15 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin
|
|||
* @return A new instance.
|
||||
*/
|
||||
fun from(parent: View) =
|
||||
HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
|
||||
BasicHeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleDiffCallback<Header>() {
|
||||
override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean =
|
||||
oldItem.titleRes == newItem.titleRes
|
||||
object : SimpleDiffCallback<BasicHeader>() {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: BasicHeader,
|
||||
newItem: BasicHeader
|
||||
): Boolean = oldItem.titleRes == newItem.titleRes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
123
app/src/main/java/org/oxycblt/auxio/music/extractor/AudioInfo.kt
Normal file
123
app/src/main/java/org/oxycblt/auxio/music/extractor/AudioInfo.kt
Normal file
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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.music.extractor
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.storage.MimeType
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* The properties of a [Song]'s file.
|
||||
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
|
||||
* @param sampleRateHz The sample rate, in hertz.
|
||||
* @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class AudioInfo(
|
||||
val bitrateKbps: Int?,
|
||||
val sampleRateHz: Int?,
|
||||
val resolvedMimeType: MimeType
|
||||
) {
|
||||
/** Implements the process of extracting [AudioInfo] from a given [Song]. */
|
||||
interface Provider {
|
||||
/**
|
||||
* Extract the [AudioInfo] of a given [Song].
|
||||
* @param song The [Song] to read.
|
||||
* @return The [AudioInfo] of the [Song], if possible to obtain.
|
||||
*/
|
||||
suspend fun extract(song: Song): AudioInfo
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Get a framework-backed implementation.
|
||||
* @param context [Context] required.
|
||||
*/
|
||||
fun from(context: Context): Provider = RealAudioInfoProvider(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class RealAudioInfoProvider(private val context: Context) : AudioInfo.Provider {
|
||||
// While we would use ExoPlayer to extract this information, it doesn't support
|
||||
// common data like bit rate in progressive data sources due to there being no
|
||||
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
|
||||
private val extractor = MediaExtractor()
|
||||
|
||||
override suspend fun extract(song: Song): AudioInfo {
|
||||
try {
|
||||
extractor.setDataSource(context, song.uri, emptyMap())
|
||||
} catch (e: Exception) {
|
||||
// Can feasibly fail with invalid file formats. Note that this isn't considered
|
||||
// an error condition in the UI, as there is still plenty of other song information
|
||||
// that we can show.
|
||||
logW("Unable to extract song attributes.")
|
||||
logW(e.stackTraceToString())
|
||||
return AudioInfo(null, null, song.mimeType)
|
||||
}
|
||||
|
||||
// Get the first track from the extractor (This is basically always the only
|
||||
// track we need to analyze).
|
||||
val format = extractor.getTrackFormat(0)
|
||||
|
||||
// Accessing fields can throw an exception if the fields are not present, and
|
||||
// the new method for using default values is not available on lower API levels.
|
||||
// So, we are forced to handle the exception and map it to a saner null value.
|
||||
val bitrate =
|
||||
try {
|
||||
// Convert bytes-per-second to kilobytes-per-second.
|
||||
format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000
|
||||
} catch (e: NullPointerException) {
|
||||
logD("Unable to extract bit rate field")
|
||||
null
|
||||
}
|
||||
|
||||
val sampleRate =
|
||||
try {
|
||||
format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
} catch (e: NullPointerException) {
|
||||
logE("Unable to extract sample rate field")
|
||||
null
|
||||
}
|
||||
|
||||
val resolvedMimeType =
|
||||
if (song.mimeType.fromFormat != null) {
|
||||
// ExoPlayer was already able to populate the format.
|
||||
song.mimeType
|
||||
} else {
|
||||
// ExoPlayer couldn't populate the format somehow, populate it here.
|
||||
val formatMimeType =
|
||||
try {
|
||||
format.getString(MediaFormat.KEY_MIME)
|
||||
} catch (e: NullPointerException) {
|
||||
logE("Unable to extract mime type field")
|
||||
null
|
||||
}
|
||||
|
||||
MimeType(song.mimeType.fromExtension, formatMimeType)
|
||||
}
|
||||
|
||||
extractor.release()
|
||||
|
||||
return AudioInfo(bitrate, sampleRate, resolvedMimeType)
|
||||
}
|
||||
}
|
|
@ -44,7 +44,7 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
|
|||
is Album -> AlbumViewHolder.VIEW_TYPE
|
||||
is Artist -> ArtistViewHolder.VIEW_TYPE
|
||||
is Genre -> GenreViewHolder.VIEW_TYPE
|
||||
is Header -> HeaderViewHolder.VIEW_TYPE
|
||||
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
|
||||
else -> super.getItemViewType(position)
|
||||
}
|
||||
|
||||
|
@ -54,7 +54,7 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
|
|||
AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.from(parent)
|
||||
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
|
||||
GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent)
|
||||
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
|
||||
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
|
||||
else -> error("Invalid item type $viewType")
|
||||
}
|
||||
|
||||
|
@ -65,11 +65,11 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
|
|||
is Album -> (holder as AlbumViewHolder).bind(item, listener)
|
||||
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
|
||||
is Genre -> (holder as GenreViewHolder).bind(item, listener)
|
||||
is Header -> (holder as HeaderViewHolder).bind(item)
|
||||
is BasicHeader -> (holder as BasicHeaderViewHolder).bind(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isItemFullWidth(position: Int) = getItem(position) is Header
|
||||
override fun isItemFullWidth(position: Int) = getItem(position) is BasicHeader
|
||||
|
||||
/**
|
||||
* Make sure that the top header has a correctly configured divider visibility. This would
|
||||
|
@ -94,8 +94,8 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
|
|||
ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is Genre && newItem is Genre ->
|
||||
GenreViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is Header && newItem is Header ->
|
||||
HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is BasicHeader && newItem is BasicHeader ->
|
||||
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
|
@ -119,19 +119,19 @@ class SearchViewModel(application: Application) :
|
|||
|
||||
return buildList {
|
||||
results.artists?.let { artists ->
|
||||
add(Header(R.string.lbl_artists))
|
||||
add(BasicHeader(R.string.lbl_artists))
|
||||
addAll(SORT.artists(artists))
|
||||
}
|
||||
results.albums?.let { albums ->
|
||||
add(Header(R.string.lbl_albums))
|
||||
add(BasicHeader(R.string.lbl_albums))
|
||||
addAll(SORT.albums(albums))
|
||||
}
|
||||
results.genres?.let { genres ->
|
||||
add(Header(R.string.lbl_genres))
|
||||
add(BasicHeader(R.string.lbl_genres))
|
||||
addAll(SORT.genres(genres))
|
||||
}
|
||||
results.songs?.let { songs ->
|
||||
add(Header(R.string.lbl_songs))
|
||||
add(BasicHeader(R.string.lbl_songs))
|
||||
addAll(SORT.songs(songs))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@android:id/title"
|
||||
style="@style/Widget.Auxio.TextView.Header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Songs" />
|
||||
|
||||
</LinearLayout>
|
||||
tools:text="Songs" />
|
|
@ -1,38 +1,31 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<LinearLayout 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:orientation="horizontal"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:id="@+id/header_divider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/header_title"
|
||||
style="@style/Widget.Auxio.TextView.Header"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="@dimen/spacing_mid_medium"
|
||||
app:layout_constraintEnd_toStartOf="@+id/header_button"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/header_divider"
|
||||
tools:text="Songs" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/header_button"
|
||||
style="@style/Widget.Auxio.Button.Icon.Small"
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="@dimen/spacing_mid_medium"
|
||||
android:contentDescription="@string/lbl_sort"
|
||||
app:icon="@drawable/ic_sort_24"
|
||||
app:layout_constraintTop_toBottomOf="@id/header_divider"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</LinearLayout>
|
Loading…
Reference in a new issue