commit
3e33510139
117 changed files with 1639 additions and 1491 deletions
2
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
@ -72,6 +72,8 @@ body:
|
|||
- `./adb -d logcat AndroidRuntime:E *:S` in the case of a crash
|
||||
5. Copy and paste the output to this area of the issue.
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
|
38
.github/workflows/android.yml
vendored
Normal file
38
.github/workflows/android.yml
vendored
Normal file
|
@ -0,0 +1,38 @@
|
|||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "dev" ]
|
||||
pull_request:
|
||||
branches: [ "dev" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Set up NDK r21e
|
||||
uses: nttld/setup-ndk@v1.2.0
|
||||
id: setup-ndk
|
||||
with:
|
||||
ndk-version: r21e
|
||||
add-to-path: false
|
||||
- run: python3 prebuild.py
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Build debug APK with Gradle
|
||||
run: ./gradlew app:packageDebug
|
||||
- name: Upload debug APK artifact
|
||||
uses: actions/upload-artifact@v3.1.1
|
||||
with:
|
||||
name: Auxio_Canary
|
||||
path: ./app/build/outputs/apk/debug/app-debug.apk
|
25
CHANGELOG.md
25
CHANGELOG.md
|
@ -1,6 +1,26 @@
|
|||
# Changelog
|
||||
|
||||
## dev
|
||||
## 3.0.1
|
||||
|
||||
#### What's New
|
||||
- Added support for album date ranges (ex. 2010 - 2013)
|
||||
|
||||
#### What's Improved
|
||||
- Formalized whitespace handling
|
||||
- Value lists are now properly localized
|
||||
- Queue no longer primarily shows previous songs when opened
|
||||
- Added reset button to ReplayGain pre-amp configuration dialog
|
||||
|
||||
#### What's Changed
|
||||
- R128 ReplayGain tags are now only used when playing OPUS files
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed mangled multi-value ID3v2 tags when UTF-16 is used
|
||||
- Fixed crash when playing certain MP3 files
|
||||
- Detail UI will no longer crash if the music library is unavailable
|
||||
|
||||
#### Dev/Meta
|
||||
- Add CI workflow
|
||||
|
||||
## 3.0.0
|
||||
|
||||
|
@ -12,7 +32,7 @@
|
|||
- Added setting to hide "collaborator" artists
|
||||
- Upgraded music ID management:
|
||||
- Added support for MusicBrainz IDs (MBIDs)
|
||||
- Use the more unique MD5 hash of metadata when MBIDs can't be used
|
||||
- Use a more unique hash of metadata when MBIDs can't be used
|
||||
- Genres now display a list of artists
|
||||
- Added toggle to load non-music (Such as podcasts)
|
||||
- Music loader now caches parsed metadata for faster load times
|
||||
|
@ -42,7 +62,6 @@ audio focus was lost
|
|||
|
||||
#### What's Changed
|
||||
- Ignore MediaStore tags is now Auxio's default and unchangeable behavior. The option has been removed.
|
||||
- Removed the "Play from genre" option in the library/detail playback mode settings+
|
||||
- "Use alternate notification action" is now "Custom notification action"
|
||||
- "Show covers" and "Ignore MediaStore covers" have been unified into "Album covers"
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
<h1 align="center"><b>Auxio</b></h1>
|
||||
<h4 align="center">A simple, rational music player for android.</h4>
|
||||
<p align="center">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.0">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.0&color=0D5AF5">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.1">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.1&color=0D5AF5">
|
||||
</a>
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
|
||||
|
@ -79,7 +79,9 @@ Auxio relies on a custom version of ExoPlayer that enables some extra features.
|
|||
|
||||
Auxio accepts most contributions as long as they follow the [Contribution Guidelines](/.github/CONTRIBUTING.md).
|
||||
|
||||
However, feature additions and major UI changes are less likely to be accepted. See [Accepted Additions](/info/ADDITIONS.md) for more information.
|
||||
However, feature additions and major UI changes are less likely to be accepted. See
|
||||
[Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F)
|
||||
for more information.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
@ -12,8 +12,8 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId namespace
|
||||
versionName "3.0.0"
|
||||
versionCode 24
|
||||
versionName "3.0.1"
|
||||
versionCode 25
|
||||
|
||||
minSdk 21
|
||||
targetSdk 33
|
||||
|
@ -121,3 +121,7 @@ spotless {
|
|||
licenseHeaderFile("NOTICE")
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
preDebugBuild.dependsOn spotlessApply
|
||||
}
|
||||
|
|
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
|
@ -21,5 +21,5 @@
|
|||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# Obsfucation is what proprietary software does to keep the user unaware of it's abuses.
|
||||
# Also it's easier to debug if the class names remain unmangled.
|
||||
# Also it's easier to fix issues if the stack trace symbols remain unmangled.
|
||||
-dontobfuscate
|
|
@ -116,7 +116,7 @@
|
|||
<!-- </intent-filter>-->
|
||||
<!-- </receiver>-->
|
||||
|
||||
<!-- "Now Playing" widget.. -->
|
||||
<!-- "Now Playing" widget. -->
|
||||
<receiver
|
||||
android:name=".widgets.WidgetProvider"
|
||||
android:exported="false"
|
||||
|
|
|
@ -42,20 +42,12 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|||
*
|
||||
* TODO: Custom language support
|
||||
*
|
||||
* TODO: Add multi-select
|
||||
*
|
||||
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
|
||||
*
|
||||
* TODO: Migrate to material animation system
|
||||
*
|
||||
* TODO: Unit testing
|
||||
*
|
||||
* TODO: Standardize from/new usage
|
||||
*
|
||||
* TODO: Standardize companion object usage
|
||||
*
|
||||
* TODO: Standardize callback/listener naming.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
@ -146,7 +138,7 @@ class MainActivity : AppCompatActivity() {
|
|||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED"
|
||||
private companion object {
|
||||
const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,6 @@ import kotlin.math.max
|
|||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
|
||||
|
@ -62,10 +61,8 @@ class MainFragment :
|
|||
private val selectionModel: SelectionViewModel by activityViewModels()
|
||||
private val callback = DynamicBackPressedCallback()
|
||||
private var lastInsets: WindowInsets? = null
|
||||
private var elevationNormal = 0f
|
||||
private var initialNavDestinationChange = true
|
||||
private val elevationNormal: Float by lifecycleObject { binding ->
|
||||
binding.context.getDimen(R.dimen.elevation_normal)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -78,6 +75,8 @@ class MainFragment :
|
|||
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
|
||||
|
||||
// --- UI SETUP ---
|
||||
val context = requireActivity()
|
||||
// Override the back pressed listener so we can map back navigation to collapsing
|
||||
|
@ -217,7 +216,7 @@ class MainFragment :
|
|||
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
|
||||
}
|
||||
|
||||
// Prevent interactions when the playback panell fully fades out.
|
||||
// Prevent interactions when the playback panel fully fades out.
|
||||
binding.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f
|
||||
|
||||
binding.queueSheet.apply {
|
||||
|
|
|
@ -20,7 +20,7 @@ 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
|
||||
import org.oxycblt.auxio.music.filesystem.MimeType
|
||||
|
||||
/**
|
||||
* A header variation that displays a button to open a sort menu.
|
||||
|
@ -35,21 +35,13 @@ data class SortHeader(@StringRes val titleRes: Int) : Item
|
|||
data class DiscHeader(val disc: Int) : Item
|
||||
|
||||
/**
|
||||
* A [Song] extension that adds information about it's file properties.
|
||||
* @param song The internal song
|
||||
* @param properties The properties of the song file. Null if parsing is ongoing.
|
||||
* 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.
|
||||
*/
|
||||
data class DetailSong(val song: Song, val properties: Properties?) {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
data class Properties(
|
||||
val bitrateKbps: Int?,
|
||||
val sampleRateHz: Int?,
|
||||
val resolvedMimeType: MimeType
|
||||
)
|
||||
}
|
||||
data class SongProperties(
|
||||
val bitrateKbps: Int?,
|
||||
val sampleRateHz: Int?,
|
||||
val resolvedMimeType: MimeType
|
||||
)
|
||||
|
|
|
@ -31,13 +31,13 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.google.android.material.appbar.AppBarLayout
|
||||
import java.lang.reflect.Field
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.AuxioAppBarLayout
|
||||
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
|
||||
/**
|
||||
* An [AuxioAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
|
||||
* beyond it's first item.
|
||||
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
|
||||
* view goes beyond it's first item.
|
||||
*
|
||||
* This is intended for the detail views, in which the first item is the album/artist/genre header,
|
||||
* and thus scrolling past them should make the toolbar show the name in order to give context on
|
||||
|
@ -51,7 +51,7 @@ import org.oxycblt.auxio.util.lazyReflectedField
|
|||
class DetailAppBarLayout
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AuxioAppBarLayout(context, attrs, defStyleAttr) {
|
||||
CoordinatorAppBarLayout(context, attrs, defStyleAttr) {
|
||||
private var titleView: TextView? = null
|
||||
private var recycler: RecyclerView? = null
|
||||
|
||||
|
@ -166,8 +166,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TOOLBAR_TITLE_TEXT_FIELD: Field by
|
||||
lazyReflectedField(Toolbar::class, "mTitleTextView")
|
||||
private companion object {
|
||||
val TOOLBAR_TITLE_TEXT_FIELD: Field by lazyReflectedField(Toolbar::class, "mTitleTextView")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Music
|
|||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.music.storage.MimeType
|
||||
import org.oxycblt.auxio.music.filesystem.MimeType
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
|
@ -51,7 +51,7 @@ import org.oxycblt.auxio.util.*
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class DetailViewModel(application: Application) :
|
||||
AndroidViewModel(application), MusicStore.Callback {
|
||||
AndroidViewModel(application), MusicStore.Listener {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
private val settings = Settings(application)
|
||||
|
||||
|
@ -59,15 +59,15 @@ class DetailViewModel(application: Application) :
|
|||
|
||||
// --- SONG ---
|
||||
|
||||
private val _currentSong = MutableStateFlow<DetailSong?>(null)
|
||||
/**
|
||||
* The current [DetailSong] to display. Null if there is nothing to show.
|
||||
*
|
||||
* TODO: De-couple Song and Properties?
|
||||
*/
|
||||
val currentSong: StateFlow<DetailSong?>
|
||||
private val _currentSong = MutableStateFlow<Song?>(null)
|
||||
/** The current [Song] to display. Null if there is nothing to show. */
|
||||
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
|
||||
|
||||
// --- ALBUM ---
|
||||
|
||||
private val _currentAlbum = MutableStateFlow<Album?>(null)
|
||||
|
@ -130,11 +130,11 @@ class DetailViewModel(application: Application) :
|
|||
}
|
||||
|
||||
init {
|
||||
musicStore.addCallback(this)
|
||||
musicStore.addListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
musicStore.removeCallback(this)
|
||||
musicStore.removeListener(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
|
@ -149,13 +149,8 @@ class DetailViewModel(application: Application) :
|
|||
|
||||
val song = currentSong.value
|
||||
if (song != null) {
|
||||
val newSong = library.sanitize(song.song)
|
||||
if (newSong != null) {
|
||||
loadDetailSong(newSong)
|
||||
} else {
|
||||
_currentSong.value = null
|
||||
}
|
||||
logD("Updated song to $newSong")
|
||||
_currentSong.value = library.sanitize(song)?.also(::loadProperties)
|
||||
logD("Updated song to ${currentSong.value}")
|
||||
}
|
||||
|
||||
val album = currentAlbum.value
|
||||
|
@ -178,17 +173,17 @@ class DetailViewModel(application: Application) :
|
|||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, a new loading
|
||||
* process will begin and the newly-loaded [DetailSong] will be set to [currentSong].
|
||||
* 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].
|
||||
* @param uid The UID of the [Song] to load. Must be valid.
|
||||
*/
|
||||
fun setSongUid(uid: Music.UID) {
|
||||
if (_currentSong.value?.run { song.uid } == uid) {
|
||||
if (_currentSong.value?.uid == uid) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Opening Song [uid: $uid]")
|
||||
loadDetailSong(requireMusic(uid))
|
||||
_currentSong.value = requireMusic<Song>(uid)?.also(::loadProperties)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -202,7 +197,7 @@ class DetailViewModel(application: Application) :
|
|||
return
|
||||
}
|
||||
logD("Opening Album [uid: $uid]")
|
||||
_currentAlbum.value = requireMusic<Album>(uid).also { refreshAlbumList(it) }
|
||||
_currentAlbum.value = requireMusic<Album>(uid)?.also(::refreshAlbumList)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -216,7 +211,7 @@ class DetailViewModel(application: Application) :
|
|||
return
|
||||
}
|
||||
logD("Opening Artist [uid: $uid]")
|
||||
_currentArtist.value = requireMusic<Artist>(uid).also { refreshArtistList(it) }
|
||||
_currentArtist.value = requireMusic<Artist>(uid)?.also(::refreshArtistList)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -230,29 +225,29 @@ class DetailViewModel(application: Application) :
|
|||
return
|
||||
}
|
||||
logD("Opening Genre [uid: $uid]")
|
||||
_currentGenre.value = requireMusic<Genre>(uid).also { refreshGenreList(it) }
|
||||
_currentGenre.value = requireMusic<Genre>(uid)?.also(::refreshGenreList)
|
||||
}
|
||||
|
||||
private fun <T : Music> requireMusic(uid: Music.UID): T =
|
||||
requireNotNull(unlikelyToBeNull(musicStore.library).find(uid)) { "Invalid id provided" }
|
||||
private fun <T : Music> requireMusic(uid: Music.UID) = musicStore.library?.find<T>(uid)
|
||||
|
||||
/**
|
||||
* Start a new job to load a [DetailSong] based on the properties of the given [Song]'s file.
|
||||
* Start a new job to load a given [Song]'s [SongProperties]. Result is pushed to
|
||||
* [songProperties].
|
||||
* @param song The song to load.
|
||||
*/
|
||||
private fun loadDetailSong(song: Song) {
|
||||
private fun loadProperties(song: Song) {
|
||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||
currentSongJob?.cancel()
|
||||
_currentSong.value = DetailSong(song, null)
|
||||
_songProperties.value = null
|
||||
currentSongJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val info = loadProperties(song)
|
||||
val properties = this@DetailViewModel.loadPropertiesImpl(song)
|
||||
yield()
|
||||
_currentSong.value = DetailSong(song, info)
|
||||
_songProperties.value = properties
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadProperties(song: Song): DetailSong.Properties {
|
||||
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.
|
||||
|
@ -266,7 +261,7 @@ class DetailViewModel(application: Application) :
|
|||
// that we can show.
|
||||
logW("Unable to extract song attributes.")
|
||||
logW(e.stackTraceToString())
|
||||
return DetailSong.Properties(null, null, song.mimeType)
|
||||
return SongProperties(null, null, song.mimeType)
|
||||
}
|
||||
|
||||
// Get the first track from the extractor (This is basically always the only
|
||||
|
@ -310,7 +305,7 @@ class DetailViewModel(application: Application) :
|
|||
MimeType(song.mimeType.fromExtension, formatMimeType)
|
||||
}
|
||||
|
||||
return DetailSong.Properties(bitrate, sampleRate, resolvedMimeType)
|
||||
return SongProperties(bitrate, sampleRate, resolvedMimeType)
|
||||
}
|
||||
|
||||
private fun refreshAlbumList(album: Album) {
|
||||
|
|
|
@ -26,6 +26,7 @@ import androidx.navigation.fragment.findNavController
|
|||
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.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
|
@ -53,10 +54,10 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
super.onBindingCreated(binding, savedInstanceState)
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setSongUid(args.itemUid)
|
||||
collectImmediately(detailModel.currentSong, ::updateSong)
|
||||
collectImmediately(detailModel.currentSong, detailModel.songProperties, ::updateSong)
|
||||
}
|
||||
|
||||
private fun updateSong(song: DetailSong?) {
|
||||
private fun updateSong(song: Song?, properties: SongProperties?) {
|
||||
if (song == null) {
|
||||
// Song we were showing no longer exists.
|
||||
findNavController().navigateUp()
|
||||
|
@ -64,28 +65,28 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
}
|
||||
|
||||
val binding = requireBinding()
|
||||
if (song.properties != null) {
|
||||
if (properties != null) {
|
||||
// Finished loading Song properties, populate and show the list of Song information.
|
||||
binding.detailLoading.isInvisible = true
|
||||
binding.detailContainer.isInvisible = false
|
||||
|
||||
val context = requireContext()
|
||||
binding.detailFileName.setText(song.song.path.name)
|
||||
binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context))
|
||||
binding.detailFormat.setText(song.properties.resolvedMimeType.resolveName(context))
|
||||
binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size))
|
||||
binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true))
|
||||
binding.detailFileName.setText(song.path.name)
|
||||
binding.detailRelativeDir.setText(song.path.parent.resolveName(context))
|
||||
binding.detailFormat.setText(properties.resolvedMimeType.resolveName(context))
|
||||
binding.detailSize.setText(Formatter.formatFileSize(context, song.size))
|
||||
binding.detailDuration.setText(song.durationMs.formatDurationMs(true))
|
||||
|
||||
if (song.properties.bitrateKbps != null) {
|
||||
if (properties.bitrateKbps != null) {
|
||||
binding.detailBitrate.setText(
|
||||
getString(R.string.fmt_bitrate, song.properties.bitrateKbps))
|
||||
getString(R.string.fmt_bitrate, properties.bitrateKbps))
|
||||
} else {
|
||||
binding.detailBitrate.setText(R.string.def_bitrate)
|
||||
}
|
||||
|
||||
if (song.properties.sampleRateHz != null) {
|
||||
if (properties.sampleRateHz != null) {
|
||||
binding.detailSampleRate.setText(
|
||||
getString(R.string.fmt_sample_rate, song.properties.sampleRateHz))
|
||||
getString(R.string.fmt_sample_rate, properties.sampleRateHz))
|
||||
} else {
|
||||
binding.detailSampleRate.setText(R.string.def_sample_rate)
|
||||
}
|
||||
|
|
|
@ -67,9 +67,9 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
when (viewType) {
|
||||
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.new(parent)
|
||||
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.new(parent)
|
||||
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.new(parent)
|
||||
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent)
|
||||
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
|
||||
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
|
||||
else -> super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
|
||||
|
@ -88,9 +88,9 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
|
|||
return super.isItemFullWidth(position) || item is Album || item is DiscHeader
|
||||
}
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
private val DIFF_CALLBACK =
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
return when {
|
||||
|
@ -110,7 +110,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [new] to
|
||||
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -142,7 +142,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
|
|||
// Date, song count, and duration map to the info text
|
||||
binding.detailInfo.apply {
|
||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||
val date = album.date?.resolveDate(context) ?: context.getString(R.string.def_date)
|
||||
val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
|
||||
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
|
||||
val duration = album.durationMs.formatDurationMs(true)
|
||||
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
||||
|
@ -161,7 +161,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
|
@ -170,7 +170,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
|
|||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
oldItem.areArtistContentsTheSame(newItem) &&
|
||||
oldItem.date == newItem.date &&
|
||||
oldItem.dates == newItem.dates &&
|
||||
oldItem.songs.size == newItem.songs.size &&
|
||||
oldItem.durationMs == newItem.durationMs &&
|
||||
oldItem.type == newItem.type
|
||||
|
@ -180,7 +180,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
|
|||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use
|
||||
* [new] to create an instance.
|
||||
* [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
||||
|
@ -202,7 +202,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
|
@ -215,7 +215,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [new] to
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [from] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -227,7 +227,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
|
|||
* @param listener A [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(song: Song, listener: SelectableListListener) {
|
||||
listener.bind(this, song, binding.songMenu)
|
||||
listener.bind(song, this, menuButton = binding.songMenu)
|
||||
|
||||
binding.songTrack.apply {
|
||||
if (song.track != null) {
|
||||
|
@ -269,7 +269,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
|
|
|
@ -54,9 +54,9 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
when (viewType) {
|
||||
ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.new(parent)
|
||||
ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.new(parent)
|
||||
ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.new(parent)
|
||||
ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.from(parent)
|
||||
ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.from(parent)
|
||||
ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.from(parent)
|
||||
else -> super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
|
||||
|
@ -76,9 +76,9 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
|
|||
return super.isItemFullWidth(position) || item is Artist
|
||||
}
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
private val DIFF_CALLBACK =
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
return when {
|
||||
|
@ -97,7 +97,7 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [new] to
|
||||
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -156,7 +156,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
|
@ -172,7 +172,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [new] to
|
||||
* A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [from] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -184,12 +184,13 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
|||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(album: Album, listener: SelectableListListener) {
|
||||
listener.bind(this, album, binding.parentMenu)
|
||||
listener.bind(album, this, menuButton = binding.parentMenu)
|
||||
binding.parentImage.bind(album)
|
||||
binding.parentName.text = album.resolveName(binding.context)
|
||||
binding.parentInfo.text =
|
||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||
album.date?.resolveDate(binding.context) ?: binding.context.getString(R.string.def_date)
|
||||
album.dates?.resolveDate(binding.context)
|
||||
?: binding.context.getString(R.string.def_date)
|
||||
}
|
||||
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
|
@ -210,20 +211,20 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Album>() {
|
||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||
oldItem.rawName == newItem.rawName && oldItem.date == newItem.date
|
||||
oldItem.rawName == newItem.rawName && oldItem.dates == newItem.dates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Artist]. Use [new] to
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Artist]. Use [from] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -235,7 +236,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
|
|||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(song: Song, listener: SelectableListListener) {
|
||||
listener.bind(this, song, binding.songMenu)
|
||||
listener.bind(song, this, menuButton = binding.songMenu)
|
||||
binding.songAlbumCover.bind(song)
|
||||
binding.songName.text = song.resolveName(binding.context)
|
||||
binding.songInfo.text = song.album.resolveName(binding.context)
|
||||
|
@ -259,7 +260,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
|
|
|
@ -57,8 +57,8 @@ abstract class DetailAdapter(
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
when (viewType) {
|
||||
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.new(parent)
|
||||
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.new(parent)
|
||||
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
|
||||
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
|
||||
else -> error("Invalid item type $viewType")
|
||||
}
|
||||
|
||||
|
@ -109,7 +109,7 @@ abstract class DetailAdapter(
|
|||
fun onOpenSortMenu(anchor: View)
|
||||
}
|
||||
|
||||
companion object {
|
||||
protected companion object {
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
|
@ -128,7 +128,7 @@ abstract class DetailAdapter(
|
|||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a
|
||||
* button opening a menu for sorting. Use [new] to create an instance.
|
||||
* button opening a menu for sorting. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
|
||||
|
@ -157,7 +157,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
|
|
|
@ -54,9 +54,9 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
when (viewType) {
|
||||
GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.new(parent)
|
||||
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.new(parent)
|
||||
SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent)
|
||||
GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.from(parent)
|
||||
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
|
||||
SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
|
||||
else -> super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
|
||||
|
@ -75,7 +75,7 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
|
|||
return super.isItemFullWidth(position) || item is Genre
|
||||
}
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
|
@ -94,7 +94,7 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [new] to
|
||||
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [from] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -130,7 +130,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
|
|
|
@ -49,14 +49,7 @@ import org.oxycblt.auxio.home.list.GenreListFragment
|
|||
import org.oxycblt.auxio.home.list.SongListFragment
|
||||
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
|
||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.system.Indexer
|
||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
|
@ -72,17 +65,7 @@ class HomeFragment :
|
|||
private val homeModel: HomeViewModel by androidActivityViewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
private val navModel: NavigationViewModel by activityViewModels()
|
||||
|
||||
// lifecycleObject builds this in the creation step, so doing this is okay.
|
||||
private val storagePermissionLauncher: ActivityResultLauncher<String> by lifecycleObject {
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
musicModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private val sortItem: MenuItem by lifecycleObject { binding ->
|
||||
binding.homeToolbar.menu.findItem(R.id.submenu_sorting)
|
||||
}
|
||||
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -105,6 +88,12 @@ class HomeFragment :
|
|||
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// Have to set up the permission launcher before the view is shown
|
||||
storagePermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
musicModel.refresh()
|
||||
}
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.homeAppbar.addOnOffsetChangedListener(this)
|
||||
binding.homeToolbar.setOnMenuItemClickListener(this)
|
||||
|
@ -171,6 +160,7 @@ class HomeFragment :
|
|||
|
||||
override fun onDestroyBinding(binding: FragmentHomeBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
storagePermissionLauncher = null
|
||||
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
||||
binding.homeToolbar.setOnMenuItemClickListener(null)
|
||||
}
|
||||
|
@ -285,14 +275,16 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
|
||||
val sortMenu = requireNotNull(sortItem.subMenu)
|
||||
val sortMenu =
|
||||
unlikelyToBeNull(
|
||||
requireBinding().homeToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
|
||||
val toHighlight = homeModel.getSortForTab(tabMode)
|
||||
|
||||
for (option in sortMenu) {
|
||||
// Check the ascending option and corresponding sort option to align with
|
||||
// the current sort of the tab.
|
||||
if (option.itemId == toHighlight.mode.itemId ||
|
||||
(option.itemId == R.id.option_sort_asc && toHighlight.isAscending)) {
|
||||
(option.itemId == R.id.option_sort_asc && toHighlight.isAscending)) {
|
||||
option.isChecked = true
|
||||
}
|
||||
|
||||
|
@ -303,7 +295,13 @@ class HomeFragment :
|
|||
// Update the scrolling view in AppBarLayout to align with the current tab's
|
||||
// scrolling state. This prevents the lift state from being confused as one
|
||||
// goes between different tabs.
|
||||
requireBinding().homeAppbar.liftOnScrollTargetViewId = getTabRecyclerId(tabMode)
|
||||
requireBinding().homeAppbar.liftOnScrollTargetViewId =
|
||||
when (tabMode) {
|
||||
MusicMode.SONGS -> R.id.home_song_recycler
|
||||
MusicMode.ALBUMS -> R.id.home_album_recycler
|
||||
MusicMode.ARTISTS -> R.id.home_artist_recycler
|
||||
MusicMode.GENRES -> R.id.home_genre_recycler
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRecreate(recreate: Boolean) {
|
||||
|
@ -321,9 +319,12 @@ class HomeFragment :
|
|||
}
|
||||
|
||||
private fun updateIndexerState(state: Indexer.State?) {
|
||||
// TODO: Make music loading experience a bit more pleasant
|
||||
// 1. Loading placeholder for item lists
|
||||
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
|
||||
val binding = requireBinding()
|
||||
when (state) {
|
||||
is Indexer.State.Complete -> setupCompleteState(binding, state.response)
|
||||
is Indexer.State.Complete -> setupCompleteState(binding, state.result)
|
||||
is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
|
||||
null -> {
|
||||
logD("Indexer is in indeterminate state")
|
||||
|
@ -332,53 +333,56 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun setupCompleteState(binding: FragmentHomeBinding, response: Indexer.Response) {
|
||||
if (response is Indexer.Response.Ok) {
|
||||
private fun setupCompleteState(
|
||||
binding: FragmentHomeBinding,
|
||||
result: Result<MusicStore.Library>
|
||||
) {
|
||||
if (result.isSuccess) {
|
||||
logD("Received ok response")
|
||||
binding.homeFab.show()
|
||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||
} else {
|
||||
logD("Received non-ok response")
|
||||
val context = requireContext()
|
||||
val throwable = unlikelyToBeNull(result.exceptionOrNull())
|
||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||
when (response) {
|
||||
is Indexer.Response.Err -> {
|
||||
logD("Updating UI to Response.Err state")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
||||
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_retry)
|
||||
setOnClickListener { musicModel.refresh() }
|
||||
}
|
||||
}
|
||||
is Indexer.Response.NoMusic -> {
|
||||
logD("Updating UI to Response.NoMusic state")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
||||
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_retry)
|
||||
setOnClickListener { musicModel.refresh() }
|
||||
}
|
||||
}
|
||||
is Indexer.Response.NoPerms -> {
|
||||
logD("Updating UI to Response.NoPerms state")
|
||||
when (throwable) {
|
||||
is Indexer.NoPermissionException -> {
|
||||
logD("Updating UI to permission request state")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
||||
|
||||
// Configure the action to act as a permission launcher.
|
||||
binding.homeIndexingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_grant)
|
||||
setOnClickListener {
|
||||
storagePermissionLauncher.launch(Indexer.PERMISSION_READ_AUDIO)
|
||||
requireNotNull(storagePermissionLauncher) {
|
||||
"Permission launcher was not available"
|
||||
}
|
||||
.launch(Indexer.PERMISSION_READ_AUDIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
is Indexer.NoMusicException -> {
|
||||
logD("Updating UI to no music state")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_retry)
|
||||
setOnClickListener { musicModel.refresh() }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
logD("Updating UI to error state")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_retry)
|
||||
setOnClickListener { musicModel.rescan() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -438,10 +442,9 @@ class HomeFragment :
|
|||
val binding = requireBinding()
|
||||
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
|
||||
selected.isNotEmpty()) {
|
||||
// New selection started, show the AppBarLayout to indicate the new state.
|
||||
logD("Significant selection occurred, expanding AppBar")
|
||||
// Significant enough change where we want to expand the RecyclerView
|
||||
binding.homeAppbar.expandWithRecycler(
|
||||
binding.homePager.findViewById(getTabRecyclerId(homeModel.currentTabMode.value)))
|
||||
binding.homeAppbar.expandWithScrollingRecycler()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -457,20 +460,6 @@ class HomeFragment :
|
|||
reenterTransition = MaterialSharedAxis(axis, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the RecyclerView contained by [ViewPager2] tab represented with the given
|
||||
* [MusicMode].
|
||||
* @param tabMode The [MusicMode] of the tab.
|
||||
* @return The ID of the RecyclerView contained by the given tab.
|
||||
*/
|
||||
private fun getTabRecyclerId(tabMode: MusicMode) =
|
||||
when (tabMode) {
|
||||
MusicMode.SONGS -> R.id.home_song_recycler
|
||||
MusicMode.ALBUMS -> R.id.home_album_recycler
|
||||
MusicMode.ARTISTS -> R.id.home_artist_recycler
|
||||
MusicMode.GENRES -> R.id.home_genre_recycler
|
||||
}
|
||||
|
||||
/**
|
||||
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
|
||||
* @param tabs The current tab configuration. This will define the [Fragment]s created.
|
||||
|
@ -493,12 +482,10 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val VP_RECYCLER_FIELD: Field by
|
||||
lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
||||
private val RV_TOUCH_SLOP_FIELD: Field by
|
||||
lazyReflectedField(RecyclerView::class, "mTouchSlop")
|
||||
private const val KEY_LAST_TRANSITION_AXIS =
|
||||
private companion object {
|
||||
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
||||
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
|
||||
const val KEY_LAST_TRANSITION_AXIS =
|
||||
BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
package org.oxycblt.auxio.home
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
@ -39,9 +40,11 @@ import org.oxycblt.auxio.util.logD
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class HomeViewModel(application: Application) :
|
||||
AndroidViewModel(application), Settings.Callback, MusicStore.Callback {
|
||||
AndroidViewModel(application),
|
||||
MusicStore.Listener,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
private val settings = Settings(application, this)
|
||||
private val settings = Settings(application)
|
||||
|
||||
private val _songsList = MutableStateFlow(listOf<Song>())
|
||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
|
@ -91,13 +94,14 @@ class HomeViewModel(application: Application) :
|
|||
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
|
||||
|
||||
init {
|
||||
musicStore.addCallback(this)
|
||||
musicStore.addListener(this)
|
||||
settings.addListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicStore.removeCallback(this)
|
||||
settings.release()
|
||||
musicStore.removeListener(this)
|
||||
settings.removeListener(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
|
@ -119,7 +123,7 @@ class HomeViewModel(application: Application) :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String) {
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
when (key) {
|
||||
context.getString(R.string.set_key_lib_tabs) -> {
|
||||
// Tabs changed, update the current tabs and set up a re-create event.
|
||||
|
|
|
@ -169,8 +169,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
|
|||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
// Pre-calculate sqrt(2)
|
||||
private const val SQRT2 = 1.4142135f
|
||||
const val SQRT2 = 1.4142135f
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,26 +71,6 @@ class FastScrollRecyclerView
|
|||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AuxioRecyclerView(context, attrs, defStyleAttr) {
|
||||
/** An interface to provide text to use in the popup when fast-scrolling. */
|
||||
interface PopupProvider {
|
||||
/**
|
||||
* Get text to use in the popup at the specified position.
|
||||
* @param pos The position in the list.
|
||||
* @return A [String] to use in the popup. Null if there is no applicable text for the popup
|
||||
* at [pos].
|
||||
*/
|
||||
fun getPopup(pos: Int): String?
|
||||
}
|
||||
|
||||
/** A listener for fast scroller interactions. */
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the fast scrolling state changes.
|
||||
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
|
||||
*/
|
||||
fun onFastScrollingChanged(isFastScrolling: Boolean)
|
||||
}
|
||||
|
||||
// Thumb
|
||||
private val thumbView =
|
||||
View(context).apply {
|
||||
|
@ -524,7 +504,27 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
else -> 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
|
||||
/** An interface to provide text to use in the popup when fast-scrolling. */
|
||||
interface PopupProvider {
|
||||
/**
|
||||
* Get text to use in the popup at the specified position.
|
||||
* @param pos The position in the list.
|
||||
* @return A [String] to use in the popup. Null if there is no applicable text for the popup
|
||||
* at [pos].
|
||||
*/
|
||||
fun getPopup(pos: Int): String?
|
||||
}
|
||||
|
||||
/** A listener for fast scroller interactions. */
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the fast scrolling state changes.
|
||||
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
|
||||
*/
|
||||
fun onFastScrollingChanged(isFastScrolling: Boolean)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
|
||||
}
|
||||
}
|
||||
|
|
|
@ -94,8 +94,8 @@ class AlbumListFragment :
|
|||
is Sort.Mode.ByArtist ->
|
||||
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Year -> Use Full Year
|
||||
is Sort.Mode.ByDate -> album.date?.resolveDate(requireContext())
|
||||
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
|
||||
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
||||
|
@ -152,7 +152,7 @@ class AlbumListFragment :
|
|||
get() = differ.currentList
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
AlbumViewHolder.new(parent)
|
||||
AlbumViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
|
|
|
@ -127,7 +127,7 @@ class ArtistListFragment :
|
|||
get() = differ.currentList
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
ArtistViewHolder.new(parent)
|
||||
ArtistViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
|
|
|
@ -126,7 +126,7 @@ class GenreListFragment :
|
|||
get() = differ.currentList
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
GenreViewHolder.new(parent)
|
||||
GenreViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
|
|
|
@ -103,7 +103,7 @@ class SongListFragment :
|
|||
song.album.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Year -> Use Full Year
|
||||
is Sort.Mode.ByDate -> song.album.date?.resolveDate(requireContext())
|
||||
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
||||
|
@ -166,7 +166,7 @@ class SongListFragment :
|
|||
get() = differ.currentList
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
SongViewHolder.new(parent)
|
||||
SongViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
package org.oxycblt.auxio.home.tabs
|
||||
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
|
@ -25,7 +26,7 @@ import org.oxycblt.auxio.util.logE
|
|||
* @param mode The type of list in the home view this instance corresponds to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed class Tab(open val mode: MusicMode) {
|
||||
sealed class Tab(open val mode: MusicMode) : Item {
|
||||
/**
|
||||
* A visible tab. This will be visible in the home and tab configuration views.
|
||||
* @param mode The type of list in the home view this instance corresponds to.
|
||||
|
|
|
@ -18,27 +18,28 @@
|
|||
package org.oxycblt.auxio.home.tabs
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemTabBinding
|
||||
import org.oxycblt.auxio.list.EditableListListener
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
|
||||
* @param listener A [Listener] for tab interactions.
|
||||
* @param listener A [EditableListListener] for tab interactions.
|
||||
*/
|
||||
class TabAdapter(private val listener: Listener) : RecyclerView.Adapter<TabViewHolder>() {
|
||||
class TabAdapter(private val listener: EditableListListener) :
|
||||
RecyclerView.Adapter<TabViewHolder>() {
|
||||
/** The current array of [Tab]s. */
|
||||
var tabs = arrayOf<Tab>()
|
||||
private set
|
||||
|
||||
override fun getItemCount() = tabs.size
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.new(parent)
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.from(parent)
|
||||
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
|
||||
holder.bind(tabs[position], listener)
|
||||
}
|
||||
|
@ -75,30 +76,13 @@ class TabAdapter(private val listener: Listener) : RecyclerView.Adapter<TabViewH
|
|||
notifyItemMoved(a, b)
|
||||
}
|
||||
|
||||
/** A listener for interactions specific to tab configuration. */
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when a tab is clicked, requesting that the visibility should be inverted (i.e
|
||||
* Visible -> Invisible and vice versa).
|
||||
* @param tabMode The [MusicMode] of the tab clicked.
|
||||
*/
|
||||
fun onToggleVisibility(tabMode: MusicMode)
|
||||
|
||||
/**
|
||||
* Called when the drag handle on a [RecyclerView.ViewHolder] is clicked, requesting that a
|
||||
* drag should be started.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] to start dragging.
|
||||
*/
|
||||
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PAYLOAD_TAB_CHANGED = Any()
|
||||
private companion object {
|
||||
val PAYLOAD_TAB_CHANGED = Any()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Tab]. Use [new] to create an instance.
|
||||
* A [RecyclerView.ViewHolder] that displays a [Tab]. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
||||
|
@ -106,12 +90,11 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
|||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param tab The new [Tab] to bind.
|
||||
* @param listener A [TabAdapter.Listener] to bind interactions to.
|
||||
* @param listener A [EditableListListener] to bind interactions to.
|
||||
*/
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
fun bind(tab: Tab, listener: TabAdapter.Listener) {
|
||||
binding.root.setOnClickListener { listener.onToggleVisibility(tab.mode) }
|
||||
|
||||
fun bind(tab: Tab, listener: EditableListListener) {
|
||||
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
|
||||
binding.tabCheckBox.apply {
|
||||
// Update the CheckBox name to align with the mode
|
||||
setText(
|
||||
|
@ -126,15 +109,6 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
|||
// the tab data since they are in the same data structure (Tab)
|
||||
isChecked = tab is Tab.Visible
|
||||
}
|
||||
|
||||
// Set up the drag handle to start a drag whenever it is touched.
|
||||
binding.tabDragHandle.setOnTouchListener { _, motionEvent ->
|
||||
binding.tabDragHandle.performClick()
|
||||
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
listener.onPickUp(this)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -143,6 +117,6 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
|
||||
fun from(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,23 +25,19 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogTabsBinding
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.list.EditableListListener
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAdapter.Listener {
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
|
||||
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener {
|
||||
private val tabAdapter = TabAdapter(this)
|
||||
private val touchHelper: ItemTouchHelper by lifecycleObject {
|
||||
ItemTouchHelper(TabDragCallback(tabAdapter))
|
||||
}
|
||||
private var touchHelper: ItemTouchHelper? = null
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
|
||||
|
||||
|
@ -50,13 +46,13 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
|
|||
.setTitle(R.string.set_lib_tabs)
|
||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||
logD("Committing tab changes")
|
||||
settings.libTabs = tabAdapter.tabs
|
||||
Settings(requireContext()).libTabs = tabAdapter.tabs
|
||||
}
|
||||
.setNegativeButton(R.string.lbl_cancel, null)
|
||||
}
|
||||
|
||||
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
|
||||
var tabs = settings.libTabs
|
||||
var tabs = Settings(requireContext()).libTabs
|
||||
// Try to restore a pending tab configuration that was saved prior.
|
||||
if (savedInstanceState != null) {
|
||||
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
|
||||
|
@ -69,7 +65,8 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
|
|||
tabAdapter.submitTabs(tabs)
|
||||
binding.tabRecycler.apply {
|
||||
adapter = tabAdapter
|
||||
touchHelper.attachToRecyclerView(this)
|
||||
touchHelper =
|
||||
ItemTouchHelper(TabDragCallback(tabAdapter)).also { it.attachToRecyclerView(this) }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -84,12 +81,11 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
|
|||
binding.tabRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onToggleVisibility(tabMode: MusicMode) {
|
||||
logD("Toggling tab $tabMode")
|
||||
|
||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||
check(item is Tab) { "Unexpected datatype: ${item::class.java}" }
|
||||
// We will need the exact index of the tab to update on in order to
|
||||
// notify the adapter of the change.
|
||||
val index = tabAdapter.tabs.indexOfFirst { it.mode == tabMode }
|
||||
val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode }
|
||||
val tab = tabAdapter.tabs[index]
|
||||
tabAdapter.setTab(
|
||||
index,
|
||||
|
@ -105,10 +101,10 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
|
|||
}
|
||||
|
||||
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
||||
touchHelper.startDrag(viewHolder)
|
||||
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
|
||||
private companion object {
|
||||
const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
private val cornerRadius: Float
|
||||
|
||||
init {
|
||||
// Obtain some StyledImageView attributes to use later when theming the cusotm view.
|
||||
// Obtain some StyledImageView attributes to use later when theming the custom view.
|
||||
@SuppressLint("CustomViewStyleable")
|
||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
|
||||
// Keep track of our corner radius so that we can apply the same attributes to the custom
|
||||
|
@ -107,7 +107,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
// Playback indicator should sit above the inner StyledImageView and custom view/
|
||||
addView(playbackIndicatorView)
|
||||
// Selction indicator should never be obscured, so place it at the top.
|
||||
// Selection indicator should never be obscured, so place it at the top.
|
||||
addView(
|
||||
selectionIndicatorView,
|
||||
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
|
||||
|
|
|
@ -177,6 +177,8 @@ object Covers {
|
|||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? {
|
||||
// Eliminate any chance that this blocking call might mess up the loading process
|
||||
return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
|
||||
return withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(album.coverUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import android.view.View
|
|||
import androidx.annotation.MenuRes
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.oxycblt.auxio.MainFragmentDirections
|
||||
import org.oxycblt.auxio.R
|
||||
|
@ -53,7 +54,7 @@ abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), Selecta
|
|||
*/
|
||||
abstract fun onRealClick(music: Music)
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
if (selectionModel.selected.value.isNotEmpty()) {
|
||||
// Map clicking an item to selecting an item when items are already selected.
|
||||
|
|
|
@ -17,8 +17,8 @@
|
|||
|
||||
package org.oxycblt.auxio.list
|
||||
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
|
@ -26,13 +26,63 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface ClickableListListener {
|
||||
// TODO: Supply a ViewHolder on clicks
|
||||
// (allows editable lists to be standardized into a listener.)
|
||||
/**
|
||||
* Called when an [Item] in the list is clicked.
|
||||
* @param item The [Item] that was clicked.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
|
||||
*/
|
||||
fun onClick(item: Item)
|
||||
fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder)
|
||||
|
||||
/**
|
||||
* Binds this instance to a list item.
|
||||
* @param item The [Item] that this list entry is bound to.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
|
||||
* @param bodyView The [View] containing the main body of the list item. Any click events on
|
||||
* this [View] are routed to the listener. Defaults to the root view.
|
||||
*/
|
||||
fun bind(
|
||||
item: Item,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
bodyView: View = viewHolder.itemView
|
||||
) {
|
||||
bodyView.setOnClickListener { onClick(item, viewHolder) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An extension of [ClickableListListener] that enables list editing functionality.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface EditableListListener : ClickableListListener {
|
||||
/**
|
||||
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] that should start being dragged.
|
||||
*/
|
||||
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
|
||||
|
||||
/**
|
||||
* Binds this instance to a list item.
|
||||
* @param item The [Item] that this list entry is bound to.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
|
||||
* @param bodyView The [View] containing the main body of the list item. Any click events on
|
||||
* this [View] are routed to the listener. Defaults to the root view.
|
||||
* @param dragHandle A touchable [View]. Any drag on this view will start a drag event.
|
||||
*/
|
||||
fun bind(
|
||||
item: Item,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
bodyView: View = viewHolder.itemView,
|
||||
dragHandle: View
|
||||
) {
|
||||
bind(item, viewHolder, bodyView)
|
||||
dragHandle.setOnTouchListener { _, motionEvent ->
|
||||
dragHandle.performClick()
|
||||
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
onPickUp(viewHolder)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -55,19 +105,23 @@ interface SelectableListListener : ClickableListListener {
|
|||
|
||||
/**
|
||||
* Binds this instance to a list item.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
|
||||
* @param item The [Item] that this list entry is bound to.
|
||||
* @param menuButton A [Button] that opens a menu.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
|
||||
* @param bodyView The [View] containing the main body of the list item. Any click events on
|
||||
* this [View] are routed to the listener. Defaults to the root view.
|
||||
* @param menuButton A clickable [View]. Any click events on this [View] will open a menu.
|
||||
*/
|
||||
fun bind(viewHolder: RecyclerView.ViewHolder, item: Item, menuButton: Button) {
|
||||
viewHolder.itemView.apply {
|
||||
// Map clicks to the click listener.
|
||||
setOnClickListener { onClick(item) }
|
||||
// Map long clicks to the selection listener.
|
||||
setOnLongClickListener {
|
||||
onSelect(item)
|
||||
true
|
||||
}
|
||||
fun bind(
|
||||
item: Item,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
bodyView: View = viewHolder.itemView,
|
||||
menuButton: View
|
||||
) {
|
||||
bind(item, viewHolder, bodyView)
|
||||
// Map long clicks to the selection listener.
|
||||
bodyView.setOnLongClickListener {
|
||||
onSelect(item)
|
||||
true
|
||||
}
|
||||
// Map the menu button to the menu opening listener.
|
||||
menuButton.setOnClickListener { onOpenMenu(item, it) }
|
||||
|
|
|
@ -115,7 +115,7 @@ abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : RecyclerV
|
|||
abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PAYLOAD_PLAYING_INDICATOR_CHANGED = Any()
|
||||
private companion object {
|
||||
val PAYLOAD_PLAYING_INDICATOR_CHANGED = Any()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,7 +77,7 @@ abstract class SelectionIndicatorAdapter<VH : RecyclerView.ViewHolder> :
|
|||
abstract fun updateSelectionIndicator(isSelected: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PAYLOAD_SELECTION_INDICATOR_CHANGED = Any()
|
||||
private companion object {
|
||||
val PAYLOAD_SELECTION_INDICATOR_CHANGED = Any()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.getPlural
|
|||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [new] to create an instance.
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SongViewHolder private constructor(private val binding: ItemSongBinding) :
|
||||
|
@ -46,7 +46,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
|
|||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(song: Song, listener: SelectableListListener) {
|
||||
listener.bind(this, song, binding.songMenu)
|
||||
listener.bind(song, this, menuButton = binding.songMenu)
|
||||
binding.songAlbumCover.bind(song)
|
||||
binding.songName.text = song.resolveName(binding.context)
|
||||
binding.songInfo.text = song.resolveArtistContents(binding.context)
|
||||
|
@ -70,7 +70,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = SongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
|
||||
fun from(parent: View) = SongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
|
@ -82,7 +82,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Album]. Use [new] to create an instance.
|
||||
* A [RecyclerView.ViewHolder] that displays a [Album]. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AlbumViewHolder private constructor(private val binding: ItemParentBinding) :
|
||||
|
@ -93,7 +93,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
|
|||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(album: Album, listener: SelectableListListener) {
|
||||
listener.bind(this, album, binding.parentMenu)
|
||||
listener.bind(album, this, menuButton = binding.parentMenu)
|
||||
binding.parentImage.bind(album)
|
||||
binding.parentName.text = album.resolveName(binding.context)
|
||||
binding.parentInfo.text = album.resolveArtistContents(binding.context)
|
||||
|
@ -117,7 +117,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = AlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
fun from(parent: View) = AlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
|
@ -131,7 +131,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Artist]. Use [new] to create an instance.
|
||||
* A [RecyclerView.ViewHolder] that displays a [Artist]. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
|
||||
|
@ -142,7 +142,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
|||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(artist: Artist, listener: SelectableListListener) {
|
||||
listener.bind(this, artist, binding.parentMenu)
|
||||
listener.bind(artist, this, menuButton = binding.parentMenu)
|
||||
binding.parentImage.bind(artist)
|
||||
binding.parentName.text = artist.resolveName(binding.context)
|
||||
binding.parentInfo.text =
|
||||
|
@ -175,7 +175,8 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = ArtistViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
fun from(parent: View) =
|
||||
ArtistViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
|
@ -189,7 +190,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Genre]. Use [new] to create an instance.
|
||||
* A [RecyclerView.ViewHolder] that displays a [Genre]. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenreViewHolder private constructor(private val binding: ItemParentBinding) :
|
||||
|
@ -200,7 +201,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
|
|||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(genre: Genre, listener: SelectableListListener) {
|
||||
listener.bind(this, genre, binding.parentMenu)
|
||||
listener.bind(genre, this, menuButton = binding.parentMenu)
|
||||
binding.parentImage.bind(genre)
|
||||
binding.parentName.text = genre.resolveName(binding.context)
|
||||
binding.parentInfo.text =
|
||||
|
@ -228,7 +229,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = GenreViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
fun from(parent: View) = GenreViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
|
@ -240,7 +241,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Header]. Use [new] to create an instance.
|
||||
* A [RecyclerView.ViewHolder] that displays a [Header]. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
|
||||
|
@ -262,7 +263,8 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
|
||||
fun from(parent: View) =
|
||||
HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
|
|
|
@ -26,7 +26,7 @@ import org.oxycblt.auxio.music.*
|
|||
* A [ViewModel] that manages the current selection.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SelectionViewModel : ViewModel(), MusicStore.Callback {
|
||||
class SelectionViewModel : ViewModel(), MusicStore.Listener {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
|
||||
private val _selected = MutableStateFlow(listOf<Music>())
|
||||
|
@ -35,7 +35,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Callback {
|
|||
get() = _selected
|
||||
|
||||
init {
|
||||
musicStore.addCallback(this)
|
||||
musicStore.addListener(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
|
@ -58,7 +58,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Callback {
|
|||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicStore.removeCallback(this)
|
||||
musicStore.removeListener(this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
265
app/src/main/java/org/oxycblt/auxio/music/Date.kt
Normal file
265
app/src/main/java/org/oxycblt/auxio/music/Date.kt
Normal file
|
@ -0,0 +1,265 @@
|
|||
/*
|
||||
* 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.music
|
||||
|
||||
import android.content.Context
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import kotlin.math.max
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.inRangeOrNull
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
|
||||
/**
|
||||
* An ISO-8601/RFC 3339 Date.
|
||||
*
|
||||
* This class only encodes the timestamp spec and it's conversion to a human-readable date, without
|
||||
* any other time management or validation. In general, this should only be used for display. Use
|
||||
* [from] to create an instance.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Date private constructor(private val tokens: List<Int>) : Comparable<Date> {
|
||||
private val year = tokens[0]
|
||||
private val month = tokens.getOrNull(1)
|
||||
private val day = tokens.getOrNull(2)
|
||||
private val hour = tokens.getOrNull(3)
|
||||
private val minute = tokens.getOrNull(4)
|
||||
private val second = tokens.getOrNull(5)
|
||||
|
||||
/**
|
||||
* Resolve this instance into a human-readable date.
|
||||
* @param context [Context] required to get human-readable names.
|
||||
* @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan
|
||||
* 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
|
||||
* be properly localized.
|
||||
*/
|
||||
fun resolveDate(context: Context): String {
|
||||
if (month != null) {
|
||||
// Parse a date format from an ISO-ish format
|
||||
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
|
||||
format.applyPattern("yyyy-MM")
|
||||
val date =
|
||||
try {
|
||||
format.parse("$year-$month")
|
||||
} catch (e: ParseException) {
|
||||
null
|
||||
}
|
||||
|
||||
if (date != null) {
|
||||
// Reformat as a readable month and year
|
||||
format.applyPattern("MMM yyyy")
|
||||
return format.format(date)
|
||||
}
|
||||
}
|
||||
|
||||
// Unable to create fine-grained date, just format as a year.
|
||||
return context.getString(R.string.fmt_number, year)
|
||||
}
|
||||
|
||||
override fun hashCode() = tokens.hashCode()
|
||||
|
||||
override fun equals(other: Any?) = other is Date && tokens == other.tokens
|
||||
|
||||
override fun compareTo(other: Date): Int {
|
||||
for (i in 0 until max(tokens.size, other.tokens.size)) {
|
||||
val ai = tokens.getOrNull(i)
|
||||
val bi = other.tokens.getOrNull(i)
|
||||
when {
|
||||
ai != null && bi != null -> {
|
||||
val result = ai.compareTo(bi)
|
||||
if (result != 0) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
ai == null && bi != null -> return -1 // a < b
|
||||
ai == null && bi == null -> return 0 // a = b
|
||||
else -> return 1 // a < b
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun toString() = StringBuilder().appendDate().toString()
|
||||
|
||||
private fun StringBuilder.appendDate(): StringBuilder {
|
||||
// Construct an ISO-8601 date, dropping precision that doesn't exist.
|
||||
append(year.toStringFixed(4))
|
||||
append("-${(month ?: return this).toStringFixed(2)}")
|
||||
append("-${(day ?: return this).toStringFixed(2)}")
|
||||
append("T${(hour ?: return this).toStringFixed(2)}")
|
||||
append(":${(minute ?: return this.append('Z')).toStringFixed(2)}")
|
||||
append(":${(second ?: return this.append('Z')).toStringFixed(2)}")
|
||||
return this.append('Z')
|
||||
}
|
||||
|
||||
private fun Int.toStringFixed(len: Int) = toString().padStart(len, '0').substring(0 until len)
|
||||
|
||||
/**
|
||||
* A range of [Date]s. This is used in contexts where the [Date] of an item is derived from
|
||||
* several sub-items and thus can have a "range" of release dates. Use [from] to create an
|
||||
* instance.
|
||||
* @author Alexander Capehart
|
||||
*/
|
||||
class Range
|
||||
private constructor(
|
||||
/** The earliest [Date] in the range. */
|
||||
val min: Date,
|
||||
/** the latest [Date] in the range. May be the same as [min]. ] */
|
||||
val max: Date
|
||||
) : Comparable<Range> {
|
||||
|
||||
/**
|
||||
* Resolve this instance into a human-readable date range.
|
||||
* @param context [Context] required to get human-readable names.
|
||||
* @return If the date has a maximum value, then a `min - max` formatted string will be
|
||||
* returned with the formatted [Date]s of the minimum and maximum dates respectively.
|
||||
* Otherwise, the formatted name of the minimum [Date] will be returned.
|
||||
*/
|
||||
fun resolveDate(context: Context) =
|
||||
if (min != max) {
|
||||
context.getString(
|
||||
R.string.fmt_date_range, min.resolveDate(context), max.resolveDate(context))
|
||||
} else {
|
||||
min.resolveDate(context)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?) =
|
||||
other is Range && min == other.min && max == other.max
|
||||
|
||||
override fun hashCode() = 31 * max.hashCode() + min.hashCode()
|
||||
|
||||
override fun compareTo(other: Range) = min.compareTo(other.min)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a [Range] from the given list of [Date]s.
|
||||
* @param dates The [Date]s to use.
|
||||
* @return A [Range] based on the minimum and maximum [Date]s. If there are no [Date]s,
|
||||
* null is returned.
|
||||
*/
|
||||
fun from(dates: List<Date>): Range? {
|
||||
if (dates.isEmpty()) {
|
||||
// Nothing to do.
|
||||
return null
|
||||
}
|
||||
// Simultaneously find the minimum and maximum values in the given range.
|
||||
// If this list has only one item, then that one date is the minimum and maximum.
|
||||
var min = dates.first()
|
||||
var max = min
|
||||
for (i in 1..dates.lastIndex) {
|
||||
if (dates[i] < min) {
|
||||
min = dates[i]
|
||||
}
|
||||
if (dates[i] > max) {
|
||||
max = dates[i]
|
||||
}
|
||||
}
|
||||
return Range(min, max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A [Regex] that can parse a variable-precision ISO-8601 timestamp. Derived from
|
||||
* https://github.com/quodlibet/mutagen
|
||||
*/
|
||||
private val ISO8601_REGEX =
|
||||
Regex(
|
||||
"""^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""")
|
||||
|
||||
/**
|
||||
* Create a [Date] from a year component.
|
||||
* @param year The year component.
|
||||
* @return A new [Date] of the given component, or null if the component is invalid.
|
||||
*/
|
||||
fun from(year: Int) = fromTokens(listOf(year))
|
||||
|
||||
/**
|
||||
* Create a [Date] from a date component.
|
||||
* @param year The year component.
|
||||
* @param month The month component.
|
||||
* @param day The day component.
|
||||
* @return A new [Date] consisting of the given components. May have reduced precision if
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
*/
|
||||
fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day))
|
||||
|
||||
/**
|
||||
* Create [Date] from a datetime component.
|
||||
* @param year The year component.
|
||||
* @param month The month component.
|
||||
* @param day The day component.
|
||||
* @param hour The hour component
|
||||
* @return A new [Date] consisting of the given components. May have reduced precision if
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
*/
|
||||
fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) =
|
||||
fromTokens(listOf(year, month, day, hour, minute))
|
||||
|
||||
/**
|
||||
* Create a [Date] from a [String] timestamp.
|
||||
* @param timestamp The ISO-8601 timestamp to parse. Can have reduced precision.
|
||||
* @return A new [Date] consisting of the given components. May have reduced precision if
|
||||
* the components were partially invalid, and will be null if all components are invalid or
|
||||
* if the timestamp is invalid.
|
||||
*/
|
||||
fun from(timestamp: String): Date? {
|
||||
val tokens =
|
||||
// Match the input with the timestamp regex
|
||||
(ISO8601_REGEX.matchEntire(timestamp) ?: return null)
|
||||
.groupValues
|
||||
// Filter to the specific tokens we want and convert them to integer tokens.
|
||||
.mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null }
|
||||
return fromTokens(tokens)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a [Date] from the given non-validated tokens.
|
||||
* @param tokens The tokens to use for each date component, in order of precision.
|
||||
* @return A new [Date] consisting of the given components. May have reduced precision if
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
*/
|
||||
private fun fromTokens(tokens: List<Int>): Date? {
|
||||
val validated = mutableListOf<Int>()
|
||||
validateTokens(tokens, validated)
|
||||
if (validated.isEmpty()) {
|
||||
// No token was valid, return null.
|
||||
return null
|
||||
}
|
||||
return Date(validated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a list of tokens provided by [src], and add the valid ones to [dst]. Will stop
|
||||
* as soon as an invalid token is found.
|
||||
* @param src The input tokens to validate.
|
||||
* @param dst The destination list to add valid tokens to.
|
||||
*/
|
||||
private fun validateTokens(src: List<Int>, dst: MutableList<Int>) {
|
||||
dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return)
|
||||
dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
|
||||
dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return)
|
||||
dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return)
|
||||
dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return)
|
||||
dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,20 +24,16 @@ import android.os.Parcelable
|
|||
import java.security.MessageDigest
|
||||
import java.text.CollationKey
|
||||
import java.text.Collator
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.UUID
|
||||
import kotlin.math.max
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.extractor.parseId3GenreNames
|
||||
import org.oxycblt.auxio.music.extractor.parseMultiValue
|
||||
import org.oxycblt.auxio.music.extractor.toUuidOrNull
|
||||
import org.oxycblt.auxio.music.storage.*
|
||||
import org.oxycblt.auxio.music.filesystem.*
|
||||
import org.oxycblt.auxio.music.parsing.parseId3GenreNames
|
||||
import org.oxycblt.auxio.music.parsing.parseMultiValue
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.inRangeOrNull
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
|
@ -114,6 +110,27 @@ sealed class Music : Item {
|
|||
return COLLATOR.getCollationKey(sortName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a list of [Music]'s resolved names into a string in a localized manner, using
|
||||
* [R.string.fmt_list].
|
||||
* @param context [Context] required to obtain localized formatting.
|
||||
* @param values The list of [Music] to format.
|
||||
* @return A single string consisting of the values delimited by a localized separator.
|
||||
*/
|
||||
protected fun resolveNames(context: Context, values: List<Music>): String {
|
||||
if (values.isEmpty()) {
|
||||
// Nothing to do.
|
||||
return ""
|
||||
}
|
||||
|
||||
var joined = values.first().resolveName(context)
|
||||
for (i in 1..values.lastIndex) {
|
||||
// Chain all previous values with the next value in the list with another delimiter.
|
||||
joined = context.getString(R.string.fmt_list, joined, values[i].resolveName(context))
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
// Note: We solely use the UID in comparisons so that certain items that differ in all
|
||||
// but UID are treated differently.
|
||||
|
||||
|
@ -262,9 +279,9 @@ sealed class Music : Item {
|
|||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
/** Cached collator instance re-used with [makeCollationKeyImpl]. */
|
||||
private val COLLATOR = Collator.getInstance().apply { strength = Collator.PRIMARY }
|
||||
val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -399,9 +416,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
|
|||
* Resolves one or more [Artist]s into a single piece of human-readable names.
|
||||
* @param context [Context] required for [resolveName]. formatter.
|
||||
*/
|
||||
fun resolveArtistContents(context: Context) =
|
||||
// TODO Internationalize the list
|
||||
artists.joinToString { it.resolveName(context) }
|
||||
fun resolveArtistContents(context: Context) = resolveNames(context, artists)
|
||||
|
||||
/**
|
||||
* Checks if the [Artist] *display* of this [Song] and another [Song] are equal. This will only
|
||||
|
@ -433,7 +448,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
|
|||
* Resolves one or more [Genre]s into a single piece human-readable names.
|
||||
* @param context [Context] required for [resolveName].
|
||||
*/
|
||||
fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) }
|
||||
fun resolveGenreContents(context: Context) = resolveNames(context, genres)
|
||||
|
||||
// --- INTERNAL FIELDS ---
|
||||
|
||||
|
@ -504,7 +519,6 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
|
|||
for (i in _artists.indices) {
|
||||
// Non-destructively reorder the linked artists so that they align with
|
||||
// the artist ordering within the song metadata.
|
||||
// TODO: Make sure this works for artists only derived from album artists.
|
||||
val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
|
||||
val other = _artists[newIdx]
|
||||
_artists[newIdx] = _artists[i]
|
||||
|
@ -610,11 +624,8 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
|
|||
override val collationKey = makeCollationKeyImpl()
|
||||
override fun resolveName(context: Context) = rawName
|
||||
|
||||
/**
|
||||
* The earliest [Date] this album was released. Will be null if no valid date was present in the
|
||||
* metadata of any [Song]
|
||||
*/
|
||||
val date: Date? // TODO: Date ranges?
|
||||
/** The [Date.Range] that [Song]s in the [Album] were released. */
|
||||
val dates = Date.Range.from(songs.mapNotNull { it.date })
|
||||
|
||||
/**
|
||||
* The [Type] of this album, signifying the type of release it actually is. Defaults to
|
||||
|
@ -634,31 +645,18 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
|
|||
val dateAdded: Long
|
||||
|
||||
init {
|
||||
var earliestDate: Date? = null
|
||||
var totalDuration: Long = 0
|
||||
var earliestDateAdded: Long = Long.MAX_VALUE
|
||||
|
||||
// Do linking and value generation in the same loop for efficiency.
|
||||
for (song in songs) {
|
||||
song._link(this)
|
||||
|
||||
if (song.date != null) {
|
||||
// Since we can't really assign a maximum value for dates, we instead
|
||||
// just check if the current earliest date doesn't exist and fill it
|
||||
// in with the current song if that's the case.
|
||||
if (earliestDate == null || song.date < earliestDate) {
|
||||
earliestDate = song.date
|
||||
}
|
||||
}
|
||||
|
||||
if (song.dateAdded < earliestDateAdded) {
|
||||
earliestDateAdded = song.dateAdded
|
||||
}
|
||||
|
||||
totalDuration += song.durationMs
|
||||
}
|
||||
|
||||
date = earliestDate
|
||||
durationMs = totalDuration
|
||||
dateAdded = earliestDateAdded
|
||||
}
|
||||
|
@ -676,7 +674,7 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
|
|||
* Resolves one or more [Artist]s into a single piece of human-readable names.
|
||||
* @param context [Context] required for [resolveName].
|
||||
*/
|
||||
fun resolveArtistContents(context: Context) = artists.joinToString { it.resolveName(context) }
|
||||
fun resolveArtistContents(context: Context) = resolveNames(context, artists)
|
||||
|
||||
/**
|
||||
* Checks if the [Artist] *display* of this [Album] and another [Album] are equal. This will
|
||||
|
@ -1043,7 +1041,7 @@ class Artist constructor(private val raw: Raw, songAlbums: List<Music>) : MusicP
|
|||
* Resolves one or more [Genre]s into a single piece of human-readable names.
|
||||
* @param context [Context] required for [resolveName].
|
||||
*/
|
||||
fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) }
|
||||
fun resolveGenreContents(context: Context) = resolveNames(context, genres)
|
||||
|
||||
/**
|
||||
* Checks if the [Genre] *display* of this [Artist] and another [Artist] are equal. This will
|
||||
|
@ -1212,181 +1210,20 @@ class Genre constructor(private val raw: Raw, override val songs: List<Song>) :
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ISO-8601/RFC 3339 Date.
|
||||
*
|
||||
* This class only encodes the timestamp spec and it's conversion to a human-readable date, without
|
||||
* any other time management or validation. In general, this should only be used for display.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Date private constructor(private val tokens: List<Int>) : Comparable<Date> {
|
||||
private val year = tokens[0]
|
||||
private val month = tokens.getOrNull(1)
|
||||
private val day = tokens.getOrNull(2)
|
||||
private val hour = tokens.getOrNull(3)
|
||||
private val minute = tokens.getOrNull(4)
|
||||
private val second = tokens.getOrNull(5)
|
||||
|
||||
/**
|
||||
* Resolve this instance into a human-readable date.
|
||||
* @param context [Context] required to get human-readable names.
|
||||
* @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan
|
||||
* 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
|
||||
* be properly localized.
|
||||
*/
|
||||
fun resolveDate(context: Context): String {
|
||||
if (month != null) {
|
||||
// Parse a date format from an ISO-ish format
|
||||
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
|
||||
format.applyPattern("yyyy-MM")
|
||||
val date =
|
||||
try {
|
||||
format.parse("$year-$month")
|
||||
} catch (e: ParseException) {
|
||||
null
|
||||
}
|
||||
|
||||
if (date != null) {
|
||||
// Reformat as a readable month and year
|
||||
format.applyPattern("MMM yyyy")
|
||||
return format.format(date)
|
||||
}
|
||||
}
|
||||
|
||||
// Unable to create fine-grained date, just format as a year.
|
||||
return context.getString(R.string.fmt_number, year)
|
||||
}
|
||||
|
||||
override fun hashCode() = tokens.hashCode()
|
||||
|
||||
override fun equals(other: Any?) = other is Date && tokens == other.tokens
|
||||
|
||||
override fun compareTo(other: Date): Int {
|
||||
for (i in 0 until max(tokens.size, other.tokens.size)) {
|
||||
val ai = tokens.getOrNull(i)
|
||||
val bi = other.tokens.getOrNull(i)
|
||||
when {
|
||||
ai != null && bi != null -> {
|
||||
val result = ai.compareTo(bi)
|
||||
if (result != 0) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
ai == null && bi != null -> return -1 // a < b
|
||||
ai == null && bi == null -> return 0 // a = b
|
||||
else -> return 1 // a < b
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun toString() = StringBuilder().appendDate().toString()
|
||||
|
||||
private fun StringBuilder.appendDate(): StringBuilder {
|
||||
// Construct an ISO-8601 date, dropping precision that doesn't exist.
|
||||
append(year.toStringFixed(4))
|
||||
append("-${(month ?: return this).toStringFixed(2)}")
|
||||
append("-${(day ?: return this).toStringFixed(2)}")
|
||||
append("T${(hour ?: return this).toStringFixed(2)}")
|
||||
append(":${(minute ?: return this.append('Z')).toStringFixed(2)}")
|
||||
append(":${(second ?: return this.append('Z')).toStringFixed(2)}")
|
||||
return this.append('Z')
|
||||
}
|
||||
|
||||
private fun Int.toStringFixed(len: Int) = toString().padStart(len, '0').substring(0 until len)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A [Regex] that can parse a variable-precision ISO-8601 timestamp. Derived from
|
||||
* https://github.com/quodlibet/mutagen
|
||||
*/
|
||||
private val ISO8601_REGEX =
|
||||
Regex(
|
||||
"""^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""")
|
||||
|
||||
/**
|
||||
* Create a [Date] from a year component.
|
||||
* @param year The year component.
|
||||
* @return A new [Date] of the given component, or null if the component is invalid.
|
||||
*/
|
||||
fun from(year: Int) = fromTokens(listOf(year))
|
||||
|
||||
/**
|
||||
* Create a [Date] from a date component.
|
||||
* @param year The year component.
|
||||
* @param month The month component.
|
||||
* @param day The day component.
|
||||
* @return A new [Date] consisting of the given components. May have reduced precision if
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
*/
|
||||
fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day))
|
||||
|
||||
/**
|
||||
* Create [Date] from a datetime component.
|
||||
* @param year The year component.
|
||||
* @param month The month component.
|
||||
* @param day The day component.
|
||||
* @param hour The hour component
|
||||
* @return A new [Date] consisting of the given components. May have reduced precision if
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
*/
|
||||
fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) =
|
||||
fromTokens(listOf(year, month, day, hour, minute))
|
||||
|
||||
/**
|
||||
* Create a [Date] from a [String] timestamp.
|
||||
* @param timestamp The ISO-8601 timestamp to parse. Can have reduced precision.
|
||||
* @return A new [Date] consisting of the given components. May have reduced precision if
|
||||
* the components were partially invalid, and will be null if all components are invalid or
|
||||
* if the timestamp is invalid.
|
||||
*/
|
||||
fun from(timestamp: String): Date? {
|
||||
val tokens =
|
||||
// Match the input with the timestamp regex
|
||||
(ISO8601_REGEX.matchEntire(timestamp) ?: return null)
|
||||
.groupValues
|
||||
// Filter to the specific tokens we want and convert them to integer tokens.
|
||||
.mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null }
|
||||
return fromTokens(tokens)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a [Date] from the given non-validated tokens.
|
||||
* @param tokens The tokens to use for each date component, in order of precision.
|
||||
* @return A new [Date] consisting of the given components. May have reduced precision if
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
*/
|
||||
private fun fromTokens(tokens: List<Int>): Date? {
|
||||
val validated = mutableListOf<Int>()
|
||||
validateTokens(tokens, validated)
|
||||
if (validated.isEmpty()) {
|
||||
// No token was valid, return null.
|
||||
return null
|
||||
}
|
||||
return Date(validated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a list of tokens provided by [src], and add the valid ones to [dst]. Will stop
|
||||
* as soon as an invalid token is found.
|
||||
* @param src The input tokens to validate.
|
||||
* @param dst The destination list to add valid tokens to.
|
||||
*/
|
||||
private fun validateTokens(src: List<Int>, dst: MutableList<Int>) {
|
||||
dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return)
|
||||
dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
|
||||
dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return)
|
||||
dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return)
|
||||
dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return)
|
||||
dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MUSIC UID CREATION UTILITIES ---
|
||||
|
||||
/**
|
||||
* Convert a [String] to a [UUID].
|
||||
* @return A [UUID] converted from the [String] value, or null if the value was not valid.
|
||||
* @see UUID.fromString
|
||||
*/
|
||||
fun String.toUuidOrNull(): UUID? =
|
||||
try {
|
||||
UUID.fromString(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a [MessageDigest] with a lowercase [String].
|
||||
* @param string The [String] to hash. If null, it will not be hashed.
|
||||
|
|
|
@ -20,8 +20,8 @@ package org.oxycblt.auxio.music
|
|||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.storage.useQuery
|
||||
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.filesystem.useQuery
|
||||
|
||||
/**
|
||||
* A repository granting access to the music library..
|
||||
|
@ -33,42 +33,43 @@ import org.oxycblt.auxio.music.storage.useQuery
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MusicStore private constructor() {
|
||||
private val callbacks = mutableListOf<Callback>()
|
||||
private val listeners = mutableListOf<Listener>()
|
||||
|
||||
/**
|
||||
* The current [Library]. May be null if a [Library] has not been successfully loaded yet. This
|
||||
* can change, so it's highly recommended to not access this directly and instead rely on
|
||||
* [Callback].
|
||||
* [Listener].
|
||||
*/
|
||||
@Volatile
|
||||
var library: Library? = null
|
||||
set(value) {
|
||||
field = value
|
||||
for (callback in callbacks) {
|
||||
for (callback in listeners) {
|
||||
callback.onLibraryChanged(library)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a [Callback] to this instance. This can be used to receive changes in the music library.
|
||||
* Will invoke all [Callback] methods to initialize the instance with the current state.
|
||||
* @param callback The [Callback] to add.
|
||||
* @see Callback
|
||||
* Add a [Listener] to this instance. This can be used to receive changes in the music library.
|
||||
* Will invoke all [Listener] methods to initialize the instance with the current state.
|
||||
* @param listener The [Listener] to add.
|
||||
* @see Listener
|
||||
*/
|
||||
@Synchronized
|
||||
fun addCallback(callback: Callback) {
|
||||
callback.onLibraryChanged(library)
|
||||
callbacks.add(callback)
|
||||
fun addListener(listener: Listener) {
|
||||
listener.onLibraryChanged(library)
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a [Callback] from this instance, preventing it from recieving any further updates.
|
||||
* @param callback The [Callback] to remove. Does nothing if the [Callback] was never added in
|
||||
* Remove a [Listener] from this instance, preventing it from recieving any further updates.
|
||||
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
|
||||
* the first place.
|
||||
* @see Callback
|
||||
* @see Listener
|
||||
*/
|
||||
@Synchronized
|
||||
fun removeCallback(callback: Callback) {
|
||||
callbacks.remove(callback)
|
||||
fun removeListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -167,7 +168,7 @@ class MusicStore private constructor() {
|
|||
}
|
||||
|
||||
/** A listener for changes in the music library. */
|
||||
interface Callback {
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the current [Library] has changed.
|
||||
* @param library The new [Library], or null if no [Library] has been loaded yet.
|
||||
|
|
|
@ -26,7 +26,7 @@ import org.oxycblt.auxio.music.system.Indexer
|
|||
* A [ViewModel] providing data specific to the music loading process.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MusicViewModel : ViewModel(), Indexer.Callback {
|
||||
class MusicViewModel : ViewModel(), Indexer.Listener {
|
||||
private val indexer = Indexer.getInstance()
|
||||
|
||||
private val _indexerState = MutableStateFlow<Indexer.State?>(null)
|
||||
|
@ -39,18 +39,18 @@ class MusicViewModel : ViewModel(), Indexer.Callback {
|
|||
get() = _statistics
|
||||
|
||||
init {
|
||||
indexer.registerCallback(this)
|
||||
indexer.registerListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
indexer.unregisterCallback(this)
|
||||
indexer.unregisterListener(this)
|
||||
}
|
||||
|
||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
||||
_indexerState.value = state
|
||||
if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) {
|
||||
if (state is Indexer.State.Complete) {
|
||||
// New state is a completed library, update the statistics values.
|
||||
val library = state.response.library
|
||||
val library = state.result.getOrNull() ?: return
|
||||
_statistics.value =
|
||||
Statistics(
|
||||
library.songs.size,
|
||||
|
|
|
@ -232,7 +232,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
|||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
||||
compareByDescending(NullableComparator.DATE) { it.album.date },
|
||||
compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates },
|
||||
compareByDescending(BasicComparator.ALBUM) { it.album },
|
||||
compareBy(NullableComparator.INT) { it.disc },
|
||||
compareBy(NullableComparator.INT) { it.track },
|
||||
|
@ -241,14 +241,14 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
|||
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
||||
compareByDescending(NullableComparator.DATE) { it.date },
|
||||
compareByDescending(NullableComparator.DATE_RANGE) { it.dates },
|
||||
compareBy(BasicComparator.ALBUM))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by the [Date] of an item. Only available for [Song] and [Album].
|
||||
* @see Song.date
|
||||
* @see Album.date
|
||||
* @see Album.dates
|
||||
*/
|
||||
object ByDate : Mode() {
|
||||
override val intCode: Int
|
||||
|
@ -259,7 +259,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
|||
|
||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, NullableComparator.DATE) { it.album.date },
|
||||
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates },
|
||||
compareByDescending(BasicComparator.ALBUM) { it.album },
|
||||
compareBy(NullableComparator.INT) { it.disc },
|
||||
compareBy(NullableComparator.INT) { it.track },
|
||||
|
@ -267,7 +267,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
|||
|
||||
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, NullableComparator.DATE) { it.date },
|
||||
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.dates },
|
||||
compareBy(BasicComparator.ALBUM))
|
||||
}
|
||||
|
||||
|
@ -366,7 +366,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
|||
/**
|
||||
* Sort by the date an item was added. Only supported by [Song]s and [Album]s.
|
||||
* @see Song.dateAdded
|
||||
* @see Album.date
|
||||
* @see Album.dates
|
||||
*/
|
||||
object ByDateAdded : Mode() {
|
||||
override val intCode: Int
|
||||
|
@ -543,8 +543,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
|||
val INT = NullableComparator<Int>()
|
||||
/** A re-usable instance configured for [Long]s. */
|
||||
val LONG = NullableComparator<Long>()
|
||||
/** A re-usable instance configured for [Date]s. */
|
||||
val DATE = NullableComparator<Date>()
|
||||
/** A re-usable instance configured for [Date.Range]s. */
|
||||
val DATE_RANGE = NullableComparator<Date.Range>()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,10 @@ import android.database.sqlite.SQLiteDatabase
|
|||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import androidx.core.database.getIntOrNull
|
||||
import androidx.core.database.getStringOrNull
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.parsing.correctWhitespace
|
||||
import org.oxycblt.auxio.music.parsing.splitEscaped
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
/**
|
||||
|
@ -278,7 +281,7 @@ private class CacheDatabase(context: Context) :
|
|||
|
||||
raw.track = cursor.getIntOrNull(trackIndex)
|
||||
raw.disc = cursor.getIntOrNull(discIndex)
|
||||
raw.date = cursor.getStringOrNull(dateIndex)?.parseTimestamp()
|
||||
raw.date = cursor.getStringOrNull(dateIndex)?.let(Date::from)
|
||||
|
||||
raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
|
||||
raw.albumName = cursor.getString(albumNameIndex)
|
||||
|
@ -387,8 +390,7 @@ private class CacheDatabase(context: Context) :
|
|||
* @return A list of strings corresponding to the delimited values present within the original
|
||||
* string. Escaped delimiters are converted back into their normal forms.
|
||||
*/
|
||||
private fun String.parseSQLMultiValue() =
|
||||
splitEscaped { it == ';' }
|
||||
private fun String.parseSQLMultiValue() = splitEscaped { it == ';' }.correctWhitespace()
|
||||
|
||||
/** Defines the columns used in this database. */
|
||||
private object Columns {
|
||||
|
|
|
@ -24,10 +24,8 @@ package org.oxycblt.auxio.music.extractor
|
|||
enum class ExtractionResult {
|
||||
/** A raw song was successfully extracted from the cache. */
|
||||
CACHED,
|
||||
|
||||
/** A raw song was successfully extracted from parsing it's file. */
|
||||
PARSED,
|
||||
|
||||
/** A raw song could not be parsed. */
|
||||
NONE
|
||||
}
|
||||
|
|
|
@ -27,17 +27,20 @@ import androidx.annotation.RequiresApi
|
|||
import androidx.core.database.getIntOrNull
|
||||
import androidx.core.database.getStringOrNull
|
||||
import java.io.File
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.storage.Directory
|
||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.storage.directoryCompat
|
||||
import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
|
||||
import org.oxycblt.auxio.music.storage.safeQuery
|
||||
import org.oxycblt.auxio.music.storage.storageVolumesCompat
|
||||
import org.oxycblt.auxio.music.storage.useQuery
|
||||
import org.oxycblt.auxio.music.filesystem.Directory
|
||||
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.filesystem.directoryCompat
|
||||
import org.oxycblt.auxio.music.filesystem.mediaStoreVolumeNameCompat
|
||||
import org.oxycblt.auxio.music.filesystem.safeQuery
|
||||
import org.oxycblt.auxio.music.filesystem.storageVolumesCompat
|
||||
import org.oxycblt.auxio.music.filesystem.useQuery
|
||||
import org.oxycblt.auxio.music.parsing.parseId3v2Position
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
|
||||
/**
|
||||
* The layer that loads music from the [MediaStore] database. This is an intermediate step in the
|
||||
|
@ -302,7 +305,7 @@ abstract class MediaStoreExtractor(
|
|||
// MediaStore only exposes the year value of a file. This is actually worse than it
|
||||
// seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments.
|
||||
// This is one of the major weaknesses of using MediaStore, hence the redundancy layers.
|
||||
raw.date = cursor.getIntOrNull(yearIndex)?.toDate()
|
||||
raw.date = cursor.getIntOrNull(yearIndex)?.let(Date::from)
|
||||
// A non-existent album name should theoretically be the name of the folder it contained
|
||||
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
|
||||
// file is not actually in the root internal storage directory. We can't do anything to
|
||||
|
@ -322,12 +325,12 @@ abstract class MediaStoreExtractor(
|
|||
genreNamesMap[raw.mediaStoreId]?.let { raw.genreNames = listOf(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
/**
|
||||
* The base selector that works across all versions of android. Does not exclude
|
||||
* directories.
|
||||
*/
|
||||
private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
|
||||
const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
|
||||
|
||||
/**
|
||||
* The album artist of a song. This column has existed since at least API 21, but until API
|
||||
|
@ -335,13 +338,13 @@ abstract class MediaStoreExtractor(
|
|||
* versions that Auxio supports.
|
||||
*/
|
||||
@Suppress("InlinedApi")
|
||||
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
|
||||
const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
|
||||
|
||||
/**
|
||||
* The external volume. This naming has existed since API 21, but no constant existed for it
|
||||
* until API 29. This will work on all versions that Auxio supports.
|
||||
*/
|
||||
@Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
|
||||
@Suppress("InlinedApi") const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -561,7 +564,24 @@ class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor)
|
|||
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
|
||||
// N is the number and T is the total. Parse the number while ignoring the
|
||||
// total, as we have no use for it.
|
||||
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it }
|
||||
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it }
|
||||
cursor.getStringOrNull(trackIndex)?.parseId3v2Position()?.let { raw.track = it }
|
||||
cursor.getStringOrNull(discIndex)?.parseId3v2Position()?.let { raw.disc = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack the track number from a combined track + disc [Int] field. These fields appear within
|
||||
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
||||
* disc number is the 4th+ digit.
|
||||
* @return The track number extracted from the combined integer value, or null if the value was
|
||||
* zero.
|
||||
*/
|
||||
private fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within
|
||||
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
||||
* disc number is the 4th+ digit.
|
||||
* @return The disc number extracted from the combined integer field, or null if the value was zero.
|
||||
*/
|
||||
private fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
|
||||
|
|
|
@ -21,12 +21,10 @@ import android.content.Context
|
|||
import androidx.core.text.isDigitsOnly
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MetadataRetriever
|
||||
import com.google.android.exoplayer2.metadata.Metadata
|
||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.storage.toAudioUri
|
||||
import org.oxycblt.auxio.music.filesystem.toAudioUri
|
||||
import org.oxycblt.auxio.music.parsing.parseId3v2Position
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
|
@ -116,8 +114,8 @@ class MetadataExtractor(
|
|||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TASK_CAPACITY = 8
|
||||
private companion object {
|
||||
const val TASK_CAPACITY = 8
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -128,7 +126,6 @@ class MetadataExtractor(
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Task(context: Context, private val raw: Song.Raw) {
|
||||
// TODO: Unify with MetadataExtractor
|
||||
// Note that we do not leverage future callbacks. This is because errors in the
|
||||
// (highly fallible) extraction process will not bubble up to Indexer when a
|
||||
// listener is used, instead crashing the app entirely.
|
||||
|
@ -144,6 +141,7 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
*/
|
||||
fun get(): Song.Raw? {
|
||||
if (!future.isDone) {
|
||||
// Not done yet, nothing to do.
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -162,7 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
|
||||
val metadata = format.metadata
|
||||
if (metadata != null) {
|
||||
populateWithMetadata(metadata)
|
||||
val tags = Tags(metadata)
|
||||
populateWithId3v2(tags.id3v2)
|
||||
populateWithVorbis(tags.vorbis)
|
||||
} else {
|
||||
logD("No metadata could be extracted for ${raw.name}")
|
||||
}
|
||||
|
@ -170,51 +170,6 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete this instance's [Song.Raw] with the newly extracted [Metadata].
|
||||
* @param metadata The [Metadata] to complete the [Song.Raw] with.
|
||||
*/
|
||||
private fun populateWithMetadata(metadata: Metadata) {
|
||||
val id3v2Tags = mutableMapOf<String, List<String>>()
|
||||
val vorbisTags = mutableMapOf<String, MutableList<String>>()
|
||||
|
||||
// ExoPlayer only exposes ID3v2 and Vorbis metadata, which constitutes the vast majority
|
||||
// of audio formats. Load both of these types of tags into separate maps, letting the
|
||||
// "source of truth" be the last of a particular tag in a file.
|
||||
for (i in 0 until metadata.length()) {
|
||||
when (val tag = metadata[i]) {
|
||||
is TextInformationFrame -> {
|
||||
// Map TXXX frames differently so we can specifically index by their
|
||||
// descriptions.
|
||||
val id = tag.description?.let { "TXXX:${it.sanitize()}" } ?: tag.id.sanitize()
|
||||
val values = tag.values.map { it.sanitize() }.filter { it.isNotEmpty() }
|
||||
if (values.isNotEmpty()) {
|
||||
id3v2Tags[id] = values
|
||||
}
|
||||
}
|
||||
is VorbisComment -> {
|
||||
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
|
||||
val id = tag.key.sanitize().uppercase()
|
||||
val value = tag.value.sanitize()
|
||||
if (value.isNotEmpty()) {
|
||||
vorbisTags.getOrPut(id) { mutableListOf() }.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
vorbisTags.isEmpty() -> populateWithId3v2(id3v2Tags)
|
||||
id3v2Tags.isEmpty() -> populateWithVorbis(vorbisTags)
|
||||
else -> {
|
||||
// Some formats (like FLAC) can contain both ID3v2 and Vorbis, so we apply
|
||||
// them both with priority given to vorbis.
|
||||
populateWithId3v2(id3v2Tags)
|
||||
populateWithVorbis(vorbisTags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete this instance's [Song.Raw] with ID3v2 Text Identification Frames.
|
||||
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
|
||||
|
@ -222,15 +177,15 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
*/
|
||||
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
|
||||
// Song
|
||||
textFrames["TXXX:MusicBrainz Release Track Id"]?.let { raw.musicBrainzId = it[0] }
|
||||
textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it[0] }
|
||||
textFrames["TIT2"]?.let { raw.name = it[0] }
|
||||
textFrames["TSOT"]?.let { raw.sortName = it[0] }
|
||||
|
||||
// Track. Only parse out the track number and ignore the total tracks value.
|
||||
textFrames["TRCK"]?.run { get(0).parsePositionNum() }?.let { raw.track = it }
|
||||
textFrames["TRCK"]?.run { first().parseId3v2Position() }?.let { raw.track = it }
|
||||
|
||||
// Disc. Only parse out the disc number and ignore the total discs value.
|
||||
textFrames["TPOS"]?.run { get(0).parsePositionNum() }?.let { raw.disc = it }
|
||||
textFrames["TPOS"]?.run { first().parseId3v2Position() }?.let { raw.disc = it }
|
||||
|
||||
// Dates are somewhat complicated, as not only did their semantics change from a flat year
|
||||
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
|
||||
|
@ -241,27 +196,27 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
// 3. ID3v2.4 Release Date, as it is the second most common date type
|
||||
// 4. ID3v2.3 Original Date, as it is like #1
|
||||
// 5. ID3v2.3 Release Year, as it is the most common date type
|
||||
(textFrames["TDOR"]?.run { get(0).parseTimestamp() }
|
||||
?: textFrames["TDRC"]?.run { get(0).parseTimestamp() }
|
||||
?: textFrames["TDRL"]?.run { get(0).parseTimestamp() }
|
||||
(textFrames["TDOR"]?.run { Date.from(first()) }
|
||||
?: textFrames["TDRC"]?.run { Date.from(first()) }
|
||||
?: textFrames["TDRL"]?.run { Date.from(first()) }
|
||||
?: parseId3v23Date(textFrames))
|
||||
?.let { raw.date = it }
|
||||
|
||||
// Album
|
||||
textFrames["TXXX:MusicBrainz Album Id"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||
textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||
textFrames["TALB"]?.let { raw.albumName = it[0] }
|
||||
textFrames["TSOA"]?.let { raw.albumSortName = it[0] }
|
||||
(textFrames["TXXX:MusicBrainz Album Type"] ?: textFrames["GRP1"])?.let {
|
||||
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
|
||||
raw.albumTypes = it
|
||||
}
|
||||
|
||||
// Artist
|
||||
textFrames["TXXX:MusicBrainz Artist Id"]?.let { raw.artistMusicBrainzIds = it }
|
||||
textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it }
|
||||
textFrames["TPE1"]?.let { raw.artistNames = it }
|
||||
textFrames["TSOP"]?.let { raw.artistSortNames = it }
|
||||
|
||||
// Album artist
|
||||
textFrames["TXXX:MusicBrainz Album Artist Id"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||
textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||
textFrames["TPE2"]?.let { raw.albumArtistNames = it }
|
||||
textFrames["TSO2"]?.let { raw.albumArtistSortNames = it }
|
||||
|
||||
|
@ -282,8 +237,8 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
||||
// is present.
|
||||
val year =
|
||||
textFrames["TORY"]?.run { get(0).toIntOrNull() }
|
||||
?: textFrames["TYER"]?.run { get(0).toIntOrNull() } ?: return null
|
||||
textFrames["TORY"]?.run { first().toIntOrNull() }
|
||||
?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
|
||||
|
||||
val tdat = textFrames["TDAT"]
|
||||
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) {
|
||||
|
@ -317,17 +272,17 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
*/
|
||||
private fun populateWithVorbis(comments: Map<String, List<String>>) {
|
||||
// Song
|
||||
comments["MUSICBRAINZ_RELEASETRACKID"]?.let { raw.musicBrainzId = it[0] }
|
||||
comments["TITLE"]?.let { raw.name = it[0] }
|
||||
comments["TITLESORT"]?.let { raw.sortName = it[0] }
|
||||
comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] }
|
||||
comments["title"]?.let { raw.name = it[0] }
|
||||
comments["titlesort"]?.let { raw.sortName = it[0] }
|
||||
|
||||
// Track. The total tracks value is in a different comment, so we can just
|
||||
// convert the entirety of this comment into a number.
|
||||
comments["TRACKNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.track = it }
|
||||
comments["tracknumber"]?.run { first().toIntOrNull() }?.let { raw.track = it }
|
||||
|
||||
// Disc. The total discs value is in a different comment, so we can just
|
||||
// convert the entirety of this comment into a number.
|
||||
comments["DISCNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.disc = it }
|
||||
comments["discnumber"]?.run { first().toIntOrNull() }?.let { raw.disc = it }
|
||||
|
||||
// Vorbis dates are less complicated, but there are still several types
|
||||
// Our hierarchy for dates is as such:
|
||||
|
@ -335,35 +290,28 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
// 2. Date, as it is the most common date type
|
||||
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
|
||||
// date tag that android supports, so it must be 15 years old or more!)
|
||||
(comments["ORIGINALDATE"]?.run { get(0).parseTimestamp() }
|
||||
?: comments["DATE"]?.run { get(0).parseTimestamp() }
|
||||
?: comments["YEAR"]?.run { get(0).parseYear() })
|
||||
(comments["originaldate"]?.run { Date.from(first()) }
|
||||
?: comments["date"]?.run { Date.from(first()) }
|
||||
?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) })
|
||||
?.let { raw.date = it }
|
||||
|
||||
// Album
|
||||
comments["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||
comments["ALBUM"]?.let { raw.albumName = it[0] }
|
||||
comments["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
|
||||
comments["RELEASETYPE"]?.let { raw.albumTypes = it }
|
||||
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||
comments["album"]?.let { raw.albumName = it[0] }
|
||||
comments["albumsort"]?.let { raw.albumSortName = it[0] }
|
||||
comments["releasetype"]?.let { raw.albumTypes = it }
|
||||
|
||||
// Artist
|
||||
comments["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it }
|
||||
comments["ARTIST"]?.let { raw.artistNames = it }
|
||||
comments["ARTISTSORT"]?.let { raw.artistSortNames = it }
|
||||
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
|
||||
comments["artist"]?.let { raw.artistNames = it }
|
||||
comments["artistsort"]?.let { raw.artistSortNames = it }
|
||||
|
||||
// Album artist
|
||||
comments["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||
comments["ALBUMARTIST"]?.let { raw.albumArtistNames = it }
|
||||
comments["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it }
|
||||
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||
comments["albumartist"]?.let { raw.albumArtistNames = it }
|
||||
comments["albumartistsort"]?.let { raw.albumArtistSortNames = it }
|
||||
|
||||
// Genre
|
||||
comments["GENRE"]?.let { raw.genreNames = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies and sanitizes a possibly native/non-UTF-8 string.
|
||||
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
|
||||
* the Unicode replacement byte sequence.
|
||||
*/
|
||||
private fun String.sanitize() = String(encodeToByteArray())
|
||||
}
|
||||
|
|
83
app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt
Normal file
83
app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 com.google.android.exoplayer2.metadata.Metadata
|
||||
import com.google.android.exoplayer2.metadata.id3.InternalFrame
|
||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||
import org.oxycblt.auxio.music.parsing.correctWhitespace
|
||||
|
||||
/**
|
||||
* Processing wrapper for [Metadata] that allows access to more organized music tags.
|
||||
* @param metadata The [Metadata] to wrap.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Tags(metadata: Metadata) {
|
||||
private val _id3v2 = mutableMapOf<String, List<String>>()
|
||||
/** The ID3v2 text identification frames found in the file. Can have more than one value. */
|
||||
val id3v2: Map<String, List<String>>
|
||||
get() = _id3v2
|
||||
|
||||
private val _vorbis = mutableMapOf<String, MutableList<String>>()
|
||||
/** The vorbis comments found in the file. Can have more than one value. */
|
||||
val vorbis: Map<String, List<String>>
|
||||
get() = _vorbis
|
||||
|
||||
init {
|
||||
for (i in 0 until metadata.length()) {
|
||||
when (val tag = metadata[i]) {
|
||||
is TextInformationFrame -> {
|
||||
// Map TXXX frames differently so we can specifically index by their
|
||||
// descriptions.
|
||||
val id =
|
||||
tag.description?.let { "TXXX:${it.sanitize().lowercase()}" }
|
||||
?: tag.id.sanitize()
|
||||
val values = tag.values.map { it.sanitize() }.correctWhitespace()
|
||||
if (values.isNotEmpty()) {
|
||||
_id3v2[id] = values
|
||||
}
|
||||
}
|
||||
is InternalFrame -> {
|
||||
// Most MP4 metadata atoms map to ID3v2 text frames, except for the ---- atom,
|
||||
// which has it's own frame. Map this to TXXX, it's rough ID3v2 equivalent.
|
||||
val id = "TXXX:${tag.description.sanitize().lowercase()}"
|
||||
val value = tag.text
|
||||
if (value.isNotEmpty()) {
|
||||
_id3v2[id] = listOf(value)
|
||||
}
|
||||
}
|
||||
is VorbisComment -> {
|
||||
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
|
||||
val id = tag.key.sanitize().lowercase()
|
||||
val value = tag.value.sanitize().correctWhitespace()
|
||||
if (value != null) {
|
||||
_vorbis.getOrPut(id) { mutableListOf() }.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies and sanitizes a possibly invalid string outputted from ExoPlayer.
|
||||
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
|
||||
* the Unicode replacement byte sequence.
|
||||
*/
|
||||
private fun String.sanitize() = String(encodeToByteArray())
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.storage
|
||||
package org.oxycblt.auxio.music.filesystem
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -41,7 +41,7 @@ class DirectoryAdapter(private val listener: Listener) :
|
|||
override fun getItemCount() = dirs.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
MusicDirViewHolder.new(parent)
|
||||
MusicDirViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
|
||||
holder.bind(dirs[position], listener)
|
||||
|
@ -86,7 +86,7 @@ class DirectoryAdapter(private val listener: Listener) :
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Recycler] that displays a [Directory]. Use [new] to create an instance.
|
||||
* A [RecyclerView.Recycler] that displays a [Directory]. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
|
||||
|
@ -107,7 +107,7 @@ class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBi
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
|
@ -15,15 +15,13 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.storage
|
||||
package org.oxycblt.auxio.music.filesystem
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.os.storage.StorageManager
|
||||
import android.os.storage.StorageVolume
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import java.io.File
|
||||
import org.oxycblt.auxio.R
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.storage
|
||||
package org.oxycblt.auxio.music.filesystem
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
|
@ -15,13 +15,14 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.storage
|
||||
package org.oxycblt.auxio.music.filesystem
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract
|
||||
import android.view.LayoutInflater
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
|
@ -30,7 +31,6 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
@ -42,10 +42,8 @@ import org.oxycblt.auxio.util.showToast
|
|||
class MusicDirsDialog :
|
||||
ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
||||
private val dirAdapter = DirectoryAdapter(this)
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
private val storageManager: StorageManager by lifecycleObject { binding ->
|
||||
binding.context.getSystemServiceCompat(StorageManager::class)
|
||||
}
|
||||
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
|
||||
private var storageManager: StorageManager? = null
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
DialogMusicDirsBinding.inflate(inflater)
|
||||
|
@ -57,7 +55,10 @@ class MusicDirsDialog :
|
|||
.setNeutralButton(R.string.lbl_add, null)
|
||||
.setNegativeButton(R.string.lbl_cancel, null)
|
||||
.setPositiveButton(R.string.lbl_save) { _, _ ->
|
||||
val dirs = settings.getMusicDirs(storageManager)
|
||||
val settings = Settings(requireContext())
|
||||
val dirs =
|
||||
settings.getMusicDirs(
|
||||
requireNotNull(storageManager) { "StorageManager was not available" })
|
||||
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
|
||||
if (dirs != newDirs) {
|
||||
logD("Committing changes")
|
||||
|
@ -67,7 +68,11 @@ class MusicDirsDialog :
|
|||
}
|
||||
|
||||
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
|
||||
val launcher =
|
||||
val context = requireContext()
|
||||
val storageManager =
|
||||
context.getSystemServiceCompat(StorageManager::class).also { storageManager = it }
|
||||
|
||||
openDocumentTreeLauncher =
|
||||
registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
|
||||
|
||||
|
@ -79,7 +84,10 @@ class MusicDirsDialog :
|
|||
val dialog = it as AlertDialog
|
||||
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
|
||||
logD("Opening launcher")
|
||||
launcher.launch(null)
|
||||
requireNotNull(openDocumentTreeLauncher) {
|
||||
"Document tree launcher was not available"
|
||||
}
|
||||
.launch(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -88,7 +96,7 @@ class MusicDirsDialog :
|
|||
itemAnimator = null
|
||||
}
|
||||
|
||||
var dirs = settings.getMusicDirs(storageManager)
|
||||
var dirs = Settings(context).getMusicDirs(storageManager)
|
||||
|
||||
if (savedInstanceState != null) {
|
||||
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
|
||||
|
@ -127,6 +135,8 @@ class MusicDirsDialog :
|
|||
|
||||
override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
storageManager = null
|
||||
openDocumentTreeLauncher = null
|
||||
binding.dirsRecycler.adapter = null
|
||||
}
|
||||
|
||||
|
@ -153,7 +163,9 @@ class MusicDirsDialog :
|
|||
DocumentsContract.buildDocumentUriUsingTree(
|
||||
uri, DocumentsContract.getTreeDocumentId(uri))
|
||||
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
|
||||
val dir = Directory.fromDocumentTreeUri(storageManager, treeUri)
|
||||
val dir =
|
||||
Directory.fromDocumentTreeUri(
|
||||
requireNotNull(storageManager) { "StorageManager was not available" }, treeUri)
|
||||
|
||||
if (dir != null) {
|
||||
dirAdapter.add(dir)
|
||||
|
@ -176,7 +188,7 @@ class MusicDirsDialog :
|
|||
private fun isUiModeInclude(binding: DialogMusicDirsBinding) =
|
||||
binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
const val KEY_PENDING_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS"
|
||||
const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE"
|
||||
}
|
|
@ -15,61 +15,27 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.extractor
|
||||
package org.oxycblt.auxio.music.parsing
|
||||
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import java.util.UUID
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
|
||||
/**
|
||||
* Unpack the track number from a combined track + disc [Int] field. These fields appear within
|
||||
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
||||
* disc number is the 4th+ digit.
|
||||
* @return The track number extracted from the combined integer value, or null if the value was
|
||||
* zero.
|
||||
*/
|
||||
fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
|
||||
/// --- GENERIC PARSING ---
|
||||
|
||||
/**
|
||||
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within
|
||||
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
||||
* disc number is the 4th+ digit.
|
||||
* @return The disc number extracted from the combined integer field, or null if the value was zero.
|
||||
* Parse a multi-value tag based on the user configuration. If the value is already composed of more
|
||||
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the
|
||||
* user's separator preferences.
|
||||
* @param settings [Settings] required to obtain user separator configuration.
|
||||
* @return A new list of one or more [String]s.
|
||||
*/
|
||||
fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Parse the number out of a combined number + total position [String] field. These fields often
|
||||
* appear in ID3v2 files, and consist of a number and an (optional) total value delimited by a /.
|
||||
* @return The number value extracted from the string field, or null if the value could not be
|
||||
* parsed or if the value was zero.
|
||||
*/
|
||||
fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Transform an [Int] year field into a [Date].
|
||||
* @return A [Date] consisting of the year value, or null if the value was zero.
|
||||
* @see Date.from
|
||||
*/
|
||||
fun Int.toDate() = Date.from(this)
|
||||
|
||||
/**
|
||||
* Parse an integer year field from a [String] and transform it into a [Date].
|
||||
* @return A [Date] consisting of the year value, or null if the value could not be parsed or if the
|
||||
* value was zero.
|
||||
* @see Date.from
|
||||
*/
|
||||
fun String.parseYear() = toIntOrNull()?.toDate()
|
||||
|
||||
/**
|
||||
* Parse an ISO-8601 timestamp [String] into a [Date].
|
||||
* @return A [Date] consisting of the year value plus one or more refinement values (ex. month,
|
||||
* day), or null if the timestamp was not valid.
|
||||
*/
|
||||
fun String.parseTimestamp() = Date.from(this)
|
||||
fun List<String>.parseMultiValue(settings: Settings) =
|
||||
if (size == 1) {
|
||||
first().maybeParseBySeparators(settings)
|
||||
} else {
|
||||
// Nothing to do.
|
||||
this
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a [String] by the given selector, automatically handling escaped characters that satisfy
|
||||
|
@ -116,42 +82,38 @@ inline fun String.splitEscaped(selector: (Char) -> Boolean): List<String> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Parse a multi-value tag based on the user configuration. If the value is already composed of more
|
||||
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the
|
||||
* user's separator preferences.
|
||||
* @param settings [Settings] required to obtain user separator configuration.
|
||||
* @return A new list of one or more [String]s.
|
||||
* Fix trailing whitespace or blank contents in a [String].
|
||||
* @return A string with trailing whitespace remove,d or null if the [String] was all whitespace or
|
||||
* empty.
|
||||
*/
|
||||
fun List<String>.parseMultiValue(settings: Settings) =
|
||||
if (size == 1) {
|
||||
get(0).maybeParseSeparators(settings)
|
||||
} else {
|
||||
// Nothing to do.
|
||||
this.map { it.trim() }
|
||||
}
|
||||
fun String.correctWhitespace() = trim().ifBlank { null }
|
||||
|
||||
/**
|
||||
* Fix trailing whitespace or blank contents within a list of [String]s.
|
||||
* @return A list of non-blank strings with trailing whitespace removed.
|
||||
*/
|
||||
fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
|
||||
|
||||
/**
|
||||
* Attempt to parse a string by the user's separator preferences.
|
||||
* @param settings [Settings] required to obtain user separator configuration.
|
||||
* @return A list of one or more [String]s that were split up by the user-defined separators.
|
||||
*/
|
||||
fun String.maybeParseSeparators(settings: Settings): List<String> {
|
||||
private fun String.maybeParseBySeparators(settings: Settings): List<String> {
|
||||
// Get the separators the user desires. If null, there's nothing to do.
|
||||
val separators = settings.musicSeparators ?: return listOf(this)
|
||||
return splitEscaped { separators.contains(it) }.map { it.trim() }
|
||||
return splitEscaped { separators.contains(it) }.correctWhitespace()
|
||||
}
|
||||
|
||||
/// --- ID3v2 PARSING ---
|
||||
|
||||
/**
|
||||
* Convert a [String] to a [UUID].
|
||||
* @return A [UUID] converted from the [String] value, or null if the value was not valid.
|
||||
* @see UUID.fromString
|
||||
* Parse the number out of a ID3v2-style number + total position [String] field. These fields
|
||||
* consist of a number and an (optional) total value delimited by a /.
|
||||
* @return The number value extracted from the string field, or null if the value could not be
|
||||
* parsed or if the value was zero.
|
||||
*/
|
||||
fun String.toUuidOrNull(): UUID? =
|
||||
try {
|
||||
UUID.fromString(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer
|
||||
|
@ -162,7 +124,7 @@ fun String.toUuidOrNull(): UUID? =
|
|||
*/
|
||||
fun List<String>.parseId3GenreNames(settings: Settings) =
|
||||
if (size == 1) {
|
||||
get(0).parseId3GenreNames(settings)
|
||||
first().parseId3MultiValueGenre(settings)
|
||||
} else {
|
||||
// Nothing to split, just map any ID3v1 genres to their name counterparts.
|
||||
map { it.parseId3v1Genre() ?: it }
|
||||
|
@ -172,8 +134,8 @@ fun List<String>.parseId3GenreNames(settings: Settings) =
|
|||
* Parse a single ID3v1/ID3v2 integer genre field into their named representations.
|
||||
* @return A list of one or more genre names.
|
||||
*/
|
||||
fun String.parseId3GenreNames(settings: Settings) =
|
||||
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseSeparators(settings)
|
||||
private fun String.parseId3MultiValueGenre(settings: Settings) =
|
||||
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings)
|
||||
|
||||
/**
|
||||
* Parse an ID3v1 integer genre field.
|
||||
|
@ -182,15 +144,17 @@ fun String.parseId3GenreNames(settings: Settings) =
|
|||
*/
|
||||
private fun String.parseId3v1Genre(): String? {
|
||||
// ID3v1 genres are a plain integer value without formatting, so in that case
|
||||
// try to index the genre table with such. If this fails, then try to compare it
|
||||
// to some other hard-coded values.
|
||||
val numeric = toIntOrNull() ?: return when (this) {
|
||||
// CR and RX are not technically ID3v1, but are formatted similarly to a plain number.
|
||||
"CR" -> "Cover"
|
||||
"RX" -> "Remix"
|
||||
else -> null
|
||||
}
|
||||
|
||||
// try to index the genre table with such.
|
||||
val numeric =
|
||||
toIntOrNull()
|
||||
// Not a numeric value, try some other fixed values.
|
||||
?: return when (this) {
|
||||
// CR and RX are not technically ID3v1, but are formatted similarly to a plain
|
||||
// number.
|
||||
"CR" -> "Cover"
|
||||
"RX" -> "Remix"
|
||||
else -> null
|
||||
}
|
||||
return GENRE_TABLE.getOrNull(numeric)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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.parsing
|
||||
|
||||
/**
|
||||
* Defines the allowed separator characters that can be used to delimit multi-value tags.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
object Separators {
|
||||
const val COMMA = ','
|
||||
const val SEMICOLON = ';'
|
||||
const val SLASH = '/'
|
||||
const val PLUS = '+'
|
||||
const val AND = '&'
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.extractor
|
||||
package org.oxycblt.auxio.music.parsing
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
|
@ -27,7 +27,6 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.context
|
||||
|
||||
/**
|
||||
* A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to
|
||||
|
@ -35,8 +34,6 @@ import org.oxycblt.auxio.util.context
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
DialogSeparatorsBinding.inflate(inflater)
|
||||
|
||||
|
@ -45,7 +42,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
|||
.setTitle(R.string.set_separators)
|
||||
.setNegativeButton(R.string.lbl_cancel, null)
|
||||
.setPositiveButton(R.string.lbl_save) { _, _ ->
|
||||
settings.musicSeparators = getCurrentSeparators()
|
||||
Settings(requireContext()).musicSeparators = getCurrentSeparators()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,16 +58,18 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
|||
// More efficient to do one iteration through the separator list and initialize
|
||||
// the corresponding CheckBox for each character instead of doing an iteration
|
||||
// through the separator list for each CheckBox.
|
||||
(savedInstanceState?.getString(KEY_PENDING_SEPARATORS) ?: settings.musicSeparators)?.forEach {
|
||||
when (it) {
|
||||
SEPARATOR_COMMA -> binding.separatorComma.isChecked = true
|
||||
SEPARATOR_SEMICOLON -> binding.separatorSemicolon.isChecked = true
|
||||
SEPARATOR_SLASH -> binding.separatorSlash.isChecked = true
|
||||
SEPARATOR_PLUS -> binding.separatorPlus.isChecked = true
|
||||
SEPARATOR_AND -> binding.separatorAnd.isChecked = true
|
||||
else -> error("Unexpected separator in settings data")
|
||||
(savedInstanceState?.getString(KEY_PENDING_SEPARATORS)
|
||||
?: Settings(requireContext()).musicSeparators)
|
||||
?.forEach {
|
||||
when (it) {
|
||||
Separators.COMMA -> binding.separatorComma.isChecked = true
|
||||
Separators.SEMICOLON -> binding.separatorSemicolon.isChecked = true
|
||||
Separators.SLASH -> binding.separatorSlash.isChecked = true
|
||||
Separators.PLUS -> binding.separatorPlus.isChecked = true
|
||||
Separators.AND -> binding.separatorAnd.isChecked = true
|
||||
else -> error("Unexpected separator in settings data")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
|
@ -85,21 +84,15 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
|||
// of use a mapping that could feasibly drift from the actual layout.
|
||||
var separators = ""
|
||||
val binding = requireBinding()
|
||||
if (binding.separatorComma.isChecked) separators += SEPARATOR_COMMA
|
||||
if (binding.separatorSemicolon.isChecked) separators += SEPARATOR_SEMICOLON
|
||||
if (binding.separatorSlash.isChecked) separators += SEPARATOR_SLASH
|
||||
if (binding.separatorPlus.isChecked) separators += SEPARATOR_PLUS
|
||||
if (binding.separatorAnd.isChecked) separators += SEPARATOR_AND
|
||||
if (binding.separatorComma.isChecked) separators += Separators.COMMA
|
||||
if (binding.separatorSemicolon.isChecked) separators += Separators.SEMICOLON
|
||||
if (binding.separatorSlash.isChecked) separators += Separators.SLASH
|
||||
if (binding.separatorPlus.isChecked) separators += Separators.PLUS
|
||||
if (binding.separatorAnd.isChecked) separators += Separators.AND
|
||||
return separators
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS"
|
||||
// TODO: Move these to a more "Correct" location?
|
||||
private const val SEPARATOR_COMMA = ','
|
||||
private const val SEPARATOR_SEMICOLON = ';'
|
||||
private const val SEPARATOR_SLASH = '/'
|
||||
private const val SEPARATOR_PLUS = '+'
|
||||
private const val SEPARATOR_AND = '&'
|
||||
private companion object {
|
||||
const val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS"
|
||||
}
|
||||
}
|
|
@ -39,7 +39,7 @@ class ArtistChoiceAdapter(private val listener: ClickableListListener) :
|
|||
override fun getItemCount() = artists.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
ArtistChoiceViewHolder.new(parent)
|
||||
ArtistChoiceViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) =
|
||||
holder.bind(artists[position], listener)
|
||||
|
@ -58,7 +58,7 @@ class ArtistChoiceAdapter(private val listener: ClickableListListener) :
|
|||
|
||||
/**
|
||||
* A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Artist] item, for
|
||||
* use with [ArtistChoiceAdapter]. Use [new] to create an instance.
|
||||
* use with [ArtistChoiceAdapter]. Use [from] to create an instance.
|
||||
*/
|
||||
class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
||||
DialogRecyclerView.ViewHolder(binding.root) {
|
||||
|
@ -68,7 +68,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
|||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(artist: Artist, listener: ClickableListListener) {
|
||||
binding.root.setOnClickListener { listener.onClick(artist) }
|
||||
listener.bind(artist, this)
|
||||
binding.pickerImage.bind(artist)
|
||||
binding.pickerName.text = artist.resolveName(binding.context)
|
||||
}
|
||||
|
@ -79,7 +79,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.picker
|
|||
import android.os.Bundle
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
|
@ -40,8 +41,8 @@ class ArtistNavigationPickerDialog : ArtistPickerDialog() {
|
|||
super.onBindingCreated(binding, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
super.onClick(item)
|
||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||
super.onClick(item, viewHolder)
|
||||
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
// User made a choice, navigate to it.
|
||||
navModel.exploreNavigateTo(item)
|
||||
|
|
|
@ -22,6 +22,7 @@ import android.view.LayoutInflater
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
|
@ -67,7 +68,7 @@ abstract class ArtistPickerDialog :
|
|||
binding.pickerRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.picker
|
|||
|
||||
import android.os.Bundle
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
|
@ -41,8 +42,8 @@ class ArtistPlaybackPickerDialog : ArtistPickerDialog() {
|
|||
super.onBindingCreated(binding, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
super.onClick(item)
|
||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||
super.onClick(item, viewHolder)
|
||||
// User made a choice, play the given song from that artist.
|
||||
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
val song = pickerModel.currentItem.value
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* 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.music.picker
|
||||
|
||||
import android.view.View
|
||||
|
@ -22,7 +39,7 @@ class GenreChoiceAdapter(private val listener: ClickableListListener) :
|
|||
override fun getItemCount() = genres.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
GenreChoiceViewHolder.new(parent)
|
||||
GenreChoiceViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) =
|
||||
holder.bind(genres[position], listener)
|
||||
|
@ -41,7 +58,7 @@ class GenreChoiceAdapter(private val listener: ClickableListListener) :
|
|||
|
||||
/**
|
||||
* A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Genre] item, for
|
||||
* use with [GenreChoiceAdapter]. Use [new] to create an instance.
|
||||
* use with [GenreChoiceAdapter]. Use [from] to create an instance.
|
||||
*/
|
||||
class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
||||
DialogRecyclerView.ViewHolder(binding.root) {
|
||||
|
@ -51,7 +68,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
|||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(genre: Genre, listener: ClickableListListener) {
|
||||
binding.root.setOnClickListener { listener.onClick(genre) }
|
||||
listener.bind(genre, this)
|
||||
binding.pickerImage.bind(genre)
|
||||
binding.pickerName.text = genre.resolveName(binding.context)
|
||||
}
|
||||
|
@ -62,7 +79,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* 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.music.picker
|
||||
|
||||
import android.os.Bundle
|
||||
|
@ -6,6 +23,7 @@ import androidx.appcompat.app.AlertDialog
|
|||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
|
@ -21,7 +39,8 @@ import org.oxycblt.auxio.util.collectImmediately
|
|||
* A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenrePlaybackPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
|
||||
class GenrePlaybackPickerDialog :
|
||||
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
|
||||
private val pickerModel: PickerViewModel by viewModels()
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
// Information about what Song to show choices for is initially within the navigation arguments
|
||||
|
@ -56,11 +75,11 @@ class GenrePlaybackPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBin
|
|||
binding.pickerRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||
// User made a choice, play the given song from that genre.
|
||||
check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
val song = pickerModel.currentItem.value
|
||||
check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
playbackModel.playFromGenre(song, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,12 +28,13 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
* contain the music themselves and then exit if the library changes.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PickerViewModel : ViewModel(), MusicStore.Callback {
|
||||
class PickerViewModel : ViewModel(), MusicStore.Listener {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
|
||||
private val _currentItem = MutableStateFlow<Music?>(null)
|
||||
/** The current item whose artists should be shown in the picker. Null if there is no item. */
|
||||
val currentItem: StateFlow<Music?> get() = _currentItem
|
||||
val currentItem: StateFlow<Music?>
|
||||
get() = _currentItem
|
||||
|
||||
private val _artistChoices = MutableStateFlow<List<Artist>>(listOf())
|
||||
/** The current [Artist] choices. Empty if no item is shown in the picker. */
|
||||
|
@ -46,7 +47,7 @@ class PickerViewModel : ViewModel(), MusicStore.Callback {
|
|||
get() = _genreChoices
|
||||
|
||||
override fun onCleared() {
|
||||
musicStore.removeCallback(this)
|
||||
musicStore.removeListener(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
|
@ -75,5 +76,4 @@ class PickerViewModel : ViewModel(), MusicStore.Callback {
|
|||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -51,10 +51,10 @@ import org.oxycblt.auxio.util.logW
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Indexer private constructor() {
|
||||
private var lastResponse: Response? = null
|
||||
private var indexingState: Indexing? = null
|
||||
private var controller: Controller? = null
|
||||
private var callback: Callback? = null
|
||||
@Volatile private var lastResponse: Result<MusicStore.Library>? = null
|
||||
@Volatile private var indexingState: Indexing? = null
|
||||
@Volatile private var controller: Controller? = null
|
||||
@Volatile private var listener: Listener? = null
|
||||
|
||||
/** Whether music loading is occurring or not. */
|
||||
val isIndexing: Boolean
|
||||
|
@ -71,7 +71,7 @@ class Indexer private constructor() {
|
|||
/**
|
||||
* Register a [Controller] for this instance. This instance will handle any commands to start
|
||||
* the music loading process. There can be only one [Controller] at a time. Will invoke all
|
||||
* [Callback] methods to initialize the instance with the current state.
|
||||
* [Listener] methods to initialize the instance with the current state.
|
||||
* @param controller The [Controller] to register. Will do nothing if already registered.
|
||||
*/
|
||||
@Synchronized
|
||||
|
@ -105,14 +105,14 @@ class Indexer private constructor() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Register the [Callback] for this instance. This can be used to receive rapid-fire updates to
|
||||
* the current music loading state. There can be only one [Callback] at a time. Will invoke all
|
||||
* [Callback] methods to initialize the instance with the current state.
|
||||
* @param callback The [Callback] to add.
|
||||
* Register the [Listener] for this instance. This can be used to receive rapid-fire updates to
|
||||
* the current music loading state. There can be only one [Listener] at a time. Will invoke all
|
||||
* [Listener] methods to initialize the instance with the current state.
|
||||
* @param listener The [Listener] to add.
|
||||
*/
|
||||
@Synchronized
|
||||
fun registerCallback(callback: Callback) {
|
||||
if (BuildConfig.DEBUG && this.callback != null) {
|
||||
fun registerListener(listener: Listener) {
|
||||
if (BuildConfig.DEBUG && this.listener != null) {
|
||||
logW("Listener is already registered")
|
||||
return
|
||||
}
|
||||
|
@ -120,24 +120,24 @@ class Indexer private constructor() {
|
|||
// Initialize the listener with the current state.
|
||||
val currentState =
|
||||
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
|
||||
callback.onIndexerStateChanged(currentState)
|
||||
this.callback = callback
|
||||
listener.onIndexerStateChanged(currentState)
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a [Callback] from this instance, preventing it from recieving any further updates.
|
||||
* @param callback The [Callback] to unregister. Must be the current [Callback]. Does nothing if
|
||||
* invoked by another [Callback] implementation.
|
||||
* @see Callback
|
||||
* Unregister a [Listener] from this instance, preventing it from recieving any further updates.
|
||||
* @param listener The [Listener] to unregister. Must be the current [Listener]. Does nothing if
|
||||
* invoked by another [Listener] implementation.
|
||||
* @see Listener
|
||||
*/
|
||||
@Synchronized
|
||||
fun unregisterCallback(callback: Callback) {
|
||||
if (BuildConfig.DEBUG && this.callback !== callback) {
|
||||
fun unregisterListener(listener: Listener) {
|
||||
if (BuildConfig.DEBUG && this.listener !== listener) {
|
||||
logW("Given controller did not match current controller")
|
||||
return
|
||||
}
|
||||
|
||||
this.callback = null
|
||||
this.listener = null
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -148,28 +148,14 @@ class Indexer private constructor() {
|
|||
* be written, but no cache entries will be loaded into the new library.
|
||||
*/
|
||||
suspend fun index(context: Context, withCache: Boolean) {
|
||||
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
||||
PackageManager.PERMISSION_DENIED) {
|
||||
// No permissions, signal that we can't do anything.
|
||||
emitCompletion(Response.NoPerms)
|
||||
return
|
||||
}
|
||||
|
||||
val response =
|
||||
val result =
|
||||
try {
|
||||
val start = System.currentTimeMillis()
|
||||
val library = indexImpl(context, withCache)
|
||||
if (library != null) {
|
||||
// Successfully loaded a library.
|
||||
logD(
|
||||
"Music indexing completed successfully in " +
|
||||
"${System.currentTimeMillis() - start}ms")
|
||||
Response.Ok(library)
|
||||
} else {
|
||||
// Loaded a library, but it contained no music.
|
||||
logE("No music found")
|
||||
Response.NoMusic
|
||||
}
|
||||
logD(
|
||||
"Music indexing completed successfully in " +
|
||||
"${System.currentTimeMillis() - start}ms")
|
||||
Result.success(library)
|
||||
} catch (e: CancellationException) {
|
||||
// Got cancelled, propagate upwards to top-level co-routine.
|
||||
logD("Loading routine was cancelled")
|
||||
|
@ -178,10 +164,9 @@ class Indexer private constructor() {
|
|||
// Music loading process failed due to something we have not handled.
|
||||
logE("Music indexing failed")
|
||||
logE(e.stackTraceToString())
|
||||
Response.Err(e)
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
emitCompletion(response)
|
||||
emitCompletion(result)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -212,9 +197,17 @@ class Indexer private constructor() {
|
|||
* @param context [Context] required to load music.
|
||||
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
|
||||
* be written, but no cache entries will be loaded into the new library.
|
||||
* @return A newly-loaded [MusicStore.Library], or null if nothing was loaded.
|
||||
* @return A newly-loaded [MusicStore.Library].
|
||||
* @throws NoPermissionException If [PERMISSION_READ_AUDIO] was not granted.
|
||||
* @throws NoMusicException If no music was found on the device.
|
||||
*/
|
||||
private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library? {
|
||||
private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library {
|
||||
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
||||
PackageManager.PERMISSION_DENIED) {
|
||||
// No permissions, signal that we can't do anything.
|
||||
throw NoPermissionException()
|
||||
}
|
||||
|
||||
// Create the chain of extractors. Each extractor builds on the previous and
|
||||
// enables version-specific features in order to create the best possible music
|
||||
// experience.
|
||||
|
@ -236,12 +229,8 @@ class Indexer private constructor() {
|
|||
|
||||
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
|
||||
|
||||
val songs = buildSongs(metadataExtractor, Settings(context))
|
||||
if (songs.isEmpty()) {
|
||||
// No songs, nothing else to do.
|
||||
return null
|
||||
}
|
||||
|
||||
val songs =
|
||||
buildSongs(metadataExtractor, Settings(context)).ifEmpty { throw NoMusicException() }
|
||||
// Build the rest of the music library from the song list. This is much more powerful
|
||||
// and reliable compared to using MediaStore to obtain grouping information.
|
||||
val buildStart = System.currentTimeMillis()
|
||||
|
@ -249,7 +238,6 @@ class Indexer private constructor() {
|
|||
val artists = buildArtists(songs, albums)
|
||||
val genres = buildGenres(songs)
|
||||
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
|
||||
|
||||
return MusicStore.Library(songs, albums, artists, genres)
|
||||
}
|
||||
|
||||
|
@ -388,17 +376,17 @@ class Indexer private constructor() {
|
|||
val state =
|
||||
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
|
||||
controller?.onIndexerStateChanged(state)
|
||||
callback?.onIndexerStateChanged(state)
|
||||
listener?.onIndexerStateChanged(state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a new [State.Complete] state. This can be used to signal the completion of the music
|
||||
* loading process to external code. Will check if the callee has not been canceled and thus has
|
||||
* the ability to emit a new state
|
||||
* @param response The new [Response] to emit, representing the outcome of the music loading
|
||||
* @param result The new [Result] to emit, representing the outcome of the music loading
|
||||
* process.
|
||||
*/
|
||||
private suspend fun emitCompletion(response: Response) {
|
||||
private suspend fun emitCompletion(result: Result<MusicStore.Library>) {
|
||||
yield()
|
||||
// Swap to the Main thread so that downstream callbacks don't crash from being on
|
||||
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
|
||||
|
@ -406,12 +394,12 @@ class Indexer private constructor() {
|
|||
synchronized(this) {
|
||||
// Do not check for redundancy here, as we actually need to notify a switch
|
||||
// from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
|
||||
lastResponse = response
|
||||
lastResponse = result
|
||||
indexingState = null
|
||||
// Signal that the music loading process has been completed.
|
||||
val state = State.Complete(response)
|
||||
val state = State.Complete(result)
|
||||
controller?.onIndexerStateChanged(state)
|
||||
callback?.onIndexerStateChanged(state)
|
||||
listener?.onIndexerStateChanged(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -427,10 +415,9 @@ class Indexer private constructor() {
|
|||
|
||||
/**
|
||||
* Music loading has completed.
|
||||
* @param response The outcome of the music loading process.
|
||||
* @see Response
|
||||
* @param result The outcome of the music loading process.
|
||||
*/
|
||||
data class Complete(val response: Response) : State()
|
||||
data class Complete(val result: Result<MusicStore.Library>) : State()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -451,35 +438,26 @@ class Indexer private constructor() {
|
|||
class Songs(val current: Int, val total: Int) : Indexing()
|
||||
}
|
||||
|
||||
/** Represents the possible outcomes of the music loading process. */
|
||||
sealed class Response {
|
||||
/**
|
||||
* Music load was successful and produced a [MusicStore.Library].
|
||||
* @param library The loaded [MusicStore.Library].
|
||||
*/
|
||||
data class Ok(val library: MusicStore.Library) : Response()
|
||||
/** Thrown when the required permissions to load the music library have not been granted yet. */
|
||||
class NoPermissionException : Exception() {
|
||||
override val message: String
|
||||
get() = "Not granted permissions to load music library"
|
||||
}
|
||||
|
||||
/**
|
||||
* Music loading encountered an unexpected error.
|
||||
* @param throwable The error thrown.
|
||||
*/
|
||||
data class Err(val throwable: Throwable) : Response()
|
||||
|
||||
/** Music loading occurred, but resulted in no music. */
|
||||
object NoMusic : Response()
|
||||
|
||||
/** Music loading could not occur due to a lack of storage permissions. */
|
||||
object NoPerms : Response()
|
||||
/** Thrown when no music was found on the device. */
|
||||
class NoMusicException : Exception() {
|
||||
override val message: String
|
||||
get() = "Unable to find any music"
|
||||
}
|
||||
|
||||
/**
|
||||
* A listener for rapid-fire changes in the music loading state.
|
||||
*
|
||||
* This is only useful for code that absolutely must show the current loading process.
|
||||
* Otherwise, [MusicStore.Callback] is highly recommended due to it's updates only consisting of
|
||||
* Otherwise, [MusicStore.Listener] is highly recommended due to it's updates only consisting of
|
||||
* the [MusicStore.Library].
|
||||
*/
|
||||
interface Callback {
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the current state of the Indexer changed.
|
||||
*
|
||||
|
@ -495,7 +473,7 @@ class Indexer private constructor() {
|
|||
* Context that runs the music loading process. Implementations should be capable of running the
|
||||
* background for long periods of time without android killing the process.
|
||||
*/
|
||||
interface Controller : Callback {
|
||||
interface Controller : Listener {
|
||||
/**
|
||||
* Called when a new music loading process was requested. Implementations should forward
|
||||
* this to [index].
|
||||
|
@ -514,8 +492,7 @@ class Indexer private constructor() {
|
|||
* system to load audio.
|
||||
*/
|
||||
val PERMISSION_READ_AUDIO =
|
||||
// TODO: Move elsewhere.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// READ_EXTERNAL_STORAGE was superseded by READ_MEDIA_AUDIO in Android 13
|
||||
Manifest.permission.READ_MEDIA_AUDIO
|
||||
} else {
|
||||
|
|
|
@ -72,7 +72,6 @@ class IndexingNotification(private val context: Context) :
|
|||
// Determinate state, show an active progress meter. Since these updates arrive
|
||||
// highly rapidly, only update every 1.5 seconds to prevent notification rate
|
||||
// limiting.
|
||||
// TODO: Can I port this to the playback notification somehow?
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (lastUpdateTime > -1 && (now - lastUpdateTime) < 1500) {
|
||||
return false
|
||||
|
|
|
@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.system
|
|||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.database.ContentObserver
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
|
@ -33,7 +34,7 @@ import kotlinx.coroutines.launch
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.service.ForegroundManager
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
|
@ -54,7 +55,8 @@ import org.oxycblt.auxio.util.logD
|
|||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
||||
class IndexerService :
|
||||
Service(), Indexer.Controller, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private val indexer = Indexer.getInstance()
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
|
@ -81,7 +83,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
indexerContentObserver = SystemContentObserver()
|
||||
settings = Settings(this, this)
|
||||
settings = Settings(this)
|
||||
settings.addListener(this)
|
||||
indexer.registerController(this)
|
||||
// An indeterminate indexer and a missing library implies we are extremely early
|
||||
// in app initialization so start loading music.
|
||||
|
@ -105,7 +108,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
// Then cancel the listener-dependent components to ensure that stray reloading
|
||||
// events will not occur.
|
||||
indexerContentObserver.release()
|
||||
settings.release()
|
||||
settings.removeListener(this)
|
||||
indexer.unregisterController(this)
|
||||
// Then cancel any remaining music loading jobs.
|
||||
serviceJob.cancel()
|
||||
|
@ -126,11 +129,11 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
|
||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
||||
when (state) {
|
||||
is Indexer.State.Indexing -> updateActiveSession(state.indexing)
|
||||
is Indexer.State.Complete -> {
|
||||
if (state.response is Indexer.Response.Ok &&
|
||||
state.response.library != musicStore.library) {
|
||||
val newLibrary = state.result.getOrNull()
|
||||
if (newLibrary != null && newLibrary != musicStore.library) {
|
||||
logD("Applying new library")
|
||||
val newLibrary = state.response.library
|
||||
// We only care if the newly-loaded library is going to replace a previously
|
||||
// loaded library.
|
||||
if (musicStore.library != null) {
|
||||
|
@ -149,9 +152,6 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
// handled right now.
|
||||
updateIdleSession()
|
||||
}
|
||||
is Indexer.State.Indexing -> {
|
||||
updateActiveSession(state.indexing)
|
||||
}
|
||||
null -> {
|
||||
// Null is the indeterminate state that occurs on app startup or after
|
||||
// the cancellation of a load, so in that case we want to stop foreground
|
||||
|
@ -195,7 +195,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
// 2. If a non-foreground service is killed, the app will probably still be alive,
|
||||
// and thus the music library will not be updated at all.
|
||||
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
|
||||
// this anymore.
|
||||
// this anymore, or at least I only have to use it when the app task is not removed.
|
||||
if (!foregroundManager.tryStartForeground(observingNotification)) {
|
||||
observingNotification.post()
|
||||
}
|
||||
|
@ -230,7 +230,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
|
||||
// --- SETTING CALLBACKS ---
|
||||
|
||||
override fun onSettingChanged(key: String) {
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
when (key) {
|
||||
// Hook changes in music settings to a new music loading event.
|
||||
getString(R.string.set_key_exclude_non_music),
|
||||
|
@ -287,8 +287,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
|
||||
private const val REINDEX_DELAY_MS = 500L
|
||||
private companion object {
|
||||
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
|
||||
const val REINDEX_DELAY_MS = 500L
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import android.media.audiofx.AudioEffect
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.updatePadding
|
||||
|
@ -53,13 +54,7 @@ class PlaybackPanelFragment :
|
|||
StyledSeekBar.Listener {
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
private val navModel: NavigationViewModel by activityViewModels()
|
||||
// AudioEffect expects you to use startActivityForResult with the panel intent. There is no
|
||||
// contract analogue for this intent, so the generic contract is used instead.
|
||||
private val equalizerLauncher by lifecycleObject {
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
// Nothing to do
|
||||
}
|
||||
}
|
||||
private var equalizerLauncher: ActivityResultLauncher<Intent>? = null
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
FragmentPlaybackPanelBinding.inflate(inflater)
|
||||
|
@ -70,6 +65,13 @@ class PlaybackPanelFragment :
|
|||
) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// AudioEffect expects you to use startActivityForResult with the panel intent. There is no
|
||||
// contract analogue for this intent, so the generic contract is used instead.
|
||||
equalizerLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
// Nothing to do
|
||||
}
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.root.setOnApplyWindowInsetsListener { view, insets ->
|
||||
val bars = insets.systemBarInsetsCompat
|
||||
|
@ -100,6 +102,7 @@ class PlaybackPanelFragment :
|
|||
binding.playbackSeekBar.listener = this
|
||||
|
||||
// Set up actions
|
||||
// TODO: Add better playback button accessibility
|
||||
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
|
||||
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
|
||||
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
|
||||
|
@ -116,6 +119,7 @@ class PlaybackPanelFragment :
|
|||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
|
||||
equalizerLauncher = null
|
||||
binding.playbackToolbar.setOnMenuItemClickListener(null)
|
||||
// Marquee elements leak if they are not disabled when the views are destroyed.
|
||||
binding.playbackSong.isSelected = false
|
||||
|
@ -127,10 +131,9 @@ class PlaybackPanelFragment :
|
|||
when (item.itemId) {
|
||||
R.id.action_open_equalizer -> {
|
||||
// Launch the system equalizer app, if possible.
|
||||
// TODO: Move this to a utility
|
||||
val equalizerIntent =
|
||||
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
|
||||
// Provide audio session ID so equalizer can show options for this app
|
||||
// Provide audio session ID so the equalizer can show options for this app
|
||||
// in particular.
|
||||
.putExtra(
|
||||
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId)
|
||||
|
@ -138,7 +141,10 @@ class PlaybackPanelFragment :
|
|||
// music playback.
|
||||
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
||||
try {
|
||||
equalizerLauncher.launch(equalizerIntent)
|
||||
requireNotNull(equalizerLauncher) {
|
||||
"Equalizer panel launcher was not available"
|
||||
}
|
||||
.launch(equalizerIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
requireContext().showToast(R.string.err_no_app)
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ import org.oxycblt.auxio.util.context
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaybackViewModel(application: Application) :
|
||||
AndroidViewModel(application), PlaybackStateManager.Callback {
|
||||
AndroidViewModel(application), PlaybackStateManager.Listener {
|
||||
private val settings = Settings(application)
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private var lastPositionJob: Job? = null
|
||||
|
@ -70,8 +70,8 @@ class PlaybackViewModel(application: Application) :
|
|||
|
||||
private val _artistPlaybackPickerSong = MutableStateFlow<Song?>(null)
|
||||
/**
|
||||
* Flag signaling to open a picker dialog in order to resolve an ambiguous choice when
|
||||
* playing a [Song] from one of it's [Artist]s.
|
||||
* Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a
|
||||
* [Song] from one of it's [Artist]s.
|
||||
* @see playFromArtist
|
||||
*/
|
||||
val artistPickerSong: StateFlow<Song?>
|
||||
|
@ -79,8 +79,8 @@ class PlaybackViewModel(application: Application) :
|
|||
|
||||
private val _genrePlaybackPickerSong = MutableStateFlow<Song?>(null)
|
||||
/**
|
||||
* Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing
|
||||
* a [Song] from one of it's [Genre]s.
|
||||
* Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a
|
||||
* [Song] from one of it's [Genre]s.
|
||||
*/
|
||||
val genrePickerSong: StateFlow<Song?>
|
||||
get() = _genrePlaybackPickerSong
|
||||
|
@ -93,11 +93,11 @@ class PlaybackViewModel(application: Application) :
|
|||
get() = playbackManager.currentAudioSessionId
|
||||
|
||||
init {
|
||||
playbackManager.addCallback(this)
|
||||
playbackManager.addListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
playbackManager.removeCallback(this)
|
||||
playbackManager.removeListener(this)
|
||||
}
|
||||
|
||||
override fun onIndexMoved(index: Int) {
|
||||
|
|
|
@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback.queue
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
|
@ -27,6 +26,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
|
||||
import org.oxycblt.auxio.list.EditableListListener
|
||||
import org.oxycblt.auxio.list.recycler.PlayingIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.SyncListDiffer
|
||||
|
@ -38,10 +38,11 @@ import org.oxycblt.auxio.util.inflater
|
|||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that shows an editable list of queue items.
|
||||
* @param listener A [Listener] to bind interactions to.
|
||||
* @param listener A [EditableListListener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter<QueueSongViewHolder>() {
|
||||
class QueueAdapter(private val listener: EditableListListener) :
|
||||
RecyclerView.Adapter<QueueSongViewHolder>() {
|
||||
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK)
|
||||
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this
|
||||
// adapter, as one item can appear at several points in the UI. Use a similar implementation
|
||||
|
@ -52,7 +53,7 @@ class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter<QueueS
|
|||
override fun getItemCount() = differ.currentList.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
QueueSongViewHolder.new(parent)
|
||||
QueueSongViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: QueueSongViewHolder, position: Int) =
|
||||
throw IllegalStateException()
|
||||
|
@ -121,29 +122,13 @@ class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter<QueueS
|
|||
}
|
||||
}
|
||||
|
||||
/** A listener for queue list events. */
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when a [RecyclerView.ViewHolder] in the list as clicked.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] that was clicked.
|
||||
*/
|
||||
fun onClick(viewHolder: RecyclerView.ViewHolder)
|
||||
|
||||
/**
|
||||
* Called when the drag handle on a [RecyclerView.ViewHolder] is clicked, requesting that a
|
||||
* drag should be started.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] to start dragging.
|
||||
*/
|
||||
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PAYLOAD_UPDATE_POSITION = Any()
|
||||
private companion object {
|
||||
val PAYLOAD_UPDATE_POSITION = Any()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [PlayingIndicatorAdapter.ViewHolder] that displays a queue [Song]. Use [new] to create an
|
||||
* A [PlayingIndicatorAdapter.ViewHolder] that displays a queue [Song]. Use [from] to create an
|
||||
* instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -190,26 +175,17 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
|
|||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param song The new [Song] to bind.
|
||||
* @param listener A [QueueAdapter.Listener] to bind interactions to.
|
||||
* @param listener A [EditableListListener] to bind interactions to.
|
||||
*/
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
fun bind(song: Song, listener: QueueAdapter.Listener) {
|
||||
binding.body.setOnClickListener { listener.onClick(this) }
|
||||
|
||||
fun bind(song: Song, listener: EditableListListener) {
|
||||
listener.bind(song, this, bodyView, binding.songDragHandle)
|
||||
binding.songAlbumCover.bind(song)
|
||||
binding.songName.text = song.resolveName(binding.context)
|
||||
binding.songInfo.text = song.resolveArtistContents(binding.context)
|
||||
// TODO: Why is this here?
|
||||
// Not swiping this ViewHolder if it's being re-bound, ensure that the background is
|
||||
// not visible. See QueueDragCallback for why this is done.
|
||||
binding.background.isInvisible = true
|
||||
|
||||
// Set up the drag handle to start a drag whenever it is touched.
|
||||
binding.songDragHandle.setOnTouchListener { _, motionEvent ->
|
||||
binding.songDragHandle.performClick()
|
||||
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
listener.onPickUp(this)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
}
|
||||
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
|
@ -223,7 +199,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
fun from(parent: View) =
|
||||
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
|
|
|
@ -24,7 +24,10 @@ import androidx.fragment.app.activityViewModels
|
|||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.databinding.FragmentQueueBinding
|
||||
import org.oxycblt.auxio.list.EditableListListener
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
|
@ -36,13 +39,11 @@ import org.oxycblt.auxio.util.logD
|
|||
* A [ViewBindingFragment] that displays an editable queue.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.Listener {
|
||||
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener {
|
||||
private val queueModel: QueueViewModel by activityViewModels()
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
private val queueAdapter = QueueAdapter(this)
|
||||
private val touchHelper: ItemTouchHelper by lifecycleObject {
|
||||
ItemTouchHelper(QueueDragCallback(queueModel))
|
||||
}
|
||||
private var touchHelper: ItemTouchHelper? = null
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
|
||||
|
||||
|
@ -52,7 +53,10 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
|
|||
// --- UI SETUP ---
|
||||
binding.queueRecycler.apply {
|
||||
adapter = queueAdapter
|
||||
touchHelper.attachToRecyclerView(this)
|
||||
touchHelper =
|
||||
ItemTouchHelper(QueueDragCallback(queueModel)).also {
|
||||
it.attachToRecyclerView(this)
|
||||
}
|
||||
}
|
||||
|
||||
// Sometimes the scroll can change without the listener being updated, so we also
|
||||
|
@ -77,13 +81,12 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
|
|||
binding.queueRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onClick(viewHolder: RecyclerView.ViewHolder) {
|
||||
// Clicking on a queue item should start playing it.
|
||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||
queueModel.goto(viewHolder.bindingAdapterPosition)
|
||||
}
|
||||
|
||||
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
||||
touchHelper.startDrag(viewHolder)
|
||||
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
|
||||
}
|
||||
|
||||
private fun updateDivider() {
|
||||
|
@ -108,17 +111,25 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
|
|||
queueModel.finishReplace()
|
||||
|
||||
// If requested, scroll to a new item (occurs when the index moves)
|
||||
// TODO: Scroll to center/top instead of bottom
|
||||
val scrollTo = queueModel.scrollTo
|
||||
if (scrollTo != null) {
|
||||
// Do not scroll to indices that are in the currently visible range. As that would
|
||||
// lead to the queue jumping around every time goto is called.
|
||||
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
|
||||
val start = lmm.findFirstCompletelyVisibleItemPosition()
|
||||
val end = lmm.findLastCompletelyVisibleItemPosition()
|
||||
if (scrollTo !in start..end) {
|
||||
logD("Scrolling to new position")
|
||||
val notInitialized =
|
||||
start == RecyclerView.NO_POSITION || end == RecyclerView.NO_POSITION
|
||||
// When we scroll, we want to scroll to the almost-top so the user can see
|
||||
// future songs instead of past songs. The way we have to do this however is
|
||||
// dependent on where we have to scroll to get to the currently playing song.
|
||||
if (notInitialized || scrollTo < start) {
|
||||
// We need to scroll upwards, or initialize the scroll, no need to offset
|
||||
binding.queueRecycler.scrollToPosition(scrollTo)
|
||||
} else if (scrollTo > end) {
|
||||
// We need to scroll downwards, we need to offset by a screen of songs.
|
||||
// This does have some error due to what the layout manager returns being
|
||||
// somewhat mutable. This is considered okay.
|
||||
binding.queueRecycler.scrollToPosition(
|
||||
min(queue.lastIndex, scrollTo + (end - start)))
|
||||
}
|
||||
}
|
||||
queueModel.finishScrollTo()
|
||||
|
|
|
@ -29,7 +29,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
|||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
|
||||
class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
|
||||
private val _queue = MutableStateFlow(listOf<Song>())
|
||||
|
@ -47,7 +47,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
var scrollTo: Int? = null
|
||||
|
||||
init {
|
||||
playbackManager.addCallback(this)
|
||||
playbackManager.addListener(this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -135,6 +135,6 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
|
|||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
playbackManager.removeCallback(this)
|
||||
playbackManager.removeListener(this)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,15 +26,12 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogPreAmpBinding
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.context
|
||||
|
||||
/**
|
||||
* aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater)
|
||||
|
||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||
|
@ -42,9 +39,12 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
|
|||
.setTitle(R.string.set_pre_amp)
|
||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||
val binding = requireBinding()
|
||||
settings.replayGainPreAmp =
|
||||
Settings(requireContext()).replayGainPreAmp =
|
||||
ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value)
|
||||
}
|
||||
.setNeutralButton(R.string.lbl_reset) { _, _ ->
|
||||
Settings(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f)
|
||||
}
|
||||
.setNegativeButton(R.string.lbl_cancel, null)
|
||||
}
|
||||
|
||||
|
@ -53,7 +53,7 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
|
|||
// First initialization, we need to supply the sliders with the values from
|
||||
// settings. After this, the sliders save their own state, so we do not need to
|
||||
// do any restore behavior.
|
||||
val preAmp = settings.replayGainPreAmp
|
||||
val preAmp = Settings(requireContext()).replayGainPreAmp
|
||||
binding.withTagsSlider.value = preAmp.with
|
||||
binding.withoutTagsSlider.value = preAmp.without
|
||||
}
|
||||
|
|
|
@ -18,33 +18,38 @@
|
|||
package org.oxycblt.auxio.playback.replaygain
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.Format
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.Tracks
|
||||
import com.google.android.exoplayer2.audio.AudioProcessor
|
||||
import com.google.android.exoplayer2.audio.BaseAudioProcessor
|
||||
import com.google.android.exoplayer2.metadata.Metadata
|
||||
import com.google.android.exoplayer2.metadata.id3.InternalFrame
|
||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.pow
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.extractor.Tags
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* An [AudioProcessor] that handles ReplayGain values and their amplification of the audio stream.
|
||||
* Instead of leveraging the volume attribute like other implementations, this system manipulates
|
||||
* the bitstream itself to modify the volume, which allows the use of positive ReplayGain values.
|
||||
*
|
||||
* Note: This instance must be updated with a new [Metadata] every time the active track chamges.
|
||||
* Note: This audio processor must be attached to a respective [Player] instance as a
|
||||
* [Player.Listener] to function properly.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
||||
class ReplayGainAudioProcessor(private val context: Context) :
|
||||
BaseAudioProcessor(), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val settings = Settings(context)
|
||||
private var lastFormat: Format? = null
|
||||
|
||||
private var volume = 1f
|
||||
set(value) {
|
||||
|
@ -53,20 +58,69 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
|||
flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add this instance to the components required for it to function correctly.
|
||||
* @param player The [Player] to attach to. Should already have this instance as an audio
|
||||
* processor.
|
||||
*/
|
||||
fun addToListeners(player: Player) {
|
||||
player.addListener(this)
|
||||
settings.addListener(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove this instance from the components required for it to function correctly.
|
||||
* @param player The [Player] to detach from. Should already have this instance as an audio
|
||||
* processor.
|
||||
*/
|
||||
fun releaseFromListeners(player: Player) {
|
||||
player.removeListener(this)
|
||||
settings.removeListener(this)
|
||||
}
|
||||
|
||||
// --- OVERRIDES ---
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
super.onTracksChanged(tracks)
|
||||
// Try to find the currently playing track so we can update the ReplayGain adjustment
|
||||
// based on it.
|
||||
for (group in tracks.groups) {
|
||||
if (group.isSelected) {
|
||||
for (i in 0 until group.length) {
|
||||
if (group.isTrackSelected(i)) {
|
||||
applyReplayGain(group.getTrackFormat(i))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nothing selected, apply nothing
|
||||
applyReplayGain(null)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key == context.getString(R.string.set_key_replay_gain) ||
|
||||
key == context.getString(R.string.set_key_pre_amp_with) ||
|
||||
key == context.getString(R.string.set_key_pre_amp_without)) {
|
||||
// ReplayGain changed, we need to set it up again.
|
||||
applyReplayGain(lastFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// --- REPLAYGAIN PARSING ---
|
||||
|
||||
/**
|
||||
* Updates the volume adjustment based on the given [Metadata].
|
||||
* @param metadata The [Metadata] of the currently playing track, or null if the track has no
|
||||
* [Metadata].
|
||||
* Updates the volume adjustment based on the given [Format].
|
||||
* @param format The [Format] of the currently playing track, or null if nothing is playing.
|
||||
*/
|
||||
fun applyReplayGain(metadata: Metadata?) {
|
||||
// TODO: Allow this to automatically obtain it's own [Metadata].
|
||||
val gain = metadata?.let(::parseReplayGain)
|
||||
private fun applyReplayGain(format: Format?) {
|
||||
lastFormat = format
|
||||
val gain = parseReplayGain(format ?: return)
|
||||
val preAmp = settings.replayGainPreAmp
|
||||
|
||||
val adjust =
|
||||
if (gain != null) {
|
||||
logD("Found ReplayGain adjustment $gain")
|
||||
// ReplayGain is configurable, so determine what to do based off of the mode.
|
||||
val useAlbumGain =
|
||||
when (settings.replayGainMode) {
|
||||
|
@ -109,104 +163,58 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Parse ReplayGain information from the given [Metadata].
|
||||
* @param metadata The [Metadata] to parse.
|
||||
* @return A [Gain] adjustment, or null if there was no adjustments to parse.
|
||||
* Parse ReplayGain information from the given [Format].
|
||||
* @param format The [Format] to parse.
|
||||
* @return A [Adjustment] adjustment, or null if there were no valid adjustments.
|
||||
*/
|
||||
private fun parseReplayGain(metadata: Metadata): Gain? {
|
||||
// TODO: Unify this parser with the music parser? They both grok Metadata.
|
||||
|
||||
private fun parseReplayGain(format: Format): Adjustment? {
|
||||
val tags = Tags(format.metadata ?: return null)
|
||||
var trackGain = 0f
|
||||
var albumGain = 0f
|
||||
var found = false
|
||||
|
||||
val tags = mutableListOf<GainTag>()
|
||||
|
||||
for (i in 0 until metadata.length()) {
|
||||
val entry = metadata.get(i)
|
||||
|
||||
val key: String?
|
||||
val value: String
|
||||
|
||||
when (entry) {
|
||||
// ID3v2 text information frame, usually these are formatted in lowercase
|
||||
// (like "replaygain_track_gain"), but can also be uppercase. Make sure that
|
||||
// capitalization is consistent before continuing.
|
||||
is TextInformationFrame -> {
|
||||
key = entry.description
|
||||
value = entry.values[0]
|
||||
}
|
||||
// Internal Frame. This is actually MP4's "----" atom, but mapped to an ID3v2
|
||||
// frame by ExoPlayer (presumably to reduce duplication).
|
||||
is InternalFrame -> {
|
||||
key = entry.description
|
||||
value = entry.text
|
||||
}
|
||||
// Vorbis comment. These are nearly always uppercase, so a check for such is
|
||||
// skipped.
|
||||
is VorbisComment -> {
|
||||
key = entry.key
|
||||
value = entry.value
|
||||
}
|
||||
else -> continue
|
||||
}
|
||||
|
||||
if (key in REPLAY_GAIN_TAGS) {
|
||||
// Grok a float from a ReplayGain tag by removing everything that is not 0-9, ,
|
||||
// or -.
|
||||
// Derived from vanilla music: https://github.com/vanilla-music/vanilla
|
||||
val gainValue =
|
||||
try {
|
||||
value.replace(Regex("[^\\d.-]"), "").toFloat()
|
||||
} catch (e: Exception) {
|
||||
0f
|
||||
}
|
||||
|
||||
tags.add(GainTag(unlikelyToBeNull(key), gainValue))
|
||||
}
|
||||
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
|
||||
// replaygain_*_gain tag.
|
||||
if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
|
||||
tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { trackGain = it }
|
||||
tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { albumGain = it }
|
||||
tags.vorbis[TAG_RG_ALBUM_GAIN]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { trackGain = it }
|
||||
tags.vorbis[TAG_RG_TRACK_GAIN]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { albumGain = it }
|
||||
} else {
|
||||
// Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the
|
||||
// adjustment by 256 to get the gain. This is used alongside the base adjustment
|
||||
// intrinsic to the format to create the normalized adjustment. That base adjustment
|
||||
// is already handled by the media framework, so we just need to apply the more
|
||||
// specific adjustments.
|
||||
tags.vorbis[TAG_R128_TRACK_GAIN]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { trackGain = it / 256f }
|
||||
tags.vorbis[TAG_R128_ALBUM_GAIN]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { albumGain = it / 256f }
|
||||
}
|
||||
|
||||
// Case 1: Normal ReplayGain, most commonly found on MPEG files.
|
||||
tags
|
||||
.findLast { tag -> tag.key.equals(TAG_RG_TRACK, ignoreCase = true) }
|
||||
?.let { tag ->
|
||||
trackGain = tag.value
|
||||
found = true
|
||||
}
|
||||
|
||||
tags
|
||||
.findLast { tag -> tag.key.equals(TAG_RG_ALBUM, ignoreCase = true) }
|
||||
?.let { tag ->
|
||||
albumGain = tag.value
|
||||
found = true
|
||||
}
|
||||
|
||||
// Case 2: R128 ReplayGain, most commonly found on FLAC files and other lossless
|
||||
// encodings to increase precision in volume adjustments.
|
||||
// While technically there is the R128 base gain in Opus files, that is automatically
|
||||
// applied by the media framework [which ExoPlayer relies on]. The only reason we would
|
||||
// want to read it is to zero previous ReplayGain values for being invalid, however there
|
||||
// is no demand to fix that edge case right now.
|
||||
tags
|
||||
.findLast { tag -> tag.key.equals(R128_TRACK, ignoreCase = true) }
|
||||
?.let { tag ->
|
||||
trackGain += tag.value / 256f
|
||||
found = true
|
||||
}
|
||||
|
||||
tags
|
||||
.findLast { tag -> tag.key.equals(R128_ALBUM, ignoreCase = true) }
|
||||
?.let { tag ->
|
||||
albumGain += tag.value / 256f
|
||||
found = true
|
||||
}
|
||||
|
||||
return if (found) {
|
||||
Gain(trackGain, albumGain)
|
||||
return if (trackGain != 0f || albumGain != 0f) {
|
||||
Adjustment(trackGain, albumGain)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ReplayGain adjustment into a float value.
|
||||
* @return A parsed adjustment float, or null if the adjustment had invalid formatting.
|
||||
*/
|
||||
private fun String.parseReplayGainAdjustment() =
|
||||
replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()
|
||||
|
||||
// --- AUDIO PROCESSOR IMPLEMENTATION ---
|
||||
|
||||
override fun onConfigure(
|
||||
|
@ -271,22 +279,18 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
|||
* @param track The track adjustment (in dB), or 0 if it is not present.
|
||||
* @param album The album adjustment (in dB), or 0 if it is not present.
|
||||
*/
|
||||
private data class Gain(val track: Float, val album: Float)
|
||||
private data class Adjustment(val track: Float, val album: Float)
|
||||
|
||||
/**
|
||||
* A raw ReplayGain adjustment.
|
||||
* @param key The tag's key.
|
||||
* @param value The tag's adjustment, in dB.
|
||||
*/
|
||||
private data class GainTag(val key: String, val value: Float)
|
||||
// TODO: Try to phase this out
|
||||
private companion object {
|
||||
const val TAG_RG_TRACK_GAIN = "replaygain_track_gain"
|
||||
const val TAG_RG_ALBUM_GAIN = "replaygain_album_gain"
|
||||
const val TAG_R128_TRACK_GAIN = "r128_track_gain"
|
||||
const val TAG_R128_ALBUM_GAIN = "r128_album_gain"
|
||||
|
||||
companion object {
|
||||
private const val TAG_RG_TRACK = "replaygain_track_gain"
|
||||
private const val TAG_RG_ALBUM = "replaygain_album_gain"
|
||||
private const val R128_TRACK = "r128_track_gain"
|
||||
private const val R128_ALBUM = "r128_album_gain"
|
||||
|
||||
private val REPLAY_GAIN_TAGS = arrayOf(TAG_RG_TRACK, TAG_RG_ALBUM, R128_ALBUM, R128_TRACK)
|
||||
/**
|
||||
* Matches non-float information from ReplayGain adjustments. Derived from vanilla music:
|
||||
* https://github.com/vanilla-music/vanilla
|
||||
*/
|
||||
val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX = Regex("[^\\d.-]")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -85,6 +85,9 @@ interface InternalPlayer {
|
|||
data class Open(val uri: Uri) : Action()
|
||||
}
|
||||
|
||||
/**
|
||||
* A representation of the current state of audio playback. Use [from] to create an instance.
|
||||
*/
|
||||
class State
|
||||
private constructor(
|
||||
/** Whether the player is actively playing audio or set to play audio in the future. */
|
||||
|
@ -157,7 +160,7 @@ interface InternalPlayer {
|
|||
* @param isAdvancing Whether the player is actively playing audio in this moment.
|
||||
* @param positionMs The current position of the player.
|
||||
*/
|
||||
fun new(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
|
||||
fun from(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
|
||||
State(
|
||||
isPlaying,
|
||||
// Minor sanity check: Make sure that advancing can't occur if already paused.
|
||||
|
|
|
@ -27,7 +27,7 @@ import org.oxycblt.auxio.music.Genre
|
|||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager.Callback
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.logW
|
|||
* - If you want to use the playback state with the ExoPlayer instance or system-side things, use
|
||||
* [org.oxycblt.auxio.playback.system.PlaybackService].
|
||||
*
|
||||
* Internal consumers should usually use [Callback], however the component that manages the player
|
||||
* Internal consumers should usually use [Listener], however the component that manages the player
|
||||
* itself should instead use [InternalPlayer].
|
||||
*
|
||||
* All access should be done with [PlaybackStateManager.getInstance].
|
||||
|
@ -54,35 +54,40 @@ import org.oxycblt.auxio.util.logW
|
|||
*/
|
||||
class PlaybackStateManager private constructor() {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
private val callbacks = mutableListOf<Callback>()
|
||||
private var internalPlayer: InternalPlayer? = null
|
||||
private var pendingAction: InternalPlayer.Action? = null
|
||||
private var isInitialized = false
|
||||
private val listeners = mutableListOf<Listener>()
|
||||
@Volatile private var internalPlayer: InternalPlayer? = null
|
||||
@Volatile private var pendingAction: InternalPlayer.Action? = null
|
||||
@Volatile private var isInitialized = false
|
||||
|
||||
/** The currently playing [Song]. Null if nothing is playing. */
|
||||
val song
|
||||
get() = queue.getOrNull(index)
|
||||
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
|
||||
@Volatile
|
||||
var parent: MusicParent? = null
|
||||
private set
|
||||
|
||||
private var _queue = mutableListOf<Song>()
|
||||
@Volatile private var _queue = mutableListOf<Song>()
|
||||
/** The current queue. */
|
||||
val queue
|
||||
get() = _queue
|
||||
/** The position of the currently playing item in the queue. */
|
||||
@Volatile
|
||||
var index = -1
|
||||
private set
|
||||
/** The current [InternalPlayer] state. */
|
||||
var playerState = InternalPlayer.State.new(isPlaying = false, isAdvancing = false, 0)
|
||||
@Volatile
|
||||
var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
|
||||
private set
|
||||
/** The current [RepeatMode] */
|
||||
@Volatile
|
||||
var repeatMode = RepeatMode.NONE
|
||||
set(value) {
|
||||
field = value
|
||||
notifyRepeatModeChanged()
|
||||
}
|
||||
/** Whether the queue is shuffled. */
|
||||
@Volatile
|
||||
var isShuffled = false
|
||||
private set
|
||||
/**
|
||||
|
@ -93,32 +98,32 @@ class PlaybackStateManager private constructor() {
|
|||
get() = internalPlayer?.audioSessionId
|
||||
|
||||
/**
|
||||
* Add a [Callback] to this instance. This can be used to receive changes in the playback state.
|
||||
* Will immediately invoke [Callback] methods to initialize the instance with the current state.
|
||||
* @param callback The [Callback] to add.
|
||||
* @see Callback
|
||||
* Add a [Listener] to this instance. This can be used to receive changes in the playback state.
|
||||
* Will immediately invoke [Listener] methods to initialize the instance with the current state.
|
||||
* @param listener The [Listener] to add.
|
||||
* @see Listener
|
||||
*/
|
||||
@Synchronized
|
||||
fun addCallback(callback: Callback) {
|
||||
fun addListener(listener: Listener) {
|
||||
if (isInitialized) {
|
||||
callback.onNewPlayback(index, queue, parent)
|
||||
callback.onRepeatChanged(repeatMode)
|
||||
callback.onShuffledChanged(isShuffled)
|
||||
callback.onStateChanged(playerState)
|
||||
listener.onNewPlayback(index, queue, parent)
|
||||
listener.onRepeatChanged(repeatMode)
|
||||
listener.onShuffledChanged(isShuffled)
|
||||
listener.onStateChanged(playerState)
|
||||
}
|
||||
|
||||
callbacks.add(callback)
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a [Callback] from this instance, preventing it from recieving any further updates.
|
||||
* @param callback The [Callback] to remove. Does nothing if the [Callback] was never added in
|
||||
* Remove a [Listener] from this instance, preventing it from recieving any further updates.
|
||||
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
|
||||
* the first place.
|
||||
* @see Callback
|
||||
* @see Listener
|
||||
*/
|
||||
@Synchronized
|
||||
fun removeCallback(callback: Callback) {
|
||||
callbacks.remove(callback)
|
||||
fun removeListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -521,10 +526,9 @@ class PlaybackStateManager private constructor() {
|
|||
* @param database The [PlaybackStateDatabase] to clear te state from
|
||||
* @return If the state was cleared, false otherwise.
|
||||
*/
|
||||
suspend fun wipeState(database: PlaybackStateDatabase): Boolean {
|
||||
logD("Wiping state")
|
||||
|
||||
return try {
|
||||
suspend fun wipeState(database: PlaybackStateDatabase) =
|
||||
try {
|
||||
logD("Wiping state")
|
||||
withContext(Dispatchers.IO) { database.write(null) }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
|
@ -532,7 +536,6 @@ class PlaybackStateManager private constructor() {
|
|||
logE(e.stackTraceToString())
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the playback state to align with a new [MusicStore.Library].
|
||||
|
@ -586,52 +589,52 @@ class PlaybackStateManager private constructor() {
|
|||
// --- CALLBACKS ---
|
||||
|
||||
private fun notifyIndexMoved() {
|
||||
for (callback in callbacks) {
|
||||
for (callback in listeners) {
|
||||
callback.onIndexMoved(index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyQueueChanged() {
|
||||
for (callback in callbacks) {
|
||||
for (callback in listeners) {
|
||||
callback.onQueueChanged(queue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyQueueReworked() {
|
||||
for (callback in callbacks) {
|
||||
for (callback in listeners) {
|
||||
callback.onQueueReworked(index, queue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyNewPlayback() {
|
||||
for (callback in callbacks) {
|
||||
for (callback in listeners) {
|
||||
callback.onNewPlayback(index, queue, parent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyStateChanged() {
|
||||
for (callback in callbacks) {
|
||||
for (callback in listeners) {
|
||||
callback.onStateChanged(playerState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyRepeatModeChanged() {
|
||||
for (callback in callbacks) {
|
||||
for (callback in listeners) {
|
||||
callback.onRepeatChanged(repeatMode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyShuffledChanged() {
|
||||
for (callback in callbacks) {
|
||||
for (callback in listeners) {
|
||||
callback.onShuffledChanged(isShuffled)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to
|
||||
* [PlaybackStateManager] using [addCallback], remove them on destruction with [removeCallback].
|
||||
* [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener].
|
||||
*/
|
||||
interface Callback {
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the position of the currently playing item has changed, changing the current
|
||||
* [Song], but no other queue attribute has changed.
|
||||
|
|
|
@ -19,6 +19,7 @@ package org.oxycblt.auxio.playback.system
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
|
@ -43,11 +44,13 @@ import org.oxycblt.auxio.util.logD
|
|||
* A component that mirrors the current playback state into the [MediaSessionCompat] and
|
||||
* [NotificationComponent].
|
||||
* @param context [Context] required to initialize components.
|
||||
* @param callback [Callback] to forward notification updates to.
|
||||
* @param listener [Listener] to forward notification updates to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MediaSessionComponent(private val context: Context, private val callback: Callback) :
|
||||
MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback {
|
||||
class MediaSessionComponent(private val context: Context, private val listener: Listener) :
|
||||
MediaSessionCompat.Callback(),
|
||||
PlaybackStateManager.Listener,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private val mediaSession =
|
||||
MediaSessionCompat(context, context.packageName).apply {
|
||||
isActive = true
|
||||
|
@ -55,13 +58,13 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
}
|
||||
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val settings = Settings(context, this)
|
||||
private val settings = Settings(context)
|
||||
|
||||
private val notification = NotificationComponent(context, mediaSession.sessionToken)
|
||||
private val provider = BitmapProvider(context)
|
||||
|
||||
init {
|
||||
playbackManager.addCallback(this)
|
||||
playbackManager.addListener(this)
|
||||
mediaSession.setCallback(this)
|
||||
}
|
||||
|
||||
|
@ -79,15 +82,15 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
*/
|
||||
fun release() {
|
||||
provider.release()
|
||||
settings.release()
|
||||
playbackManager.removeCallback(this)
|
||||
settings.removeListener(this)
|
||||
playbackManager.removeListener(this)
|
||||
mediaSession.apply {
|
||||
isActive = false
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// --- PLAYBACKSTATEMANAGER CALLBACKS ---
|
||||
// --- PLAYBACKSTATEMANAGER OVERRIDES ---
|
||||
|
||||
override fun onIndexMoved(index: Int) {
|
||||
updateMediaMetadata(playbackManager.song, playbackManager.parent)
|
||||
|
@ -113,7 +116,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
invalidateSessionState()
|
||||
notification.updatePlaying(playbackManager.playerState.isPlaying)
|
||||
if (!provider.isBusy) {
|
||||
callback.onPostNotification(notification)
|
||||
listener.onPostNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -139,9 +142,9 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
invalidateSecondaryAction()
|
||||
}
|
||||
|
||||
// --- SETTINGSMANAGER CALLBACKS ---
|
||||
// --- SETTINGS OVERRIDES ---
|
||||
|
||||
override fun onSettingChanged(key: String) {
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
when (key) {
|
||||
context.getString(R.string.set_key_cover_mode) ->
|
||||
updateMediaMetadata(playbackManager.song, playbackManager.parent)
|
||||
|
@ -149,7 +152,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
}
|
||||
}
|
||||
|
||||
// --- MEDIASESSION CALLBACKS ---
|
||||
// --- MEDIASESSION OVERRIDES ---
|
||||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
super.onPlayFromMediaId(mediaId, extras)
|
||||
|
@ -306,7 +309,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
val metadata = builder.build()
|
||||
mediaSession.setMetadata(metadata)
|
||||
notification.updateMetadata(metadata)
|
||||
callback.onPostNotification(notification)
|
||||
listener.onPostNotification(notification)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -393,12 +396,12 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
|||
}
|
||||
|
||||
if (!provider.isBusy) {
|
||||
callback.onPostNotification(notification)
|
||||
listener.onPostNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
/** An interface for handling changes in the notification configuration. */
|
||||
interface Callback {
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the [NotificationComponent] changes, requiring it to be re-posed.
|
||||
* @param notification The new [NotificationComponent].
|
||||
|
|
|
@ -148,9 +148,9 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
/** Notification channel used by solely the playback notification. */
|
||||
private val CHANNEL_INFO =
|
||||
val CHANNEL_INFO =
|
||||
ChannelInfo(
|
||||
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
|
||||
nameRes = R.string.lbl_playback)
|
||||
|
|
|
@ -31,7 +31,6 @@ import com.google.android.exoplayer2.MediaItem
|
|||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.RenderersFactory
|
||||
import com.google.android.exoplayer2.Tracks
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.audio.AudioCapabilities
|
||||
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
|
||||
|
@ -44,7 +43,6 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||
|
@ -79,9 +77,8 @@ class PlaybackService :
|
|||
Service(),
|
||||
Player.Listener,
|
||||
InternalPlayer,
|
||||
MediaSessionComponent.Callback,
|
||||
Settings.Callback,
|
||||
MusicStore.Callback {
|
||||
MediaSessionComponent.Listener,
|
||||
MusicStore.Listener {
|
||||
// Player components
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var replayGainProcessor: ReplayGainAudioProcessor
|
||||
|
@ -143,13 +140,14 @@ class PlaybackService :
|
|||
true)
|
||||
.build()
|
||||
.also { it.addListener(this) }
|
||||
replayGainProcessor.addToListeners(player)
|
||||
// Initialize the core service components
|
||||
settings = Settings(this, this)
|
||||
settings = Settings(this)
|
||||
foregroundManager = ForegroundManager(this)
|
||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
playbackManager.registerInternalPlayer(this)
|
||||
musicStore.addCallback(this)
|
||||
musicStore.addListener(this)
|
||||
widgetComponent = WidgetComponent(this)
|
||||
mediaSessionComponent = MediaSessionComponent(this, this)
|
||||
registerReceiver(
|
||||
|
@ -185,12 +183,11 @@ class PlaybackService :
|
|||
super.onDestroy()
|
||||
|
||||
foregroundManager.release()
|
||||
settings.release()
|
||||
|
||||
// Pause just in case this destruction was unexpected.
|
||||
playbackManager.setPlaying(false)
|
||||
playbackManager.unregisterInternalPlayer(this)
|
||||
musicStore.removeCallback(this)
|
||||
musicStore.removeListener(this)
|
||||
|
||||
unregisterReceiver(systemReceiver)
|
||||
serviceJob.cancel()
|
||||
|
@ -198,6 +195,7 @@ class PlaybackService :
|
|||
widgetComponent.release()
|
||||
mediaSessionComponent.release()
|
||||
|
||||
replayGainProcessor.releaseFromListeners(player)
|
||||
player.release()
|
||||
if (openAudioEffectSession) {
|
||||
// Make sure to close the audio session when we release the player.
|
||||
|
@ -217,7 +215,7 @@ class PlaybackService :
|
|||
get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
|
||||
|
||||
override fun getState(durationMs: Long) =
|
||||
InternalPlayer.State.new(
|
||||
InternalPlayer.State.from(
|
||||
player.playWhenReady,
|
||||
player.isPlaying,
|
||||
// The position value can be below zero or past the expected duration, make
|
||||
|
@ -302,24 +300,6 @@ class PlaybackService :
|
|||
playbackManager.next()
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
super.onTracksChanged(tracks)
|
||||
// Try to find the currently playing track so we can update ReplayGainAudioProcessor
|
||||
// with it.
|
||||
for (group in tracks.groups) {
|
||||
if (group.isSelected) {
|
||||
for (i in 0 until group.length) {
|
||||
if (group.isTrackSelected(i)) {
|
||||
replayGainProcessor.applyReplayGain(group.getTrackFormat(i).metadata)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MUSICSTORE OVERRIDES ---
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
|
@ -329,16 +309,6 @@ class PlaybackService :
|
|||
}
|
||||
}
|
||||
|
||||
// --- SETTINGSMANAGER OVERRIDES ---
|
||||
|
||||
override fun onSettingChanged(key: String) {
|
||||
if (key == getString(R.string.set_key_replay_gain) ||
|
||||
key == getString(R.string.set_key_pre_amp_with) ||
|
||||
key == getString(R.string.set_key_pre_amp_without)) {
|
||||
onTracksChanged(player.currentTracks)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OTHER FUNCTIONS ---
|
||||
|
||||
private fun broadcastAudioEffectAction(event: String) {
|
||||
|
|
|
@ -51,11 +51,11 @@ class SearchAdapter(private val listener: SelectableListListener) :
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
when (viewType) {
|
||||
SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent)
|
||||
AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.new(parent)
|
||||
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.new(parent)
|
||||
GenreViewHolder.VIEW_TYPE -> GenreViewHolder.new(parent)
|
||||
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.new(parent)
|
||||
SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
|
||||
AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.from(parent)
|
||||
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
|
||||
GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent)
|
||||
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
|
||||
else -> error("Invalid item type $viewType")
|
||||
}
|
||||
|
||||
|
@ -81,9 +81,9 @@ class SearchAdapter(private val listener: SelectableListListener) :
|
|||
differ.submitList(newList, callback)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
private val DIFF_CALLBACK =
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
|
||||
when {
|
||||
|
|
|
@ -53,10 +53,8 @@ import org.oxycblt.auxio.util.*
|
|||
class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||
private val searchModel: SearchViewModel by androidViewModels()
|
||||
private val searchAdapter = SearchAdapter(this)
|
||||
private var imm: InputMethodManager? = null
|
||||
private var launchedKeyboard = false
|
||||
private val imm: InputMethodManager by lifecycleObject { binding ->
|
||||
binding.context.getSystemServiceCompat(InputMethodManager::class)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -74,13 +72,15 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
|||
override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
imm = binding.context.getSystemServiceCompat(InputMethodManager::class)
|
||||
|
||||
binding.searchToolbar.apply {
|
||||
// Initialize the current filtering mode.
|
||||
menu.findItem(searchModel.getFilterOptionId()).isChecked = true
|
||||
|
||||
setNavigationOnClickListener {
|
||||
// Keyboard is no longer needed.
|
||||
imm.hide()
|
||||
hideKeyboard()
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
|
||||
|
@ -95,7 +95,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
|||
|
||||
if (!launchedKeyboard) {
|
||||
// Auto-open the keyboard when this view is shown
|
||||
imm.show(this)
|
||||
showKeyboard(this)
|
||||
launchedKeyboard = true
|
||||
}
|
||||
}
|
||||
|
@ -184,7 +184,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
|||
else -> return
|
||||
}
|
||||
// Keyboard is no longer needed.
|
||||
imm.hide()
|
||||
hideKeyboard()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
|
||||
|
@ -193,7 +193,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
|||
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
|
||||
selected.isNotEmpty()) {
|
||||
// Make selection of obscured items easier by hiding the keyboard.
|
||||
imm.hide()
|
||||
hideKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -201,15 +201,19 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
|||
* Safely focus the keyboard on a particular [View].
|
||||
* @param view The [View] to focus the keyboard on.
|
||||
*/
|
||||
private fun InputMethodManager.show(view: View) {
|
||||
private fun showKeyboard(view: View) {
|
||||
view.apply {
|
||||
requestFocus()
|
||||
postDelayed(200) { showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) }
|
||||
postDelayed(200) {
|
||||
requireNotNull(imm) { "InputMethodManager was not available" }
|
||||
.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Safely hide the keyboard from this view. */
|
||||
private fun InputMethodManager.hide() {
|
||||
hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||
private fun hideKeyboard() {
|
||||
requireNotNull(imm) { "InputMethodManager was not available" }
|
||||
.hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.logD
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SearchViewModel(application: Application) :
|
||||
AndroidViewModel(application), MusicStore.Callback {
|
||||
AndroidViewModel(application), MusicStore.Listener {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
private val settings = Settings(context)
|
||||
private var lastQuery: String? = null
|
||||
|
@ -55,12 +55,12 @@ class SearchViewModel(application: Application) :
|
|||
get() = _searchResults
|
||||
|
||||
init {
|
||||
musicStore.addCallback(this)
|
||||
musicStore.addListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicStore.removeCallback(this)
|
||||
musicStore.removeListener(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
|
@ -212,11 +212,11 @@ class SearchViewModel(application: Application) :
|
|||
search(lastQuery)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
/**
|
||||
* Converts the output of [Normalizer] to remove any junk characters added by it's
|
||||
* replacements.
|
||||
*/
|
||||
private val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
|
||||
val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -152,14 +152,14 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
|||
startActivity(chooserIntent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
/** The URL to the source code. */
|
||||
private const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio"
|
||||
const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio"
|
||||
/** The URL to the app wiki. */
|
||||
private const val LINK_WIKI = "$LINK_SOURCE/wiki"
|
||||
const val LINK_WIKI = "$LINK_SOURCE/wiki"
|
||||
/** The URL to the licenses wiki page. */
|
||||
private const val LINK_LICENSES = "$LINK_WIKI/Licenses"
|
||||
const val LINK_LICENSES = "$LINK_WIKI/Licenses"
|
||||
/** The URL to the app author. */
|
||||
private const val LINK_AUTHOR = "https://github.com/OxygenCobalt"
|
||||
const val LINK_AUTHOR = "https://github.com/OxygenCobalt"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ package org.oxycblt.auxio.settings
|
|||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
|
@ -30,8 +31,8 @@ import org.oxycblt.auxio.home.tabs.Tab
|
|||
import org.oxycblt.auxio.image.CoverMode
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.music.storage.Directory
|
||||
import org.oxycblt.auxio.music.storage.MusicDirectories
|
||||
import org.oxycblt.auxio.music.filesystem.Directory
|
||||
import org.oxycblt.auxio.music.filesystem.MusicDirectories
|
||||
import org.oxycblt.auxio.playback.ActionMode
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
|
||||
|
@ -40,20 +41,14 @@ import org.oxycblt.auxio.util.logD
|
|||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Object
|
||||
* mutability
|
||||
* A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Member
|
||||
* mutability is dependent on how they are used in app. Immutable members are often only modified by
|
||||
* the preferences view, while mutable members are modified elsewhere.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Settings(private val context: Context, private val callback: Callback? = null) :
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
class Settings(private val context: Context) {
|
||||
private val inner = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
|
||||
|
||||
init {
|
||||
if (callback != null) {
|
||||
inner.registerOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate any settings from an old version into their modern counterparts. This can cause data
|
||||
* loss depending on the feasibility of a migration.
|
||||
|
@ -154,27 +149,19 @@ class Settings(private val context: Context, private val callback: Callback? = n
|
|||
}
|
||||
|
||||
/**
|
||||
* Release this instance and any callbacks held by it. This is not needed if no [Callback] was
|
||||
* originally attached.
|
||||
* Add a [SharedPreferences.OnSharedPreferenceChangeListener] to monitor for settings updates.
|
||||
* @param listener The [SharedPreferences.OnSharedPreferenceChangeListener] to add.
|
||||
*/
|
||||
fun release() {
|
||||
inner.unregisterOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
unlikelyToBeNull(callback).onSettingChanged(key)
|
||||
fun addListener(listener: OnSharedPreferenceChangeListener) {
|
||||
inner.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified callback for settings changes.
|
||||
* Unregister a [SharedPreferences.OnSharedPreferenceChangeListener], preventing any further
|
||||
* settings updates from being sent to ti.t
|
||||
*/
|
||||
interface Callback {
|
||||
// TODO: Refactor this lifecycle
|
||||
/**
|
||||
* Called when a setting has changed.
|
||||
* @param key The key of the setting that changed.
|
||||
*/
|
||||
fun onSettingChanged(key: String)
|
||||
fun removeListener(listener: OnSharedPreferenceChangeListener) {
|
||||
inner.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
// --- VALUES ---
|
||||
|
|
|
@ -162,8 +162,8 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PREFERENCE_DEFAULT_VALUE_FIELD: Field by
|
||||
private companion object {
|
||||
val PREFERENCE_DEFAULT_VALUE_FIELD: Field by
|
||||
lazyReflectedField(Preference::class, "mDefaultValue")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import org.oxycblt.auxio.BuildConfig
|
|||
import org.oxycblt.auxio.R
|
||||
|
||||
/**
|
||||
* The companion dialog to [IntListPreference]. Use [new] to create an instance.
|
||||
* The companion dialog to [IntListPreference]. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
|
||||
|
@ -62,11 +62,10 @@ class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
|
|||
* @param preference The [IntListPreference] to display.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(preference: IntListPreference): IntListPreferenceDialog {
|
||||
return IntListPreferenceDialog().apply {
|
||||
fun from(preference: IntListPreference) =
|
||||
IntListPreferenceDialog().apply {
|
||||
// Populate the key field required by PreferenceDialogFragmentCompat.
|
||||
arguments = Bundle().apply { putString(ARG_KEY, preference.key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,7 +76,7 @@ class PreferenceFragment : PreferenceFragmentCompat() {
|
|||
is IntListPreference -> {
|
||||
// Copy the built-in preference dialog launching code into our project so
|
||||
// we can automatically use the provided preference class.
|
||||
val dialog = IntListPreferenceDialog.new(preference)
|
||||
val dialog = IntListPreferenceDialog.from(preference)
|
||||
dialog.setTargetFragment(this, 0)
|
||||
dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG)
|
||||
}
|
||||
|
@ -104,46 +104,44 @@ class PreferenceFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
val context = requireContext()
|
||||
|
||||
// Hook generic preferences to their specified preferences
|
||||
// TODO: These seem like good things to put into a side navigation view, if I choose to
|
||||
// do one.
|
||||
when (preference.key) {
|
||||
context.getString(R.string.set_key_save_state) -> {
|
||||
getString(R.string.set_key_save_state) -> {
|
||||
playbackModel.savePlaybackState { saved ->
|
||||
// Use the nullable context, as we could try to show a toast when this
|
||||
// fragment is no longer attached.
|
||||
if (saved) {
|
||||
this.context?.showToast(R.string.lbl_state_saved)
|
||||
context?.showToast(R.string.lbl_state_saved)
|
||||
} else {
|
||||
this.context?.showToast(R.string.err_did_not_save)
|
||||
context?.showToast(R.string.err_did_not_save)
|
||||
}
|
||||
}
|
||||
}
|
||||
context.getString(R.string.set_key_wipe_state) -> {
|
||||
getString(R.string.set_key_wipe_state) -> {
|
||||
playbackModel.wipePlaybackState { wiped ->
|
||||
if (wiped) {
|
||||
// Use the nullable context, as we could try to show a toast when this
|
||||
// fragment is no longer attached.
|
||||
this.context?.showToast(R.string.lbl_state_wiped)
|
||||
context?.showToast(R.string.lbl_state_wiped)
|
||||
} else {
|
||||
this.context?.showToast(R.string.err_did_not_wipe)
|
||||
context?.showToast(R.string.err_did_not_wipe)
|
||||
}
|
||||
}
|
||||
}
|
||||
context.getString(R.string.set_key_restore_state) ->
|
||||
getString(R.string.set_key_restore_state) ->
|
||||
playbackModel.tryRestorePlaybackState { restored ->
|
||||
if (restored) {
|
||||
// Use the nullable context, as we could try to show a toast when this
|
||||
// fragment is no longer attached.
|
||||
this.context?.showToast(R.string.lbl_state_restored)
|
||||
context?.showToast(R.string.lbl_state_restored)
|
||||
} else {
|
||||
this.context?.showToast(R.string.err_did_not_restore)
|
||||
context?.showToast(R.string.err_did_not_restore)
|
||||
}
|
||||
}
|
||||
context.getString(R.string.set_key_reindex) -> musicModel.refresh()
|
||||
context.getString(R.string.set_key_rescan) -> musicModel.rescan()
|
||||
getString(R.string.set_key_reindex) -> musicModel.refresh()
|
||||
getString(R.string.set_key_rescan) -> musicModel.rescan()
|
||||
else -> return super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
|
||||
|
@ -151,8 +149,7 @@ class PreferenceFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
|
||||
private fun setupPreference(preference: Preference) {
|
||||
val context = requireActivity()
|
||||
val settings = Settings(context)
|
||||
val settings = Settings(requireContext())
|
||||
|
||||
if (!preference.isVisible) {
|
||||
// Nothing to do.
|
||||
|
@ -165,30 +162,31 @@ class PreferenceFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
|
||||
when (preference.key) {
|
||||
context.getString(R.string.set_key_theme) -> {
|
||||
getString(R.string.set_key_theme) -> {
|
||||
preference.onPreferenceChangeListener =
|
||||
Preference.OnPreferenceChangeListener { _, value ->
|
||||
AppCompatDelegate.setDefaultNightMode(value as Int)
|
||||
true
|
||||
}
|
||||
}
|
||||
context.getString(R.string.set_key_accent) -> {
|
||||
preference.summary = context.getString(settings.accent.name)
|
||||
getString(R.string.set_key_accent) -> {
|
||||
preference.summary = getString(settings.accent.name)
|
||||
}
|
||||
context.getString(R.string.set_key_black_theme) -> {
|
||||
getString(R.string.set_key_black_theme) -> {
|
||||
preference.onPreferenceChangeListener =
|
||||
Preference.OnPreferenceChangeListener { _, _ ->
|
||||
if (context.isNight) {
|
||||
context.recreate()
|
||||
val activity = requireActivity()
|
||||
if (activity.isNight) {
|
||||
activity.recreate()
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
context.getString(R.string.set_key_cover_mode) -> {
|
||||
getString(R.string.set_key_cover_mode) -> {
|
||||
preference.onPreferenceChangeListener =
|
||||
Preference.OnPreferenceChangeListener { _, _ ->
|
||||
Coil.imageLoader(context).memoryCache?.clear()
|
||||
Coil.imageLoader(requireContext()).memoryCache?.clear()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
|||
*
|
||||
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
open class AuxioAppBarLayout
|
||||
open class CoordinatorAppBarLayout
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AppBarLayout(context, attrs, defStyleAttr) {
|
||||
|
@ -68,14 +68,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
/**
|
||||
* Expand this [AppBarLayout] with respect to the given [RecyclerView], preventing it from
|
||||
* jumping around.
|
||||
* @param recycler [RecyclerView] to expand with, or null if one is currently unavailable.
|
||||
* Expand this [AppBarLayout] with respect to the current [RecyclerView] at
|
||||
* [liftOnScrollTargetViewId], preventing it from jumping around.
|
||||
*/
|
||||
fun expandWithRecycler(recycler: RecyclerView?) {
|
||||
// TODO: Is it possible to use liftOnScrollTargetViewId to avoid the RecyclerView arg?
|
||||
fun expandWithScrollingRecycler() {
|
||||
setExpanded(true)
|
||||
recycler?.let { addOnOffsetChangedListener(ExpansionHackListener(it)) }
|
||||
(findScrollingChild() as? RecyclerView)?.let {
|
||||
addOnOffsetChangedListener(ExpansionHackListener(it))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
|
@ -136,8 +136,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private companion object {
|
||||
/** @see AppBarLayout.BaseBehavior.MAX_OFFSET_ANIMATION_DURATION */
|
||||
private const val APP_BAR_LAYOUT_MAX_OFFSET_ANIMATION_DURATION = 600
|
||||
const val APP_BAR_LAYOUT_MAX_OFFSET_ANIMATION_DURATION = 600
|
||||
}
|
||||
}
|
|
@ -92,8 +92,8 @@ class NavigationViewModel : ViewModel() {
|
|||
|
||||
/**
|
||||
* Navigate to one of the parent [Artist]'s of the given [Song].
|
||||
* @param song The [Song] to navigate with. If there are multiple parent [Artist]s,
|
||||
* a picker dialog will be shown.
|
||||
* @param song The [Song] to navigate with. If there are multiple parent [Artist]s, a picker
|
||||
* dialog will be shown.
|
||||
*/
|
||||
fun exploreNavigateToParentArtist(song: Song) {
|
||||
exploreNavigateToParentArtistImpl(song, song.artists)
|
||||
|
@ -101,8 +101,8 @@ class NavigationViewModel : ViewModel() {
|
|||
|
||||
/**
|
||||
* Navigate to one of the parent [Artist]'s of the given [Album].
|
||||
* @param album The [Album] to navigate with. If there are multiple parent [Artist]s,
|
||||
* a picker dialog will be shown.
|
||||
* @param album The [Album] to navigate with. If there are multiple parent [Artist]s, a picker
|
||||
* dialog will be shown.
|
||||
*/
|
||||
fun exploreNavigateToParentArtist(album: Album) {
|
||||
exploreNavigateToParentArtistImpl(album, album.artists)
|
||||
|
|
|
@ -23,11 +23,8 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.reflect.KProperty
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
|
@ -37,7 +34,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
*/
|
||||
abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
|
||||
private var _binding: VB? = null
|
||||
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
|
||||
|
||||
/**
|
||||
* Configure the [AlertDialog.Builder] during [onCreateDialog].
|
||||
|
@ -85,25 +81,6 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate to automatically create and destroy an object derived from the [ViewBinding].
|
||||
* @param create Block to create the object from the [ViewBinding].
|
||||
*/
|
||||
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> {
|
||||
lifecycleObjects.add(LifecycleObject(null, create))
|
||||
|
||||
return object : ReadOnlyProperty<Fragment, T> {
|
||||
private val objIdx = lifecycleObjects.lastIndex
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun getValue(thisRef: Fragment, property: KProperty<*>) =
|
||||
requireNotNull(lifecycleObjects[objIdx].data) {
|
||||
"Cannot access lifecycle object when view does not exist"
|
||||
}
|
||||
as T
|
||||
}
|
||||
}
|
||||
|
||||
final override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
@ -119,9 +96,6 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
|
|||
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val binding = unlikelyToBeNull(_binding)
|
||||
// Populate lifecycle-dependent objects
|
||||
lifecycleObjects.forEach { it.populate(binding) }
|
||||
// Configure binding
|
||||
onBindingCreated(requireBinding(), savedInstanceState)
|
||||
// Apply the newly-configured view to the dialog.
|
||||
|
@ -132,21 +106,8 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
|
|||
final override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
onDestroyBinding(unlikelyToBeNull(_binding))
|
||||
// Clear the lifecycle-dependent objects
|
||||
lifecycleObjects.forEach { it.clear() }
|
||||
// Clear binding
|
||||
_binding = null
|
||||
logD("Fragment destroyed")
|
||||
}
|
||||
|
||||
/** Internal implementation of [lifecycleObject]. */
|
||||
private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) {
|
||||
fun populate(binding: VB) {
|
||||
data = create(binding)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
data = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,8 +23,6 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.reflect.KProperty
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
|
@ -34,7 +32,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
*/
|
||||
abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
|
||||
private var _binding: VB? = null
|
||||
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
|
||||
|
||||
/**
|
||||
* Inflate the [ViewBinding] during [onCreateView].
|
||||
|
@ -75,26 +72,6 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate to automatically create and destroy an object derived from the [ViewBinding].
|
||||
* @param create Block to create the object from the [ViewBinding].
|
||||
*/
|
||||
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> {
|
||||
// TODO: Phase this out.
|
||||
lifecycleObjects.add(LifecycleObject(null, create))
|
||||
|
||||
return object : ReadOnlyProperty<Fragment, T> {
|
||||
private val objIdx = lifecycleObjects.lastIndex
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun getValue(thisRef: Fragment, property: KProperty<*>) =
|
||||
requireNotNull(lifecycleObjects[objIdx].data) {
|
||||
"Cannot access lifecycle object when view does not exist"
|
||||
}
|
||||
as T
|
||||
}
|
||||
}
|
||||
|
||||
final override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
@ -103,9 +80,6 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
|
|||
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val binding = unlikelyToBeNull(_binding)
|
||||
// Populate lifecycle-dependent objects
|
||||
lifecycleObjects.forEach { it.populate(binding) }
|
||||
// Configure binding
|
||||
onBindingCreated(requireBinding(), savedInstanceState)
|
||||
logD("Fragment created")
|
||||
|
@ -114,21 +88,8 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
|
|||
final override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
onDestroyBinding(unlikelyToBeNull(_binding))
|
||||
// Clear the lifecycle-dependent objects
|
||||
lifecycleObjects.forEach { it.clear() }
|
||||
// Clear binding
|
||||
_binding = null
|
||||
logD("Fragment destroyed")
|
||||
}
|
||||
|
||||
/** Internal implementation of [lifecycleObject]. */
|
||||
private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) {
|
||||
fun populate(binding: VB) {
|
||||
data = create(binding)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
data = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,7 +41,8 @@ class AccentAdapter(private val listener: ClickableListListener) :
|
|||
|
||||
override fun getItemCount() = Accent.MAX
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AccentViewHolder.new(parent)
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
AccentViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: AccentViewHolder, position: Int) =
|
||||
throw NotImplementedError()
|
||||
|
@ -75,13 +76,13 @@ class AccentAdapter(private val listener: ClickableListListener) :
|
|||
notifyItemChanged(accent.index, PAYLOAD_SELECTION_CHANGED)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PAYLOAD_SELECTION_CHANGED = Any()
|
||||
private companion object {
|
||||
val PAYLOAD_SELECTION_CHANGED = Any()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays an [Accent] choice. Use [new] to create an instance.
|
||||
* A [RecyclerView.ViewHolder] that displays an [Accent] choice. Use [from] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AccentViewHolder private constructor(private val binding: ItemAccentBinding) :
|
||||
|
@ -93,13 +94,13 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin
|
|||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(accent: Accent, listener: ClickableListListener) {
|
||||
listener.bind(accent, this, binding.accent)
|
||||
binding.accent.apply {
|
||||
setOnClickListener { listener.onClick(accent) }
|
||||
backgroundTintList = context.getColorCompat(accent.primary)
|
||||
// Add a Tooltip based on the content description so that the purpose of this
|
||||
// button can be clear.
|
||||
contentDescription = context.getString(accent.name)
|
||||
TooltipCompat.setTooltipText(this, contentDescription)
|
||||
backgroundTintList = context.getColorCompat(accent.primary)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -124,6 +125,7 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin
|
|||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = AccentViewHolder(ItemAccentBinding.inflate(parent.context.inflater))
|
||||
fun from(parent: View) =
|
||||
AccentViewHolder(ItemAccentBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.ui.accent
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogAccentBinding
|
||||
|
@ -27,7 +28,6 @@ import org.oxycblt.auxio.list.ClickableListListener
|
|||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
|
@ -38,7 +38,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
class AccentCustomizeDialog :
|
||||
ViewBindingDialogFragment<DialogAccentBinding>(), ClickableListListener {
|
||||
private var accentAdapter = AccentAdapter(this)
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater)
|
||||
|
||||
|
@ -46,6 +45,7 @@ class AccentCustomizeDialog :
|
|||
builder
|
||||
.setTitle(R.string.set_accent)
|
||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||
val settings = Settings(requireContext())
|
||||
if (accentAdapter.selectedAccent == settings.accent) {
|
||||
// Nothing to do.
|
||||
return@setPositiveButton
|
||||
|
@ -66,7 +66,7 @@ class AccentCustomizeDialog :
|
|||
if (savedInstanceState != null) {
|
||||
Accent.from(savedInstanceState.getInt(KEY_PENDING_ACCENT))
|
||||
} else {
|
||||
settings.accent
|
||||
Settings(requireContext()).accent
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -80,12 +80,12 @@ class AccentCustomizeDialog :
|
|||
binding.accentRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||
check(item is Accent) { "Unexpected datatype: ${item::class.java}" }
|
||||
accentAdapter.setSelectedAccent(item)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT"
|
||||
private companion object {
|
||||
const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
package org.oxycblt.auxio.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import coil.request.ImageRequest
|
||||
|
@ -41,14 +42,15 @@ import org.oxycblt.auxio.util.logD
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class WidgetComponent(private val context: Context) :
|
||||
PlaybackStateManager.Callback, Settings.Callback {
|
||||
PlaybackStateManager.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val settings = Settings(context, this)
|
||||
private val settings = Settings(context)
|
||||
private val widgetProvider = WidgetProvider()
|
||||
private val provider = BitmapProvider(context)
|
||||
|
||||
init {
|
||||
playbackManager.addCallback(this)
|
||||
playbackManager.addListener(this)
|
||||
settings.addListener(this)
|
||||
}
|
||||
|
||||
/** Update [WidgetProvider] with the current playback state. */
|
||||
|
@ -104,9 +106,9 @@ class WidgetComponent(private val context: Context) :
|
|||
/** Release this instance, preventing any further events from updating the widget instances. */
|
||||
fun release() {
|
||||
provider.release()
|
||||
settings.release()
|
||||
settings.removeListener(this)
|
||||
widgetProvider.reset(context)
|
||||
playbackManager.removeCallback(this)
|
||||
playbackManager.removeListener(this)
|
||||
}
|
||||
|
||||
// --- CALLBACKS ---
|
||||
|
@ -118,7 +120,8 @@ class WidgetComponent(private val context: Context) :
|
|||
override fun onStateChanged(state: InternalPlayer.State) = update()
|
||||
override fun onShuffledChanged(isShuffled: Boolean) = update()
|
||||
override fun onRepeatChanged(repeatMode: RepeatMode) = update()
|
||||
override fun onSettingChanged(key: String) {
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
if (key == context.getString(R.string.set_key_cover_mode) ||
|
||||
key == context.getString(R.string.set_key_round_mode)) {
|
||||
update()
|
||||
|
|
|
@ -80,8 +80,8 @@ fun RemoteViews.setLayoutDirection(@IdRes viewId: Int, layoutDirection: Int) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update the app widget layouts corresponding to the given [WidgetProvider] [ComponentName] with
|
||||
* an adaptive layout, in a version-compatible manner.
|
||||
* Update the app widget layouts corresponding to the given [WidgetProvider] [ComponentName] with an
|
||||
* adaptive layout, in a version-compatible manner.
|
||||
* @param context [Context] required to backport adaptive layout behavior.
|
||||
* @param component [ComponentName] of the app widget layout to update.
|
||||
* @param views Mapping between different size classes and [RemoteViews] instances.
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
android:transitionGroup="true"
|
||||
tools:context=".settings.AboutFragment">
|
||||
|
||||
<org.oxycblt.auxio.ui.AuxioAppBarLayout
|
||||
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||
android:id="@+id/about_appbar"
|
||||
style="@style/Widget.Auxio.AppBarLayout"
|
||||
app:liftOnScroll="true">
|
||||
|
@ -21,7 +21,7 @@
|
|||
app:navigationIcon="@drawable/ic_back_24"
|
||||
app:title="@string/lbl_about" />
|
||||
|
||||
</org.oxycblt.auxio.ui.AuxioAppBarLayout>
|
||||
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/about_contents"
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
android:background="?attr/colorSurface"
|
||||
android:transitionGroup="true">
|
||||
|
||||
<org.oxycblt.auxio.ui.AuxioAppBarLayout
|
||||
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||
android:id="@+id/home_appbar"
|
||||
style="@style/Widget.Auxio.AppBarLayout"
|
||||
android:fitsSystemWindows="true">
|
||||
|
@ -37,7 +37,7 @@
|
|||
app:tabGravity="start"
|
||||
app:tabMode="scrollable" />
|
||||
|
||||
</org.oxycblt.auxio.ui.AuxioAppBarLayout>
|
||||
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
|
||||
|
||||
|
||||
<FrameLayout
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
android:background="?attr/colorSurface"
|
||||
android:transitionGroup="true">
|
||||
|
||||
<org.oxycblt.auxio.ui.AuxioAppBarLayout
|
||||
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||
style="@style/Widget.Auxio.AppBarLayout"
|
||||
app:liftOnScroll="true"
|
||||
app:liftOnScrollTargetViewId="@id/search_recycler">
|
||||
|
@ -51,7 +51,7 @@
|
|||
|
||||
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
|
||||
|
||||
</org.oxycblt.auxio.ui.AuxioAppBarLayout>
|
||||
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
|
||||
|
||||
<org.oxycblt.auxio.list.recycler.AuxioRecyclerView
|
||||
android:id="@+id/search_recycler"
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
android:orientation="vertical"
|
||||
android:transitionGroup="true">
|
||||
|
||||
<org.oxycblt.auxio.ui.AuxioAppBarLayout
|
||||
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||
android:id="@+id/settings_appbar"
|
||||
style="@style/Widget.Auxio.AppBarLayout"
|
||||
android:clickable="true"
|
||||
|
@ -22,7 +22,7 @@
|
|||
app:navigationIcon="@drawable/ic_back_24"
|
||||
app:title="@string/set_title" />
|
||||
|
||||
</org.oxycblt.auxio.ui.AuxioAppBarLayout>
|
||||
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/settings_list_fragment"
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
style="@style/Widget.Auxio.Button.Icon.Small"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginEnd="@dimen/spacing_mid_large"
|
||||
android:contentDescription="@string/desc_music_dir_delete"
|
||||
app:icon="@drawable/ic_delete_24"
|
||||
|
|
|
@ -106,12 +106,12 @@
|
|||
tools:layout="@layout/dialog_pre_amp" />
|
||||
<dialog
|
||||
android:id="@+id/music_dirs_dialog"
|
||||
android:name="org.oxycblt.auxio.music.storage.MusicDirsDialog"
|
||||
android:name="org.oxycblt.auxio.music.filesystem.MusicDirsDialog"
|
||||
android:label="music_dirs_dialog"
|
||||
tools:layout="@layout/dialog_music_dirs" />
|
||||
<dialog
|
||||
android:id="@+id/separators_dialog"
|
||||
android:name="org.oxycblt.auxio.music.extractor.SeparatorsDialog"
|
||||
android:name="org.oxycblt.auxio.music.parsing.SeparatorsDialog"
|
||||
android:label="music_dirs_dialog"
|
||||
tools:layout="@layout/dialog_separators" />
|
||||
|
||||
|
|
|
@ -268,4 +268,6 @@
|
|||
<string name="lbl_shuffle_selected">Náhodně přehrát vybrané</string>
|
||||
<string name="set_playback_mode_genre">Přehrát z žánru</string>
|
||||
<string name="lbl_wiki">Wiki</string>
|
||||
<string name="fmt_list">%1$s, %2$s</string>
|
||||
<string name="lbl_reset">Obnovit</string>
|
||||
</resources>
|
|
@ -259,4 +259,5 @@
|
|||
<string name="fmt_selected">%d ausgewählt</string>
|
||||
<string name="set_playback_mode_genre">Vom Genre abspielen</string>
|
||||
<string name="lbl_wiki">Wiki</string>
|
||||
<string name="fmt_list">%1$s, %2$s</string>
|
||||
</resources>
|
|
@ -128,12 +128,10 @@
|
|||
<string name="fmt_lib_song_count">Canciones cargadas: %d</string>
|
||||
<plurals name="fmt_song_count">
|
||||
<item quantity="one">%d canción</item>
|
||||
<item quantity="many">%d canciones</item>
|
||||
<item quantity="other">%d canciones</item>
|
||||
</plurals>
|
||||
<plurals name="fmt_album_count">
|
||||
<item quantity="one">%d álbum</item>
|
||||
<item quantity="many">%d álbumes</item>
|
||||
<item quantity="other">%d álbumes</item>
|
||||
</plurals>
|
||||
<string name="lbl_size">Tamaño</string>
|
||||
|
@ -263,4 +261,5 @@
|
|||
<string name="lbl_play_selected">Reproducir los seleccionados</string>
|
||||
<string name="set_playback_mode_genre">Reproducir desde el género</string>
|
||||
<string name="lbl_wiki">Wiki</string>
|
||||
<string name="fmt_list">%1$s, %2$s</string>
|
||||
</resources>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue