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
|
- `./adb -d logcat AndroidRuntime:E *:S` in the case of a crash
|
||||||
5. Copy and paste the output to this area of the issue.
|
5. Copy and paste the output to this area of the issue.
|
||||||
render: shell
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: terms
|
id: terms
|
||||||
attributes:
|
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
|
# 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
|
## 3.0.0
|
||||||
|
|
||||||
|
@ -12,7 +32,7 @@
|
||||||
- Added setting to hide "collaborator" artists
|
- Added setting to hide "collaborator" artists
|
||||||
- Upgraded music ID management:
|
- Upgraded music ID management:
|
||||||
- Added support for MusicBrainz IDs (MBIDs)
|
- 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
|
- Genres now display a list of artists
|
||||||
- Added toggle to load non-music (Such as podcasts)
|
- Added toggle to load non-music (Such as podcasts)
|
||||||
- Music loader now caches parsed metadata for faster load times
|
- Music loader now caches parsed metadata for faster load times
|
||||||
|
@ -42,7 +62,6 @@ audio focus was lost
|
||||||
|
|
||||||
#### What's Changed
|
#### What's Changed
|
||||||
- Ignore MediaStore tags is now Auxio's default and unchangeable behavior. The option has been removed.
|
- 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"
|
- "Use alternate notification action" is now "Custom notification action"
|
||||||
- "Show covers" and "Ignore MediaStore covers" have been unified into "Album covers"
|
- "Show covers" and "Ignore MediaStore covers" have been unified into "Album covers"
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
<h1 align="center"><b>Auxio</b></h1>
|
<h1 align="center"><b>Auxio</b></h1>
|
||||||
<h4 align="center">A simple, rational music player for android.</h4>
|
<h4 align="center">A simple, rational music player for android.</h4>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.0">
|
<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.0&color=0D5AF5">
|
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.1&color=0D5AF5">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
|
<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).
|
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
|
## License
|
||||||
|
|
||||||
|
|
|
@ -12,8 +12,8 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId namespace
|
applicationId namespace
|
||||||
versionName "3.0.0"
|
versionName "3.0.1"
|
||||||
versionCode 24
|
versionCode 25
|
||||||
|
|
||||||
minSdk 21
|
minSdk 21
|
||||||
targetSdk 33
|
targetSdk 33
|
||||||
|
@ -121,3 +121,7 @@ spotless {
|
||||||
licenseHeaderFile("NOTICE")
|
licenseHeaderFile("NOTICE")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
preDebugBuild.dependsOn spotlessApply
|
||||||
|
}
|
||||||
|
|
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
|
@ -21,5 +21,5 @@
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
# Obsfucation is what proprietary software does to keep the user unaware of it's abuses.
|
# 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
|
-dontobfuscate
|
|
@ -116,7 +116,7 @@
|
||||||
<!-- </intent-filter>-->
|
<!-- </intent-filter>-->
|
||||||
<!-- </receiver>-->
|
<!-- </receiver>-->
|
||||||
|
|
||||||
<!-- "Now Playing" widget.. -->
|
<!-- "Now Playing" widget. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widgets.WidgetProvider"
|
android:name=".widgets.WidgetProvider"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|
|
@ -42,20 +42,12 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
*
|
*
|
||||||
* TODO: Custom language support
|
* TODO: Custom language support
|
||||||
*
|
*
|
||||||
* TODO: Add multi-select
|
|
||||||
*
|
|
||||||
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
|
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
|
||||||
*
|
*
|
||||||
* TODO: Migrate to material animation system
|
* TODO: Migrate to material animation system
|
||||||
*
|
*
|
||||||
* TODO: Unit testing
|
* TODO: Unit testing
|
||||||
*
|
*
|
||||||
* TODO: Standardize from/new usage
|
|
||||||
*
|
|
||||||
* TODO: Standardize companion object usage
|
|
||||||
*
|
|
||||||
* TODO: Standardize callback/listener naming.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
@ -146,7 +138,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED"
|
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 kotlin.math.min
|
||||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
|
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
|
||||||
|
@ -62,10 +61,8 @@ class MainFragment :
|
||||||
private val selectionModel: SelectionViewModel by activityViewModels()
|
private val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
private val callback = DynamicBackPressedCallback()
|
private val callback = DynamicBackPressedCallback()
|
||||||
private var lastInsets: WindowInsets? = null
|
private var lastInsets: WindowInsets? = null
|
||||||
|
private var elevationNormal = 0f
|
||||||
private var initialNavDestinationChange = true
|
private var initialNavDestinationChange = true
|
||||||
private val elevationNormal: Float by lifecycleObject { binding ->
|
|
||||||
binding.context.getDimen(R.dimen.elevation_normal)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -78,6 +75,8 @@ class MainFragment :
|
||||||
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
|
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
val context = requireActivity()
|
val context = requireActivity()
|
||||||
// Override the back pressed listener so we can map back navigation to collapsing
|
// 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 }
|
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.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f
|
||||||
|
|
||||||
binding.queueSheet.apply {
|
binding.queueSheet.apply {
|
||||||
|
|
|
@ -20,7 +20,7 @@ package org.oxycblt.auxio.detail
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.Song
|
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.
|
* 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
|
data class DiscHeader(val disc: Int) : Item
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [Song] extension that adds information about it's file properties.
|
* The properties of a [Song]'s file.
|
||||||
* @param song The internal song
|
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
|
||||||
* @param properties The properties of the song file. Null if parsing is ongoing.
|
* @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?) {
|
data class SongProperties(
|
||||||
/**
|
val bitrateKbps: Int?,
|
||||||
* The properties of a [Song]'s file.
|
val sampleRateHz: Int?,
|
||||||
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
|
val resolvedMimeType: MimeType
|
||||||
* @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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -31,13 +31,13 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
import org.oxycblt.auxio.R
|
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.getInteger
|
||||||
import org.oxycblt.auxio.util.lazyReflectedField
|
import org.oxycblt.auxio.util.lazyReflectedField
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An [AuxioAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
|
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
|
||||||
* beyond it's first item.
|
* 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,
|
* 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
|
* 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
|
class DetailAppBarLayout
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
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 titleView: TextView? = null
|
||||||
private var recycler: RecyclerView? = null
|
private var recycler: RecyclerView? = null
|
||||||
|
|
||||||
|
@ -166,8 +166,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private val TOOLBAR_TITLE_TEXT_FIELD: Field by
|
val TOOLBAR_TITLE_TEXT_FIELD: Field by lazyReflectedField(Toolbar::class, "mTitleTextView")
|
||||||
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.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.Sort
|
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.settings.Settings
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
|
@ -51,7 +51,7 @@ import org.oxycblt.auxio.util.*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class DetailViewModel(application: Application) :
|
class DetailViewModel(application: Application) :
|
||||||
AndroidViewModel(application), MusicStore.Callback {
|
AndroidViewModel(application), MusicStore.Listener {
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private val settings = Settings(application)
|
private val settings = Settings(application)
|
||||||
|
|
||||||
|
@ -59,15 +59,15 @@ class DetailViewModel(application: Application) :
|
||||||
|
|
||||||
// --- SONG ---
|
// --- SONG ---
|
||||||
|
|
||||||
private val _currentSong = MutableStateFlow<DetailSong?>(null)
|
private val _currentSong = MutableStateFlow<Song?>(null)
|
||||||
/**
|
/** The current [Song] to display. Null if there is nothing to show. */
|
||||||
* The current [DetailSong] to display. Null if there is nothing to show.
|
val currentSong: StateFlow<Song?>
|
||||||
*
|
|
||||||
* TODO: De-couple Song and Properties?
|
|
||||||
*/
|
|
||||||
val currentSong: StateFlow<DetailSong?>
|
|
||||||
get() = _currentSong
|
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 ---
|
// --- ALBUM ---
|
||||||
|
|
||||||
private val _currentAlbum = MutableStateFlow<Album?>(null)
|
private val _currentAlbum = MutableStateFlow<Album?>(null)
|
||||||
|
@ -130,11 +130,11 @@ class DetailViewModel(application: Application) :
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicStore.addCallback(this)
|
musicStore.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
musicStore.removeCallback(this)
|
musicStore.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||||
|
@ -149,13 +149,8 @@ class DetailViewModel(application: Application) :
|
||||||
|
|
||||||
val song = currentSong.value
|
val song = currentSong.value
|
||||||
if (song != null) {
|
if (song != null) {
|
||||||
val newSong = library.sanitize(song.song)
|
_currentSong.value = library.sanitize(song)?.also(::loadProperties)
|
||||||
if (newSong != null) {
|
logD("Updated song to ${currentSong.value}")
|
||||||
loadDetailSong(newSong)
|
|
||||||
} else {
|
|
||||||
_currentSong.value = null
|
|
||||||
}
|
|
||||||
logD("Updated song to $newSong")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val album = currentAlbum.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
|
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong]
|
||||||
* process will begin and the newly-loaded [DetailSong] will be set to [currentSong].
|
* and [songProperties] will be updated to align with the new [Song].
|
||||||
* @param uid The UID of the [Song] to load. Must be valid.
|
* @param uid The UID of the [Song] to load. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setSongUid(uid: Music.UID) {
|
fun setSongUid(uid: Music.UID) {
|
||||||
if (_currentSong.value?.run { song.uid } == uid) {
|
if (_currentSong.value?.uid == uid) {
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logD("Opening Song [uid: $uid]")
|
logD("Opening Song [uid: $uid]")
|
||||||
loadDetailSong(requireMusic(uid))
|
_currentSong.value = requireMusic<Song>(uid)?.also(::loadProperties)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -202,7 +197,7 @@ class DetailViewModel(application: Application) :
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logD("Opening Album [uid: $uid]")
|
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
|
return
|
||||||
}
|
}
|
||||||
logD("Opening Artist [uid: $uid]")
|
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
|
return
|
||||||
}
|
}
|
||||||
logD("Opening Genre [uid: $uid]")
|
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 =
|
private fun <T : Music> requireMusic(uid: Music.UID) = musicStore.library?.find<T>(uid)
|
||||||
requireNotNull(unlikelyToBeNull(musicStore.library).find(uid)) { "Invalid id provided" }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
* @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.
|
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||||
currentSongJob?.cancel()
|
currentSongJob?.cancel()
|
||||||
_currentSong.value = DetailSong(song, null)
|
_songProperties.value = null
|
||||||
currentSongJob =
|
currentSongJob =
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val info = loadProperties(song)
|
val properties = this@DetailViewModel.loadPropertiesImpl(song)
|
||||||
yield()
|
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
|
// 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
|
// 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.
|
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
|
||||||
|
@ -266,7 +261,7 @@ class DetailViewModel(application: Application) :
|
||||||
// that we can show.
|
// that we can show.
|
||||||
logW("Unable to extract song attributes.")
|
logW("Unable to extract song attributes.")
|
||||||
logW(e.stackTraceToString())
|
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
|
// 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)
|
MimeType(song.mimeType.fromExtension, formatMimeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
return DetailSong.Properties(bitrate, sampleRate, resolvedMimeType)
|
return SongProperties(bitrate, sampleRate, resolvedMimeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshAlbumList(album: Album) {
|
private fun refreshAlbumList(album: Album) {
|
||||||
|
|
|
@ -26,6 +26,7 @@ import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
|
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||||
|
@ -53,10 +54,10 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
// DetailViewModel handles most initialization from the navigation argument.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setSongUid(args.itemUid)
|
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) {
|
if (song == null) {
|
||||||
// Song we were showing no longer exists.
|
// Song we were showing no longer exists.
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
|
@ -64,28 +65,28 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
if (song.properties != null) {
|
if (properties != null) {
|
||||||
// Finished loading Song properties, populate and show the list of Song information.
|
// Finished loading Song properties, populate and show the list of Song information.
|
||||||
binding.detailLoading.isInvisible = true
|
binding.detailLoading.isInvisible = true
|
||||||
binding.detailContainer.isInvisible = false
|
binding.detailContainer.isInvisible = false
|
||||||
|
|
||||||
val context = requireContext()
|
val context = requireContext()
|
||||||
binding.detailFileName.setText(song.song.path.name)
|
binding.detailFileName.setText(song.path.name)
|
||||||
binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context))
|
binding.detailRelativeDir.setText(song.path.parent.resolveName(context))
|
||||||
binding.detailFormat.setText(song.properties.resolvedMimeType.resolveName(context))
|
binding.detailFormat.setText(properties.resolvedMimeType.resolveName(context))
|
||||||
binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size))
|
binding.detailSize.setText(Formatter.formatFileSize(context, song.size))
|
||||||
binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true))
|
binding.detailDuration.setText(song.durationMs.formatDurationMs(true))
|
||||||
|
|
||||||
if (song.properties.bitrateKbps != null) {
|
if (properties.bitrateKbps != null) {
|
||||||
binding.detailBitrate.setText(
|
binding.detailBitrate.setText(
|
||||||
getString(R.string.fmt_bitrate, song.properties.bitrateKbps))
|
getString(R.string.fmt_bitrate, properties.bitrateKbps))
|
||||||
} else {
|
} else {
|
||||||
binding.detailBitrate.setText(R.string.def_bitrate)
|
binding.detailBitrate.setText(R.string.def_bitrate)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (song.properties.sampleRateHz != null) {
|
if (properties.sampleRateHz != null) {
|
||||||
binding.detailSampleRate.setText(
|
binding.detailSampleRate.setText(
|
||||||
getString(R.string.fmt_sample_rate, song.properties.sampleRateHz))
|
getString(R.string.fmt_sample_rate, properties.sampleRateHz))
|
||||||
} else {
|
} else {
|
||||||
binding.detailSampleRate.setText(R.string.def_sample_rate)
|
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) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
when (viewType) {
|
when (viewType) {
|
||||||
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.new(parent)
|
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent)
|
||||||
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.new(parent)
|
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
|
||||||
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.new(parent)
|
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
|
||||||
else -> super.onCreateViewHolder(parent, viewType)
|
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
|
return super.isItemFullWidth(position) || item is Album || item is DiscHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
private val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleItemCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||||
return when {
|
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.
|
* create an instance.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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
|
// Date, song count, and duration map to the info text
|
||||||
binding.detailInfo.apply {
|
binding.detailInfo.apply {
|
||||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
// 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 songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
|
||||||
val duration = album.durationMs.formatDurationMs(true)
|
val duration = album.durationMs.formatDurationMs(true)
|
||||||
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** 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) =
|
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.rawName == newItem.rawName &&
|
||||||
oldItem.areArtistContentsTheSame(newItem) &&
|
oldItem.areArtistContentsTheSame(newItem) &&
|
||||||
oldItem.date == newItem.date &&
|
oldItem.dates == newItem.dates &&
|
||||||
oldItem.songs.size == newItem.songs.size &&
|
oldItem.songs.size == newItem.songs.size &&
|
||||||
oldItem.durationMs == newItem.durationMs &&
|
oldItem.durationMs == newItem.durationMs &&
|
||||||
oldItem.type == newItem.type
|
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
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
|
DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** 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.
|
* create an instance.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* @param listener A [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(song: Song, listener: SelectableListListener) {
|
fun bind(song: Song, listener: SelectableListListener) {
|
||||||
listener.bind(this, song, binding.songMenu)
|
listener.bind(song, this, menuButton = binding.songMenu)
|
||||||
|
|
||||||
binding.songTrack.apply {
|
binding.songTrack.apply {
|
||||||
if (song.track != null) {
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
|
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** 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) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
when (viewType) {
|
when (viewType) {
|
||||||
ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.new(parent)
|
ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.from(parent)
|
||||||
ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.new(parent)
|
ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.from(parent)
|
||||||
ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.new(parent)
|
ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.from(parent)
|
||||||
else -> super.onCreateViewHolder(parent, viewType)
|
else -> super.onCreateViewHolder(parent, viewType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,9 +76,9 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
|
||||||
return super.isItemFullWidth(position) || item is Artist
|
return super.isItemFullWidth(position) || item is Artist
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
private val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleItemCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||||
return when {
|
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.
|
* create an instance.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** 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.
|
* create an instance.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(album: Album, listener: SelectableListListener) {
|
fun bind(album: Album, listener: SelectableListListener) {
|
||||||
listener.bind(this, album, binding.parentMenu)
|
listener.bind(album, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(album)
|
binding.parentImage.bind(album)
|
||||||
binding.parentName.text = album.resolveName(binding.context)
|
binding.parentName.text = album.resolveName(binding.context)
|
||||||
binding.parentInfo.text =
|
binding.parentInfo.text =
|
||||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
// 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) {
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Album>() {
|
object : SimpleItemCallback<Album>() {
|
||||||
override fun areContentsTheSame(oldItem: Album, newItem: 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.
|
* create an instance.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(song: Song, listener: SelectableListListener) {
|
fun bind(song: Song, listener: SelectableListListener) {
|
||||||
listener.bind(this, song, binding.songMenu)
|
listener.bind(song, this, menuButton = binding.songMenu)
|
||||||
binding.songAlbumCover.bind(song)
|
binding.songAlbumCover.bind(song)
|
||||||
binding.songName.text = song.resolveName(binding.context)
|
binding.songName.text = song.resolveName(binding.context)
|
||||||
binding.songInfo.text = song.album.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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
|
ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
|
|
|
@ -57,8 +57,8 @@ abstract class DetailAdapter(
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
when (viewType) {
|
when (viewType) {
|
||||||
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.new(parent)
|
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
|
||||||
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.new(parent)
|
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
|
||||||
else -> error("Invalid item type $viewType")
|
else -> error("Invalid item type $viewType")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,7 +109,7 @@ abstract class DetailAdapter(
|
||||||
fun onOpenSortMenu(anchor: View)
|
fun onOpenSortMenu(anchor: View)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
protected companion object {
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleItemCallback<Item>() {
|
||||||
|
@ -128,7 +128,7 @@ abstract class DetailAdapter(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater))
|
SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** 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) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
when (viewType) {
|
when (viewType) {
|
||||||
GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.new(parent)
|
GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.from(parent)
|
||||||
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.new(parent)
|
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
|
||||||
SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent)
|
SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
|
||||||
else -> super.onCreateViewHolder(parent, viewType)
|
else -> super.onCreateViewHolder(parent, viewType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,7 +75,7 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
|
||||||
return super.isItemFullWidth(position) || item is Genre
|
return super.isItemFullWidth(position) || item is Genre
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleItemCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
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.
|
* create an instance.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** 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.list.SongListFragment
|
||||||
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
|
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
|
||||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
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.system.Indexer
|
import org.oxycblt.auxio.music.system.Indexer
|
||||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||||
|
@ -72,17 +65,7 @@ class HomeFragment :
|
||||||
private val homeModel: HomeViewModel by androidActivityViewModels()
|
private val homeModel: HomeViewModel by androidActivityViewModels()
|
||||||
private val musicModel: MusicViewModel by activityViewModels()
|
private val musicModel: MusicViewModel by activityViewModels()
|
||||||
private val navModel: NavigationViewModel by activityViewModels()
|
private val navModel: NavigationViewModel by activityViewModels()
|
||||||
|
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -105,6 +88,12 @@ class HomeFragment :
|
||||||
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
|
// Have to set up the permission launcher before the view is shown
|
||||||
|
storagePermissionLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||||
|
musicModel.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
binding.homeAppbar.addOnOffsetChangedListener(this)
|
binding.homeAppbar.addOnOffsetChangedListener(this)
|
||||||
binding.homeToolbar.setOnMenuItemClickListener(this)
|
binding.homeToolbar.setOnMenuItemClickListener(this)
|
||||||
|
@ -171,6 +160,7 @@ class HomeFragment :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentHomeBinding) {
|
override fun onDestroyBinding(binding: FragmentHomeBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
|
storagePermissionLauncher = null
|
||||||
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
||||||
binding.homeToolbar.setOnMenuItemClickListener(null)
|
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)
|
val toHighlight = homeModel.getSortForTab(tabMode)
|
||||||
|
|
||||||
for (option in sortMenu) {
|
for (option in sortMenu) {
|
||||||
// Check the ascending option and corresponding sort option to align with
|
// Check the ascending option and corresponding sort option to align with
|
||||||
// the current sort of the tab.
|
// the current sort of the tab.
|
||||||
if (option.itemId == toHighlight.mode.itemId ||
|
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
|
option.isChecked = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -303,7 +295,13 @@ class HomeFragment :
|
||||||
// Update the scrolling view in AppBarLayout to align with the current tab's
|
// 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
|
// scrolling state. This prevents the lift state from being confused as one
|
||||||
// goes between different tabs.
|
// 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) {
|
private fun handleRecreate(recreate: Boolean) {
|
||||||
|
@ -321,9 +319,12 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateIndexerState(state: Indexer.State?) {
|
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()
|
val binding = requireBinding()
|
||||||
when (state) {
|
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)
|
is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
|
||||||
null -> {
|
null -> {
|
||||||
logD("Indexer is in indeterminate state")
|
logD("Indexer is in indeterminate state")
|
||||||
|
@ -332,53 +333,56 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupCompleteState(binding: FragmentHomeBinding, response: Indexer.Response) {
|
private fun setupCompleteState(
|
||||||
if (response is Indexer.Response.Ok) {
|
binding: FragmentHomeBinding,
|
||||||
|
result: Result<MusicStore.Library>
|
||||||
|
) {
|
||||||
|
if (result.isSuccess) {
|
||||||
logD("Received ok response")
|
logD("Received ok response")
|
||||||
binding.homeFab.show()
|
binding.homeFab.show()
|
||||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||||
} else {
|
} else {
|
||||||
logD("Received non-ok response")
|
logD("Received non-ok response")
|
||||||
val context = requireContext()
|
val context = requireContext()
|
||||||
|
val throwable = unlikelyToBeNull(result.exceptionOrNull())
|
||||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||||
when (response) {
|
when (throwable) {
|
||||||
is Indexer.Response.Err -> {
|
is Indexer.NoPermissionException -> {
|
||||||
logD("Updating UI to Response.Err state")
|
logD("Updating UI to permission request 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")
|
|
||||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
||||||
|
|
||||||
// Configure the action to act as a permission launcher.
|
// Configure the action to act as a permission launcher.
|
||||||
binding.homeIndexingAction.apply {
|
binding.homeIndexingAction.apply {
|
||||||
visibility = View.VISIBLE
|
visibility = View.VISIBLE
|
||||||
text = context.getString(R.string.lbl_grant)
|
text = context.getString(R.string.lbl_grant)
|
||||||
setOnClickListener {
|
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()
|
val binding = requireBinding()
|
||||||
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
|
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
|
||||||
selected.isNotEmpty()) {
|
selected.isNotEmpty()) {
|
||||||
|
// New selection started, show the AppBarLayout to indicate the new state.
|
||||||
logD("Significant selection occurred, expanding AppBar")
|
logD("Significant selection occurred, expanding AppBar")
|
||||||
// Significant enough change where we want to expand the RecyclerView
|
binding.homeAppbar.expandWithScrollingRecycler()
|
||||||
binding.homeAppbar.expandWithRecycler(
|
|
||||||
binding.homePager.findViewById(getTabRecyclerId(homeModel.currentTabMode.value)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -457,20 +460,6 @@ class HomeFragment :
|
||||||
reenterTransition = MaterialSharedAxis(axis, false)
|
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.
|
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
|
||||||
* @param tabs The current tab configuration. This will define the [Fragment]s created.
|
* @param tabs The current tab configuration. This will define the [Fragment]s created.
|
||||||
|
@ -493,12 +482,10 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private val VP_RECYCLER_FIELD: Field by
|
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
||||||
lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
|
||||||
private val RV_TOUCH_SLOP_FIELD: Field by
|
const val KEY_LAST_TRANSITION_AXIS =
|
||||||
lazyReflectedField(RecyclerView::class, "mTouchSlop")
|
|
||||||
private const val KEY_LAST_TRANSITION_AXIS =
|
|
||||||
BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
|
BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
package org.oxycblt.auxio.home
|
package org.oxycblt.auxio.home
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
@ -39,9 +40,11 @@ import org.oxycblt.auxio.util.logD
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class HomeViewModel(application: Application) :
|
class HomeViewModel(application: Application) :
|
||||||
AndroidViewModel(application), Settings.Callback, MusicStore.Callback {
|
AndroidViewModel(application),
|
||||||
|
MusicStore.Listener,
|
||||||
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private val settings = Settings(application, this)
|
private val settings = Settings(application)
|
||||||
|
|
||||||
private val _songsList = MutableStateFlow(listOf<Song>())
|
private val _songsList = MutableStateFlow(listOf<Song>())
|
||||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
/** 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
|
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicStore.addCallback(this)
|
musicStore.addListener(this)
|
||||||
|
settings.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
musicStore.removeCallback(this)
|
musicStore.removeListener(this)
|
||||||
settings.release()
|
settings.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
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) {
|
when (key) {
|
||||||
context.getString(R.string.set_key_lib_tabs) -> {
|
context.getString(R.string.set_key_lib_tabs) -> {
|
||||||
// Tabs changed, update the current tabs and set up a re-create event.
|
// 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)
|
// Pre-calculate sqrt(2)
|
||||||
private const val SQRT2 = 1.4142135f
|
const val SQRT2 = 1.4142135f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,26 +71,6 @@ class FastScrollRecyclerView
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||||
AuxioRecyclerView(context, attrs, defStyleAttr) {
|
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
|
// Thumb
|
||||||
private val thumbView =
|
private val thumbView =
|
||||||
View(context).apply {
|
View(context).apply {
|
||||||
|
@ -524,7 +504,27 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
/** An interface to provide text to use in the popup when fast-scrolling. */
|
||||||
private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
|
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 ->
|
is Sort.Mode.ByArtist ->
|
||||||
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
|
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
|
||||||
|
|
||||||
// Year -> Use Full Year
|
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
|
||||||
is Sort.Mode.ByDate -> album.date?.resolveDate(requireContext())
|
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
|
||||||
|
|
||||||
// Duration -> Use formatted duration
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
||||||
|
@ -152,7 +152,7 @@ class AlbumListFragment :
|
||||||
get() = differ.currentList
|
get() = differ.currentList
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
AlbumViewHolder.new(parent)
|
AlbumViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
|
||||||
holder.bind(differ.currentList[position], listener)
|
holder.bind(differ.currentList[position], listener)
|
||||||
|
|
|
@ -127,7 +127,7 @@ class ArtistListFragment :
|
||||||
get() = differ.currentList
|
get() = differ.currentList
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
ArtistViewHolder.new(parent)
|
ArtistViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
|
||||||
holder.bind(differ.currentList[position], listener)
|
holder.bind(differ.currentList[position], listener)
|
||||||
|
|
|
@ -126,7 +126,7 @@ class GenreListFragment :
|
||||||
get() = differ.currentList
|
get() = differ.currentList
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
GenreViewHolder.new(parent)
|
GenreViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
|
||||||
holder.bind(differ.currentList[position], listener)
|
holder.bind(differ.currentList[position], listener)
|
||||||
|
|
|
@ -103,7 +103,7 @@ class SongListFragment :
|
||||||
song.album.collationKey?.run { sourceString.first().uppercase() }
|
song.album.collationKey?.run { sourceString.first().uppercase() }
|
||||||
|
|
||||||
// Year -> Use Full Year
|
// 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
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
||||||
|
@ -166,7 +166,7 @@ class SongListFragment :
|
||||||
get() = differ.currentList
|
get() = differ.currentList
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
SongViewHolder.new(parent)
|
SongViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
|
||||||
holder.bind(differ.currentList[position], listener)
|
holder.bind(differ.currentList[position], listener)
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.home.tabs
|
package org.oxycblt.auxio.home.tabs
|
||||||
|
|
||||||
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.util.logE
|
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.
|
* @param mode The type of list in the home view this instance corresponds to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* 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.
|
* @param mode The type of list in the home view this instance corresponds to.
|
||||||
|
|
|
@ -18,27 +18,28 @@
|
||||||
package org.oxycblt.auxio.home.tabs
|
package org.oxycblt.auxio.home.tabs
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.view.MotionEvent
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemTabBinding
|
import org.oxycblt.auxio.databinding.ItemTabBinding
|
||||||
|
import org.oxycblt.auxio.list.EditableListListener
|
||||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
|
* 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. */
|
/** The current array of [Tab]s. */
|
||||||
var tabs = arrayOf<Tab>()
|
var tabs = arrayOf<Tab>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
override fun getItemCount() = tabs.size
|
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) {
|
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
|
||||||
holder.bind(tabs[position], listener)
|
holder.bind(tabs[position], listener)
|
||||||
}
|
}
|
||||||
|
@ -75,30 +76,13 @@ class TabAdapter(private val listener: Listener) : RecyclerView.Adapter<TabViewH
|
||||||
notifyItemMoved(a, b)
|
notifyItemMoved(a, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A listener for interactions specific to tab configuration. */
|
private companion object {
|
||||||
interface Listener {
|
val PAYLOAD_TAB_CHANGED = Any()
|
||||||
/**
|
|
||||||
* 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
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.
|
* Bind new data to this instance.
|
||||||
* @param tab The new [Tab] to bind.
|
* @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")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
fun bind(tab: Tab, listener: TabAdapter.Listener) {
|
fun bind(tab: Tab, listener: EditableListListener) {
|
||||||
binding.root.setOnClickListener { listener.onToggleVisibility(tab.mode) }
|
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
|
||||||
|
|
||||||
binding.tabCheckBox.apply {
|
binding.tabCheckBox.apply {
|
||||||
// Update the CheckBox name to align with the mode
|
// Update the CheckBox name to align with the mode
|
||||||
setText(
|
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)
|
// the tab data since they are in the same data structure (Tab)
|
||||||
isChecked = tab is Tab.Visible
|
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 {
|
companion object {
|
||||||
|
@ -143,6 +117,6 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
||||||
* @param parent The parent to inflate this instance from.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @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.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogTabsBinding
|
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.settings.Settings
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
|
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAdapter.Listener {
|
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener {
|
||||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
|
||||||
|
|
||||||
private val tabAdapter = TabAdapter(this)
|
private val tabAdapter = TabAdapter(this)
|
||||||
private val touchHelper: ItemTouchHelper by lifecycleObject {
|
private var touchHelper: ItemTouchHelper? = null
|
||||||
ItemTouchHelper(TabDragCallback(tabAdapter))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
|
override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
|
||||||
|
|
||||||
|
@ -50,13 +46,13 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
|
||||||
.setTitle(R.string.set_lib_tabs)
|
.setTitle(R.string.set_lib_tabs)
|
||||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||||
logD("Committing tab changes")
|
logD("Committing tab changes")
|
||||||
settings.libTabs = tabAdapter.tabs
|
Settings(requireContext()).libTabs = tabAdapter.tabs
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
.setNegativeButton(R.string.lbl_cancel, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
|
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.
|
// Try to restore a pending tab configuration that was saved prior.
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState != null) {
|
||||||
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
|
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
|
||||||
|
@ -69,7 +65,8 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
|
||||||
tabAdapter.submitTabs(tabs)
|
tabAdapter.submitTabs(tabs)
|
||||||
binding.tabRecycler.apply {
|
binding.tabRecycler.apply {
|
||||||
adapter = tabAdapter
|
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
|
binding.tabRecycler.adapter = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onToggleVisibility(tabMode: MusicMode) {
|
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||||
logD("Toggling tab $tabMode")
|
check(item is Tab) { "Unexpected datatype: ${item::class.java}" }
|
||||||
|
|
||||||
// We will need the exact index of the tab to update on in order to
|
// We will need the exact index of the tab to update on in order to
|
||||||
// notify the adapter of the change.
|
// 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]
|
val tab = tabAdapter.tabs[index]
|
||||||
tabAdapter.setTab(
|
tabAdapter.setTab(
|
||||||
index,
|
index,
|
||||||
|
@ -105,10 +101,10 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
||||||
touchHelper.startDrag(viewHolder)
|
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
|
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
|
private val cornerRadius: Float
|
||||||
|
|
||||||
init {
|
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")
|
@SuppressLint("CustomViewStyleable")
|
||||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
|
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
|
// 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/
|
// Playback indicator should sit above the inner StyledImageView and custom view/
|
||||||
addView(playbackIndicatorView)
|
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(
|
addView(
|
||||||
selectionIndicatorView,
|
selectionIndicatorView,
|
||||||
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
|
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
|
|
@ -177,6 +177,8 @@ object Covers {
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? {
|
private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? {
|
||||||
// Eliminate any chance that this blocking call might mess up the loading process
|
// 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.annotation.MenuRes
|
||||||
import androidx.appcompat.widget.PopupMenu
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import org.oxycblt.auxio.MainFragmentDirections
|
import org.oxycblt.auxio.MainFragmentDirections
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
@ -53,7 +54,7 @@ abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), Selecta
|
||||||
*/
|
*/
|
||||||
abstract fun onRealClick(music: Music)
|
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}" }
|
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||||
if (selectionModel.selected.value.isNotEmpty()) {
|
if (selectionModel.selected.value.isNotEmpty()) {
|
||||||
// Map clicking an item to selecting an item when items are already selected.
|
// Map clicking an item to selecting an item when items are already selected.
|
||||||
|
|
|
@ -17,8 +17,8 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.list
|
package org.oxycblt.auxio.list
|
||||||
|
|
||||||
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Button
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -26,13 +26,63 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface ClickableListListener {
|
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.
|
* Called when an [Item] in the list is clicked.
|
||||||
* @param item The [Item] that was 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.
|
* 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 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) {
|
fun bind(
|
||||||
viewHolder.itemView.apply {
|
item: Item,
|
||||||
// Map clicks to the click listener.
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
setOnClickListener { onClick(item) }
|
bodyView: View = viewHolder.itemView,
|
||||||
// Map long clicks to the selection listener.
|
menuButton: View
|
||||||
setOnLongClickListener {
|
) {
|
||||||
onSelect(item)
|
bind(item, viewHolder, bodyView)
|
||||||
true
|
// Map long clicks to the selection listener.
|
||||||
}
|
bodyView.setOnLongClickListener {
|
||||||
|
onSelect(item)
|
||||||
|
true
|
||||||
}
|
}
|
||||||
// Map the menu button to the menu opening listener.
|
// Map the menu button to the menu opening listener.
|
||||||
menuButton.setOnClickListener { onOpenMenu(item, it) }
|
menuButton.setOnClickListener { onOpenMenu(item, it) }
|
||||||
|
|
|
@ -115,7 +115,7 @@ abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : RecyclerV
|
||||||
abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean)
|
abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private val PAYLOAD_PLAYING_INDICATOR_CHANGED = Any()
|
val PAYLOAD_PLAYING_INDICATOR_CHANGED = Any()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,7 +77,7 @@ abstract class SelectionIndicatorAdapter<VH : RecyclerView.ViewHolder> :
|
||||||
abstract fun updateSelectionIndicator(isSelected: Boolean)
|
abstract fun updateSelectionIndicator(isSelected: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private val PAYLOAD_SELECTION_INDICATOR_CHANGED = Any()
|
val PAYLOAD_SELECTION_INDICATOR_CHANGED = Any()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.inflater
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SongViewHolder private constructor(private val binding: ItemSongBinding) :
|
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.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(song: Song, listener: SelectableListListener) {
|
fun bind(song: Song, listener: SelectableListListener) {
|
||||||
listener.bind(this, song, binding.songMenu)
|
listener.bind(song, this, menuButton = binding.songMenu)
|
||||||
binding.songAlbumCover.bind(song)
|
binding.songAlbumCover.bind(song)
|
||||||
binding.songName.text = song.resolveName(binding.context)
|
binding.songName.text = song.resolveName(binding.context)
|
||||||
binding.songInfo.text = song.resolveArtistContents(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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class AlbumViewHolder private constructor(private val binding: ItemParentBinding) :
|
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.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(album: Album, listener: SelectableListListener) {
|
fun bind(album: Album, listener: SelectableListListener) {
|
||||||
listener.bind(this, album, binding.parentMenu)
|
listener.bind(album, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(album)
|
binding.parentImage.bind(album)
|
||||||
binding.parentName.text = album.resolveName(binding.context)
|
binding.parentName.text = album.resolveName(binding.context)
|
||||||
binding.parentInfo.text = album.resolveArtistContents(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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
|
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.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(artist: Artist, listener: SelectableListListener) {
|
fun bind(artist: Artist, listener: SelectableListListener) {
|
||||||
listener.bind(this, artist, binding.parentMenu)
|
listener.bind(artist, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(artist)
|
binding.parentImage.bind(artist)
|
||||||
binding.parentName.text = artist.resolveName(binding.context)
|
binding.parentName.text = artist.resolveName(binding.context)
|
||||||
binding.parentInfo.text =
|
binding.parentInfo.text =
|
||||||
|
@ -175,7 +175,8 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
||||||
* @param parent The parent to inflate this instance from.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class GenreViewHolder private constructor(private val binding: ItemParentBinding) :
|
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.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(genre: Genre, listener: SelectableListListener) {
|
fun bind(genre: Genre, listener: SelectableListListener) {
|
||||||
listener.bind(this, genre, binding.parentMenu)
|
listener.bind(genre, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(genre)
|
binding.parentImage.bind(genre)
|
||||||
binding.parentName.text = genre.resolveName(binding.context)
|
binding.parentName.text = genre.resolveName(binding.context)
|
||||||
binding.parentInfo.text =
|
binding.parentInfo.text =
|
||||||
|
@ -228,7 +229,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
|
||||||
* @param parent The parent to inflate this instance from.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
|
|
|
@ -26,7 +26,7 @@ import org.oxycblt.auxio.music.*
|
||||||
* A [ViewModel] that manages the current selection.
|
* A [ViewModel] that manages the current selection.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SelectionViewModel : ViewModel(), MusicStore.Callback {
|
class SelectionViewModel : ViewModel(), MusicStore.Listener {
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
|
|
||||||
private val _selected = MutableStateFlow(listOf<Music>())
|
private val _selected = MutableStateFlow(listOf<Music>())
|
||||||
|
@ -35,7 +35,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Callback {
|
||||||
get() = _selected
|
get() = _selected
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicStore.addCallback(this)
|
musicStore.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||||
|
@ -58,7 +58,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Callback {
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.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.security.MessageDigest
|
||||||
import java.text.CollationKey
|
import java.text.CollationKey
|
||||||
import java.text.Collator
|
import java.text.Collator
|
||||||
import java.text.ParseException
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlinx.parcelize.IgnoredOnParcel
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.extractor.parseId3GenreNames
|
import org.oxycblt.auxio.music.filesystem.*
|
||||||
import org.oxycblt.auxio.music.extractor.parseMultiValue
|
import org.oxycblt.auxio.music.parsing.parseId3GenreNames
|
||||||
import org.oxycblt.auxio.music.extractor.toUuidOrNull
|
import org.oxycblt.auxio.music.parsing.parseMultiValue
|
||||||
import org.oxycblt.auxio.music.storage.*
|
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.inRangeOrNull
|
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
@ -114,6 +110,27 @@ sealed class Music : Item {
|
||||||
return COLLATOR.getCollationKey(sortName)
|
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
|
// Note: We solely use the UID in comparisons so that certain items that differ in all
|
||||||
// but UID are treated differently.
|
// 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]. */
|
/** 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.
|
* Resolves one or more [Artist]s into a single piece of human-readable names.
|
||||||
* @param context [Context] required for [resolveName]. formatter.
|
* @param context [Context] required for [resolveName]. formatter.
|
||||||
*/
|
*/
|
||||||
fun resolveArtistContents(context: Context) =
|
fun resolveArtistContents(context: Context) = resolveNames(context, artists)
|
||||||
// TODO Internationalize the list
|
|
||||||
artists.joinToString { it.resolveName(context) }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the [Artist] *display* of this [Song] and another [Song] are equal. This will only
|
* 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.
|
* Resolves one or more [Genre]s into a single piece human-readable names.
|
||||||
* @param context [Context] required for [resolveName].
|
* @param context [Context] required for [resolveName].
|
||||||
*/
|
*/
|
||||||
fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) }
|
fun resolveGenreContents(context: Context) = resolveNames(context, genres)
|
||||||
|
|
||||||
// --- INTERNAL FIELDS ---
|
// --- INTERNAL FIELDS ---
|
||||||
|
|
||||||
|
@ -504,7 +519,6 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
|
||||||
for (i in _artists.indices) {
|
for (i in _artists.indices) {
|
||||||
// Non-destructively reorder the linked artists so that they align with
|
// Non-destructively reorder the linked artists so that they align with
|
||||||
// the artist ordering within the song metadata.
|
// 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 newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
|
||||||
val other = _artists[newIdx]
|
val other = _artists[newIdx]
|
||||||
_artists[newIdx] = _artists[i]
|
_artists[newIdx] = _artists[i]
|
||||||
|
@ -610,11 +624,8 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
|
||||||
override val collationKey = makeCollationKeyImpl()
|
override val collationKey = makeCollationKeyImpl()
|
||||||
override fun resolveName(context: Context) = rawName
|
override fun resolveName(context: Context) = rawName
|
||||||
|
|
||||||
/**
|
/** The [Date.Range] that [Song]s in the [Album] were released. */
|
||||||
* The earliest [Date] this album was released. Will be null if no valid date was present in the
|
val dates = Date.Range.from(songs.mapNotNull { it.date })
|
||||||
* metadata of any [Song]
|
|
||||||
*/
|
|
||||||
val date: Date? // TODO: Date ranges?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The [Type] of this album, signifying the type of release it actually is. Defaults to
|
* 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
|
val dateAdded: Long
|
||||||
|
|
||||||
init {
|
init {
|
||||||
var earliestDate: Date? = null
|
|
||||||
var totalDuration: Long = 0
|
var totalDuration: Long = 0
|
||||||
var earliestDateAdded: Long = Long.MAX_VALUE
|
var earliestDateAdded: Long = Long.MAX_VALUE
|
||||||
|
|
||||||
// Do linking and value generation in the same loop for efficiency.
|
// Do linking and value generation in the same loop for efficiency.
|
||||||
for (song in songs) {
|
for (song in songs) {
|
||||||
song._link(this)
|
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) {
|
if (song.dateAdded < earliestDateAdded) {
|
||||||
earliestDateAdded = song.dateAdded
|
earliestDateAdded = song.dateAdded
|
||||||
}
|
}
|
||||||
|
|
||||||
totalDuration += song.durationMs
|
totalDuration += song.durationMs
|
||||||
}
|
}
|
||||||
|
|
||||||
date = earliestDate
|
|
||||||
durationMs = totalDuration
|
durationMs = totalDuration
|
||||||
dateAdded = earliestDateAdded
|
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.
|
* Resolves one or more [Artist]s into a single piece of human-readable names.
|
||||||
* @param context [Context] required for [resolveName].
|
* @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
|
* 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.
|
* Resolves one or more [Genre]s into a single piece of human-readable names.
|
||||||
* @param context [Context] required for [resolveName].
|
* @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
|
* 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 ---
|
// --- 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].
|
* Update a [MessageDigest] with a lowercase [String].
|
||||||
* @param string The [String] to hash. If null, it will not be hashed.
|
* @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.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
|
||||||
import org.oxycblt.auxio.music.storage.useQuery
|
import org.oxycblt.auxio.music.filesystem.useQuery
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A repository granting access to the music library..
|
* A repository granting access to the music library..
|
||||||
|
@ -33,42 +33,43 @@ import org.oxycblt.auxio.music.storage.useQuery
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class MusicStore private constructor() {
|
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
|
* 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
|
* can change, so it's highly recommended to not access this directly and instead rely on
|
||||||
* [Callback].
|
* [Listener].
|
||||||
*/
|
*/
|
||||||
|
@Volatile
|
||||||
var library: Library? = null
|
var library: Library? = null
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
for (callback in callbacks) {
|
for (callback in listeners) {
|
||||||
callback.onLibraryChanged(library)
|
callback.onLibraryChanged(library)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a [Callback] to this instance. This can be used to receive changes in the music library.
|
* Add a [Listener] 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.
|
* Will invoke all [Listener] methods to initialize the instance with the current state.
|
||||||
* @param callback The [Callback] to add.
|
* @param listener The [Listener] to add.
|
||||||
* @see Callback
|
* @see Listener
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addCallback(callback: Callback) {
|
fun addListener(listener: Listener) {
|
||||||
callback.onLibraryChanged(library)
|
listener.onLibraryChanged(library)
|
||||||
callbacks.add(callback)
|
listeners.add(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a [Callback] from this instance, preventing it from recieving any further updates.
|
* Remove a [Listener] 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
|
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
|
||||||
* the first place.
|
* the first place.
|
||||||
* @see Callback
|
* @see Listener
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun removeCallback(callback: Callback) {
|
fun removeListener(listener: Listener) {
|
||||||
callbacks.remove(callback)
|
listeners.remove(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -167,7 +168,7 @@ class MusicStore private constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A listener for changes in the music library. */
|
/** A listener for changes in the music library. */
|
||||||
interface Callback {
|
interface Listener {
|
||||||
/**
|
/**
|
||||||
* Called when the current [Library] has changed.
|
* Called when the current [Library] has changed.
|
||||||
* @param library The new [Library], or null if no [Library] has been loaded yet.
|
* @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.
|
* A [ViewModel] providing data specific to the music loading process.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class MusicViewModel : ViewModel(), Indexer.Callback {
|
class MusicViewModel : ViewModel(), Indexer.Listener {
|
||||||
private val indexer = Indexer.getInstance()
|
private val indexer = Indexer.getInstance()
|
||||||
|
|
||||||
private val _indexerState = MutableStateFlow<Indexer.State?>(null)
|
private val _indexerState = MutableStateFlow<Indexer.State?>(null)
|
||||||
|
@ -39,18 +39,18 @@ class MusicViewModel : ViewModel(), Indexer.Callback {
|
||||||
get() = _statistics
|
get() = _statistics
|
||||||
|
|
||||||
init {
|
init {
|
||||||
indexer.registerCallback(this)
|
indexer.registerListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
indexer.unregisterCallback(this)
|
indexer.unregisterListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
override fun onIndexerStateChanged(state: Indexer.State?) {
|
||||||
_indexerState.value = 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.
|
// New state is a completed library, update the statistics values.
|
||||||
val library = state.response.library
|
val library = state.result.getOrNull() ?: return
|
||||||
_statistics.value =
|
_statistics.value =
|
||||||
Statistics(
|
Statistics(
|
||||||
library.songs.size,
|
library.songs.size,
|
||||||
|
|
|
@ -232,7 +232,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
||||||
compareByDescending(NullableComparator.DATE) { it.album.date },
|
compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates },
|
||||||
compareByDescending(BasicComparator.ALBUM) { it.album },
|
compareByDescending(BasicComparator.ALBUM) { it.album },
|
||||||
compareBy(NullableComparator.INT) { it.disc },
|
compareBy(NullableComparator.INT) { it.disc },
|
||||||
compareBy(NullableComparator.INT) { it.track },
|
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> =
|
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
||||||
compareByDescending(NullableComparator.DATE) { it.date },
|
compareByDescending(NullableComparator.DATE_RANGE) { it.dates },
|
||||||
compareBy(BasicComparator.ALBUM))
|
compareBy(BasicComparator.ALBUM))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sort by the [Date] of an item. Only available for [Song] and [Album].
|
* Sort by the [Date] of an item. Only available for [Song] and [Album].
|
||||||
* @see Song.date
|
* @see Song.date
|
||||||
* @see Album.date
|
* @see Album.dates
|
||||||
*/
|
*/
|
||||||
object ByDate : Mode() {
|
object ByDate : Mode() {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
|
@ -259,7 +259,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||||
|
|
||||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(isAscending, NullableComparator.DATE) { it.album.date },
|
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates },
|
||||||
compareByDescending(BasicComparator.ALBUM) { it.album },
|
compareByDescending(BasicComparator.ALBUM) { it.album },
|
||||||
compareBy(NullableComparator.INT) { it.disc },
|
compareBy(NullableComparator.INT) { it.disc },
|
||||||
compareBy(NullableComparator.INT) { it.track },
|
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> =
|
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(isAscending, NullableComparator.DATE) { it.date },
|
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.dates },
|
||||||
compareBy(BasicComparator.ALBUM))
|
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.
|
* Sort by the date an item was added. Only supported by [Song]s and [Album]s.
|
||||||
* @see Song.dateAdded
|
* @see Song.dateAdded
|
||||||
* @see Album.date
|
* @see Album.dates
|
||||||
*/
|
*/
|
||||||
object ByDateAdded : Mode() {
|
object ByDateAdded : Mode() {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
|
@ -543,8 +543,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||||
val INT = NullableComparator<Int>()
|
val INT = NullableComparator<Int>()
|
||||||
/** A re-usable instance configured for [Long]s. */
|
/** A re-usable instance configured for [Long]s. */
|
||||||
val LONG = NullableComparator<Long>()
|
val LONG = NullableComparator<Long>()
|
||||||
/** A re-usable instance configured for [Date]s. */
|
/** A re-usable instance configured for [Date.Range]s. */
|
||||||
val DATE = NullableComparator<Date>()
|
val DATE_RANGE = NullableComparator<Date.Range>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,10 @@ import android.database.sqlite.SQLiteDatabase
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
import androidx.core.database.getIntOrNull
|
import androidx.core.database.getIntOrNull
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
|
import org.oxycblt.auxio.music.Date
|
||||||
import org.oxycblt.auxio.music.Song
|
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.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -278,7 +281,7 @@ private class CacheDatabase(context: Context) :
|
||||||
|
|
||||||
raw.track = cursor.getIntOrNull(trackIndex)
|
raw.track = cursor.getIntOrNull(trackIndex)
|
||||||
raw.disc = cursor.getIntOrNull(discIndex)
|
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.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
|
||||||
raw.albumName = cursor.getString(albumNameIndex)
|
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
|
* @return A list of strings corresponding to the delimited values present within the original
|
||||||
* string. Escaped delimiters are converted back into their normal forms.
|
* string. Escaped delimiters are converted back into their normal forms.
|
||||||
*/
|
*/
|
||||||
private fun String.parseSQLMultiValue() =
|
private fun String.parseSQLMultiValue() = splitEscaped { it == ';' }.correctWhitespace()
|
||||||
splitEscaped { it == ';' }
|
|
||||||
|
|
||||||
/** Defines the columns used in this database. */
|
/** Defines the columns used in this database. */
|
||||||
private object Columns {
|
private object Columns {
|
||||||
|
|
|
@ -24,10 +24,8 @@ package org.oxycblt.auxio.music.extractor
|
||||||
enum class ExtractionResult {
|
enum class ExtractionResult {
|
||||||
/** A raw song was successfully extracted from the cache. */
|
/** A raw song was successfully extracted from the cache. */
|
||||||
CACHED,
|
CACHED,
|
||||||
|
|
||||||
/** A raw song was successfully extracted from parsing it's file. */
|
/** A raw song was successfully extracted from parsing it's file. */
|
||||||
PARSED,
|
PARSED,
|
||||||
|
|
||||||
/** A raw song could not be parsed. */
|
/** A raw song could not be parsed. */
|
||||||
NONE
|
NONE
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,17 +27,20 @@ import androidx.annotation.RequiresApi
|
||||||
import androidx.core.database.getIntOrNull
|
import androidx.core.database.getIntOrNull
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import org.oxycblt.auxio.music.Date
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.storage.Directory
|
import org.oxycblt.auxio.music.filesystem.Directory
|
||||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
|
||||||
import org.oxycblt.auxio.music.storage.directoryCompat
|
import org.oxycblt.auxio.music.filesystem.directoryCompat
|
||||||
import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
|
import org.oxycblt.auxio.music.filesystem.mediaStoreVolumeNameCompat
|
||||||
import org.oxycblt.auxio.music.storage.safeQuery
|
import org.oxycblt.auxio.music.filesystem.safeQuery
|
||||||
import org.oxycblt.auxio.music.storage.storageVolumesCompat
|
import org.oxycblt.auxio.music.filesystem.storageVolumesCompat
|
||||||
import org.oxycblt.auxio.music.storage.useQuery
|
import org.oxycblt.auxio.music.filesystem.useQuery
|
||||||
|
import org.oxycblt.auxio.music.parsing.parseId3v2Position
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.logD
|
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
|
* 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
|
// 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.
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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) }
|
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
|
* The base selector that works across all versions of android. Does not exclude
|
||||||
* directories.
|
* 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
|
* 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.
|
* versions that Auxio supports.
|
||||||
*/
|
*/
|
||||||
@Suppress("InlinedApi")
|
@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
|
* 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.
|
* 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
|
// 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
|
// N is the number and T is the total. Parse the number while ignoring the
|
||||||
// total, as we have no use for it.
|
// total, as we have no use for it.
|
||||||
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it }
|
cursor.getStringOrNull(trackIndex)?.parseId3v2Position()?.let { raw.track = it }
|
||||||
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = 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 androidx.core.text.isDigitsOnly
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import com.google.android.exoplayer2.MediaItem
|
||||||
import com.google.android.exoplayer2.MetadataRetriever
|
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.Date
|
||||||
import org.oxycblt.auxio.music.Song
|
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.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
|
@ -116,8 +114,8 @@ class MetadataExtractor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private const val TASK_CAPACITY = 8
|
const val TASK_CAPACITY = 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,7 +126,6 @@ class MetadataExtractor(
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class Task(context: Context, private val raw: Song.Raw) {
|
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
|
// 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
|
// (highly fallible) extraction process will not bubble up to Indexer when a
|
||||||
// listener is used, instead crashing the app entirely.
|
// 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? {
|
fun get(): Song.Raw? {
|
||||||
if (!future.isDone) {
|
if (!future.isDone) {
|
||||||
|
// Not done yet, nothing to do.
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -162,7 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) {
|
||||||
|
|
||||||
val metadata = format.metadata
|
val metadata = format.metadata
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
populateWithMetadata(metadata)
|
val tags = Tags(metadata)
|
||||||
|
populateWithId3v2(tags.id3v2)
|
||||||
|
populateWithVorbis(tags.vorbis)
|
||||||
} else {
|
} else {
|
||||||
logD("No metadata could be extracted for ${raw.name}")
|
logD("No metadata could be extracted for ${raw.name}")
|
||||||
}
|
}
|
||||||
|
@ -170,51 +170,6 @@ class Task(context: Context, private val raw: Song.Raw) {
|
||||||
return 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.
|
* 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
|
* @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>>) {
|
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
|
||||||
// Song
|
// 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["TIT2"]?.let { raw.name = it[0] }
|
||||||
textFrames["TSOT"]?.let { raw.sortName = it[0] }
|
textFrames["TSOT"]?.let { raw.sortName = it[0] }
|
||||||
|
|
||||||
// Track. Only parse out the track number and ignore the total tracks value.
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 3. ID3v2.4 Release Date, as it is the second most common date type
|
||||||
// 4. ID3v2.3 Original Date, as it is like #1
|
// 4. ID3v2.3 Original Date, as it is like #1
|
||||||
// 5. ID3v2.3 Release Year, as it is the most common date type
|
// 5. ID3v2.3 Release Year, as it is the most common date type
|
||||||
(textFrames["TDOR"]?.run { get(0).parseTimestamp() }
|
(textFrames["TDOR"]?.run { Date.from(first()) }
|
||||||
?: textFrames["TDRC"]?.run { get(0).parseTimestamp() }
|
?: textFrames["TDRC"]?.run { Date.from(first()) }
|
||||||
?: textFrames["TDRL"]?.run { get(0).parseTimestamp() }
|
?: textFrames["TDRL"]?.run { Date.from(first()) }
|
||||||
?: parseId3v23Date(textFrames))
|
?: parseId3v23Date(textFrames))
|
||||||
?.let { raw.date = it }
|
?.let { raw.date = it }
|
||||||
|
|
||||||
// Album
|
// 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["TALB"]?.let { raw.albumName = it[0] }
|
||||||
textFrames["TSOA"]?.let { raw.albumSortName = 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
|
raw.albumTypes = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist
|
// 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["TPE1"]?.let { raw.artistNames = it }
|
||||||
textFrames["TSOP"]?.let { raw.artistSortNames = it }
|
textFrames["TSOP"]?.let { raw.artistSortNames = it }
|
||||||
|
|
||||||
// Album artist
|
// 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["TPE2"]?.let { raw.albumArtistNames = it }
|
||||||
textFrames["TSO2"]?.let { raw.albumArtistSortNames = 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
|
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
||||||
// is present.
|
// is present.
|
||||||
val year =
|
val year =
|
||||||
textFrames["TORY"]?.run { get(0).toIntOrNull() }
|
textFrames["TORY"]?.run { first().toIntOrNull() }
|
||||||
?: textFrames["TYER"]?.run { get(0).toIntOrNull() } ?: return null
|
?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
|
||||||
|
|
||||||
val tdat = textFrames["TDAT"]
|
val tdat = textFrames["TDAT"]
|
||||||
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) {
|
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>>) {
|
private fun populateWithVorbis(comments: Map<String, List<String>>) {
|
||||||
// Song
|
// Song
|
||||||
comments["MUSICBRAINZ_RELEASETRACKID"]?.let { raw.musicBrainzId = it[0] }
|
comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] }
|
||||||
comments["TITLE"]?.let { raw.name = it[0] }
|
comments["title"]?.let { raw.name = it[0] }
|
||||||
comments["TITLESORT"]?.let { raw.sortName = it[0] }
|
comments["titlesort"]?.let { raw.sortName = it[0] }
|
||||||
|
|
||||||
// Track. The total tracks value is in a different comment, so we can just
|
// Track. The total tracks value is in a different comment, so we can just
|
||||||
// convert the entirety of this comment into a number.
|
// 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
|
// Disc. The total discs value is in a different comment, so we can just
|
||||||
// convert the entirety of this comment into a number.
|
// 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
|
// Vorbis dates are less complicated, but there are still several types
|
||||||
// Our hierarchy for dates is as such:
|
// 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
|
// 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
|
// 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!)
|
// date tag that android supports, so it must be 15 years old or more!)
|
||||||
(comments["ORIGINALDATE"]?.run { get(0).parseTimestamp() }
|
(comments["originaldate"]?.run { Date.from(first()) }
|
||||||
?: comments["DATE"]?.run { get(0).parseTimestamp() }
|
?: comments["date"]?.run { Date.from(first()) }
|
||||||
?: comments["YEAR"]?.run { get(0).parseYear() })
|
?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) })
|
||||||
?.let { raw.date = it }
|
?.let { raw.date = it }
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
comments["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] }
|
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||||
comments["ALBUM"]?.let { raw.albumName = it[0] }
|
comments["album"]?.let { raw.albumName = it[0] }
|
||||||
comments["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
|
comments["albumsort"]?.let { raw.albumSortName = it[0] }
|
||||||
comments["RELEASETYPE"]?.let { raw.albumTypes = it }
|
comments["releasetype"]?.let { raw.albumTypes = it }
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
comments["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it }
|
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
|
||||||
comments["ARTIST"]?.let { raw.artistNames = it }
|
comments["artist"]?.let { raw.artistNames = it }
|
||||||
comments["ARTISTSORT"]?.let { raw.artistSortNames = it }
|
comments["artistsort"]?.let { raw.artistSortNames = it }
|
||||||
|
|
||||||
// Album artist
|
// Album artist
|
||||||
comments["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it }
|
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||||
comments["ALBUMARTIST"]?.let { raw.albumArtistNames = it }
|
comments["albumartist"]?.let { raw.albumArtistNames = it }
|
||||||
comments["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it }
|
comments["albumartistsort"]?.let { raw.albumArtistSortNames = it }
|
||||||
|
|
||||||
// Genre
|
// Genre
|
||||||
comments["GENRE"]?.let { raw.genreNames = it }
|
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/>.
|
* 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.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
@ -41,7 +41,7 @@ class DirectoryAdapter(private val listener: Listener) :
|
||||||
override fun getItemCount() = dirs.size
|
override fun getItemCount() = dirs.size
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
MusicDirViewHolder.new(parent)
|
MusicDirViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
|
override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
|
||||||
holder.bind(dirs[position], listener)
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
|
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -15,15 +15,13 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.content.Context
|
||||||
import android.media.MediaExtractor
|
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import android.os.storage.StorageVolume
|
import android.os.storage.StorageVolume
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import com.google.android.exoplayer2.util.MimeTypes
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.annotation.SuppressLint
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
|
@ -15,13 +15,14 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
@ -30,7 +31,6 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
|
@ -42,10 +42,8 @@ import org.oxycblt.auxio.util.showToast
|
||||||
class MusicDirsDialog :
|
class MusicDirsDialog :
|
||||||
ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
||||||
private val dirAdapter = DirectoryAdapter(this)
|
private val dirAdapter = DirectoryAdapter(this)
|
||||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
|
||||||
private val storageManager: StorageManager by lifecycleObject { binding ->
|
private var storageManager: StorageManager? = null
|
||||||
binding.context.getSystemServiceCompat(StorageManager::class)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||||
DialogMusicDirsBinding.inflate(inflater)
|
DialogMusicDirsBinding.inflate(inflater)
|
||||||
|
@ -57,7 +55,10 @@ class MusicDirsDialog :
|
||||||
.setNeutralButton(R.string.lbl_add, null)
|
.setNeutralButton(R.string.lbl_add, null)
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
.setNegativeButton(R.string.lbl_cancel, null)
|
||||||
.setPositiveButton(R.string.lbl_save) { _, _ ->
|
.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()))
|
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
|
||||||
if (dirs != newDirs) {
|
if (dirs != newDirs) {
|
||||||
logD("Committing changes")
|
logD("Committing changes")
|
||||||
|
@ -67,7 +68,11 @@ class MusicDirsDialog :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
|
||||||
val launcher =
|
val context = requireContext()
|
||||||
|
val storageManager =
|
||||||
|
context.getSystemServiceCompat(StorageManager::class).also { storageManager = it }
|
||||||
|
|
||||||
|
openDocumentTreeLauncher =
|
||||||
registerForActivityResult(
|
registerForActivityResult(
|
||||||
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
|
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
|
||||||
|
|
||||||
|
@ -79,7 +84,10 @@ class MusicDirsDialog :
|
||||||
val dialog = it as AlertDialog
|
val dialog = it as AlertDialog
|
||||||
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
|
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
|
||||||
logD("Opening launcher")
|
logD("Opening launcher")
|
||||||
launcher.launch(null)
|
requireNotNull(openDocumentTreeLauncher) {
|
||||||
|
"Document tree launcher was not available"
|
||||||
|
}
|
||||||
|
.launch(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,7 +96,7 @@ class MusicDirsDialog :
|
||||||
itemAnimator = null
|
itemAnimator = null
|
||||||
}
|
}
|
||||||
|
|
||||||
var dirs = settings.getMusicDirs(storageManager)
|
var dirs = Settings(context).getMusicDirs(storageManager)
|
||||||
|
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState != null) {
|
||||||
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
|
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
|
||||||
|
@ -127,6 +135,8 @@ class MusicDirsDialog :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
|
override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
|
storageManager = null
|
||||||
|
openDocumentTreeLauncher = null
|
||||||
binding.dirsRecycler.adapter = null
|
binding.dirsRecycler.adapter = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -153,7 +163,9 @@ class MusicDirsDialog :
|
||||||
DocumentsContract.buildDocumentUriUsingTree(
|
DocumentsContract.buildDocumentUriUsingTree(
|
||||||
uri, DocumentsContract.getTreeDocumentId(uri))
|
uri, DocumentsContract.getTreeDocumentId(uri))
|
||||||
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
|
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) {
|
if (dir != null) {
|
||||||
dirAdapter.add(dir)
|
dirAdapter.add(dir)
|
||||||
|
@ -176,7 +188,7 @@ class MusicDirsDialog :
|
||||||
private fun isUiModeInclude(binding: DialogMusicDirsBinding) =
|
private fun isUiModeInclude(binding: DialogMusicDirsBinding) =
|
||||||
binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include
|
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_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS"
|
||||||
const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE"
|
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/>.
|
* 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.settings.Settings
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
|
||||||
/**
|
/// --- GENERIC PARSING ---
|
||||||
* 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()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within
|
* Parse a multi-value tag based on the user configuration. If the value is already composed of more
|
||||||
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the
|
||||||
* disc number is the 4th+ digit.
|
* user's separator preferences.
|
||||||
* @return The disc number extracted from the combined integer field, or null if the value was zero.
|
* @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()
|
fun List<String>.parseMultiValue(settings: Settings) =
|
||||||
|
if (size == 1) {
|
||||||
/**
|
first().maybeParseBySeparators(settings)
|
||||||
* Parse the number out of a combined number + total position [String] field. These fields often
|
} else {
|
||||||
* appear in ID3v2 files, and consist of a number and an (optional) total value delimited by a /.
|
// Nothing to do.
|
||||||
* @return The number value extracted from the string field, or null if the value could not be
|
this
|
||||||
* 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)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split a [String] by the given selector, automatically handling escaped characters that satisfy
|
* 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
|
* Fix trailing whitespace or blank contents in a [String].
|
||||||
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the
|
* @return A string with trailing whitespace remove,d or null if the [String] was all whitespace or
|
||||||
* user's separator preferences.
|
* empty.
|
||||||
* @param settings [Settings] required to obtain user separator configuration.
|
|
||||||
* @return A new list of one or more [String]s.
|
|
||||||
*/
|
*/
|
||||||
fun List<String>.parseMultiValue(settings: Settings) =
|
fun String.correctWhitespace() = trim().ifBlank { null }
|
||||||
if (size == 1) {
|
|
||||||
get(0).maybeParseSeparators(settings)
|
/**
|
||||||
} else {
|
* Fix trailing whitespace or blank contents within a list of [String]s.
|
||||||
// Nothing to do.
|
* @return A list of non-blank strings with trailing whitespace removed.
|
||||||
this.map { it.trim() }
|
*/
|
||||||
}
|
fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to parse a string by the user's separator preferences.
|
* Attempt to parse a string by the user's separator preferences.
|
||||||
* @param settings [Settings] required to obtain user separator configuration.
|
* @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.
|
* @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.
|
// Get the separators the user desires. If null, there's nothing to do.
|
||||||
val separators = settings.musicSeparators ?: return listOf(this)
|
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].
|
* Parse the number out of a ID3v2-style number + total position [String] field. These fields
|
||||||
* @return A [UUID] converted from the [String] value, or null if the value was not valid.
|
* consist of a number and an (optional) total value delimited by a /.
|
||||||
* @see UUID.fromString
|
* @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? =
|
fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
|
||||||
try {
|
|
||||||
UUID.fromString(this)
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer
|
* 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) =
|
fun List<String>.parseId3GenreNames(settings: Settings) =
|
||||||
if (size == 1) {
|
if (size == 1) {
|
||||||
get(0).parseId3GenreNames(settings)
|
first().parseId3MultiValueGenre(settings)
|
||||||
} else {
|
} else {
|
||||||
// Nothing to split, just map any ID3v1 genres to their name counterparts.
|
// Nothing to split, just map any ID3v1 genres to their name counterparts.
|
||||||
map { it.parseId3v1Genre() ?: it }
|
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.
|
* Parse a single ID3v1/ID3v2 integer genre field into their named representations.
|
||||||
* @return A list of one or more genre names.
|
* @return A list of one or more genre names.
|
||||||
*/
|
*/
|
||||||
fun String.parseId3GenreNames(settings: Settings) =
|
private fun String.parseId3MultiValueGenre(settings: Settings) =
|
||||||
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseSeparators(settings)
|
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an ID3v1 integer genre field.
|
* Parse an ID3v1 integer genre field.
|
||||||
|
@ -182,15 +144,17 @@ fun String.parseId3GenreNames(settings: Settings) =
|
||||||
*/
|
*/
|
||||||
private fun String.parseId3v1Genre(): String? {
|
private fun String.parseId3v1Genre(): String? {
|
||||||
// ID3v1 genres are a plain integer value without formatting, so in that case
|
// 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
|
// try to index the genre table with such.
|
||||||
// to some other hard-coded values.
|
val numeric =
|
||||||
val numeric = toIntOrNull() ?: return when (this) {
|
toIntOrNull()
|
||||||
// CR and RX are not technically ID3v1, but are formatted similarly to a plain number.
|
// Not a numeric value, try some other fixed values.
|
||||||
"CR" -> "Cover"
|
?: return when (this) {
|
||||||
"RX" -> "Remix"
|
// CR and RX are not technically ID3v1, but are formatted similarly to a plain
|
||||||
else -> null
|
// number.
|
||||||
}
|
"CR" -> "Cover"
|
||||||
|
"RX" -> "Remix"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
return GENRE_TABLE.getOrNull(numeric)
|
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/>.
|
* 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.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
@ -27,7 +27,6 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
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
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
||||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||||
DialogSeparatorsBinding.inflate(inflater)
|
DialogSeparatorsBinding.inflate(inflater)
|
||||||
|
|
||||||
|
@ -45,7 +42,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
||||||
.setTitle(R.string.set_separators)
|
.setTitle(R.string.set_separators)
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
.setNegativeButton(R.string.lbl_cancel, null)
|
||||||
.setPositiveButton(R.string.lbl_save) { _, _ ->
|
.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
|
// More efficient to do one iteration through the separator list and initialize
|
||||||
// the corresponding CheckBox for each character instead of doing an iteration
|
// the corresponding CheckBox for each character instead of doing an iteration
|
||||||
// through the separator list for each CheckBox.
|
// through the separator list for each CheckBox.
|
||||||
(savedInstanceState?.getString(KEY_PENDING_SEPARATORS) ?: settings.musicSeparators)?.forEach {
|
(savedInstanceState?.getString(KEY_PENDING_SEPARATORS)
|
||||||
when (it) {
|
?: Settings(requireContext()).musicSeparators)
|
||||||
SEPARATOR_COMMA -> binding.separatorComma.isChecked = true
|
?.forEach {
|
||||||
SEPARATOR_SEMICOLON -> binding.separatorSemicolon.isChecked = true
|
when (it) {
|
||||||
SEPARATOR_SLASH -> binding.separatorSlash.isChecked = true
|
Separators.COMMA -> binding.separatorComma.isChecked = true
|
||||||
SEPARATOR_PLUS -> binding.separatorPlus.isChecked = true
|
Separators.SEMICOLON -> binding.separatorSemicolon.isChecked = true
|
||||||
SEPARATOR_AND -> binding.separatorAnd.isChecked = true
|
Separators.SLASH -> binding.separatorSlash.isChecked = true
|
||||||
else -> error("Unexpected separator in settings data")
|
Separators.PLUS -> binding.separatorPlus.isChecked = true
|
||||||
|
Separators.AND -> binding.separatorAnd.isChecked = true
|
||||||
|
else -> error("Unexpected separator in settings data")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
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.
|
// of use a mapping that could feasibly drift from the actual layout.
|
||||||
var separators = ""
|
var separators = ""
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
if (binding.separatorComma.isChecked) separators += SEPARATOR_COMMA
|
if (binding.separatorComma.isChecked) separators += Separators.COMMA
|
||||||
if (binding.separatorSemicolon.isChecked) separators += SEPARATOR_SEMICOLON
|
if (binding.separatorSemicolon.isChecked) separators += Separators.SEMICOLON
|
||||||
if (binding.separatorSlash.isChecked) separators += SEPARATOR_SLASH
|
if (binding.separatorSlash.isChecked) separators += Separators.SLASH
|
||||||
if (binding.separatorPlus.isChecked) separators += SEPARATOR_PLUS
|
if (binding.separatorPlus.isChecked) separators += Separators.PLUS
|
||||||
if (binding.separatorAnd.isChecked) separators += SEPARATOR_AND
|
if (binding.separatorAnd.isChecked) separators += Separators.AND
|
||||||
return separators
|
return separators
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS"
|
const 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 = '&'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -39,7 +39,7 @@ class ArtistChoiceAdapter(private val listener: ClickableListListener) :
|
||||||
override fun getItemCount() = artists.size
|
override fun getItemCount() = artists.size
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
ArtistChoiceViewHolder.new(parent)
|
ArtistChoiceViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) =
|
override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) =
|
||||||
holder.bind(artists[position], listener)
|
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
|
* 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) :
|
class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
||||||
DialogRecyclerView.ViewHolder(binding.root) {
|
DialogRecyclerView.ViewHolder(binding.root) {
|
||||||
|
@ -68,7 +68,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
||||||
* @param listener A [ClickableListListener] to bind interactions to.
|
* @param listener A [ClickableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(artist: Artist, listener: ClickableListListener) {
|
fun bind(artist: Artist, listener: ClickableListListener) {
|
||||||
binding.root.setOnClickListener { listener.onClick(artist) }
|
listener.bind(artist, this)
|
||||||
binding.pickerImage.bind(artist)
|
binding.pickerImage.bind(artist)
|
||||||
binding.pickerName.text = artist.resolveName(binding.context)
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
|
ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.picker
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
@ -40,8 +41,8 @@ class ArtistNavigationPickerDialog : ArtistPickerDialog() {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(item: Item) {
|
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||||
super.onClick(item)
|
super.onClick(item, viewHolder)
|
||||||
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
|
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||||
// User made a choice, navigate to it.
|
// User made a choice, navigate to it.
|
||||||
navModel.exploreNavigateTo(item)
|
navModel.exploreNavigateTo(item)
|
||||||
|
|
|
@ -22,6 +22,7 @@ import android.view.LayoutInflater
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||||
import org.oxycblt.auxio.list.ClickableListListener
|
import org.oxycblt.auxio.list.ClickableListListener
|
||||||
|
@ -67,7 +68,7 @@ abstract class ArtistPickerDialog :
|
||||||
binding.pickerRecycler.adapter = null
|
binding.pickerRecycler.adapter = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(item: Item) {
|
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.picker
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
@ -41,8 +42,8 @@ class ArtistPlaybackPickerDialog : ArtistPickerDialog() {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(item: Item) {
|
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||||
super.onClick(item)
|
super.onClick(item, viewHolder)
|
||||||
// User made a choice, play the given song from that artist.
|
// User made a choice, play the given song from that artist.
|
||||||
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
|
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||||
val song = pickerModel.currentItem.value
|
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
|
package org.oxycblt.auxio.music.picker
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
@ -22,7 +39,7 @@ class GenreChoiceAdapter(private val listener: ClickableListListener) :
|
||||||
override fun getItemCount() = genres.size
|
override fun getItemCount() = genres.size
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
GenreChoiceViewHolder.new(parent)
|
GenreChoiceViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) =
|
override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) =
|
||||||
holder.bind(genres[position], listener)
|
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
|
* 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) :
|
class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
||||||
DialogRecyclerView.ViewHolder(binding.root) {
|
DialogRecyclerView.ViewHolder(binding.root) {
|
||||||
|
@ -51,7 +68,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
||||||
* @param listener A [ClickableListListener] to bind interactions to.
|
* @param listener A [ClickableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(genre: Genre, listener: ClickableListListener) {
|
fun bind(genre: Genre, listener: ClickableListListener) {
|
||||||
binding.root.setOnClickListener { listener.onClick(genre) }
|
listener.bind(genre, this)
|
||||||
binding.pickerImage.bind(genre)
|
binding.pickerImage.bind(genre)
|
||||||
binding.pickerName.text = genre.resolveName(binding.context)
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
|
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
|
package org.oxycblt.auxio.music.picker
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
@ -6,6 +23,7 @@ import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||||
import org.oxycblt.auxio.list.ClickableListListener
|
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.
|
* A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class GenrePlaybackPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
|
class GenrePlaybackPickerDialog :
|
||||||
|
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
|
||||||
private val pickerModel: PickerViewModel by viewModels()
|
private val pickerModel: PickerViewModel by viewModels()
|
||||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||||
// Information about what Song to show choices for is initially within the navigation arguments
|
// Information about what Song to show choices for is initially within the navigation arguments
|
||||||
|
@ -56,7 +75,7 @@ class GenrePlaybackPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBin
|
||||||
binding.pickerRecycler.adapter = null
|
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.
|
// User made a choice, play the given song from that genre.
|
||||||
check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" }
|
check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||||
val song = pickerModel.currentItem.value
|
val song = pickerModel.currentItem.value
|
||||||
|
|
|
@ -28,12 +28,13 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
* contain the music themselves and then exit if the library changes.
|
* contain the music themselves and then exit if the library changes.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class PickerViewModel : ViewModel(), MusicStore.Callback {
|
class PickerViewModel : ViewModel(), MusicStore.Listener {
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
|
|
||||||
private val _currentItem = MutableStateFlow<Music?>(null)
|
private val _currentItem = MutableStateFlow<Music?>(null)
|
||||||
/** The current item whose artists should be shown in the picker. Null if there is no item. */
|
/** 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())
|
private val _artistChoices = MutableStateFlow<List<Artist>>(listOf())
|
||||||
/** The current [Artist] choices. Empty if no item is shown in the picker. */
|
/** The current [Artist] choices. Empty if no item is shown in the picker. */
|
||||||
|
@ -46,7 +47,7 @@ class PickerViewModel : ViewModel(), MusicStore.Callback {
|
||||||
get() = _genreChoices
|
get() = _genreChoices
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
musicStore.removeCallback(this)
|
musicStore.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||||
|
@ -75,5 +76,4 @@ class PickerViewModel : ViewModel(), MusicStore.Callback {
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,10 +51,10 @@ import org.oxycblt.auxio.util.logW
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class Indexer private constructor() {
|
class Indexer private constructor() {
|
||||||
private var lastResponse: Response? = null
|
@Volatile private var lastResponse: Result<MusicStore.Library>? = null
|
||||||
private var indexingState: Indexing? = null
|
@Volatile private var indexingState: Indexing? = null
|
||||||
private var controller: Controller? = null
|
@Volatile private var controller: Controller? = null
|
||||||
private var callback: Callback? = null
|
@Volatile private var listener: Listener? = null
|
||||||
|
|
||||||
/** Whether music loading is occurring or not. */
|
/** Whether music loading is occurring or not. */
|
||||||
val isIndexing: Boolean
|
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
|
* 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
|
* 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.
|
* @param controller The [Controller] to register. Will do nothing if already registered.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@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
|
* 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 [Callback] at a time. Will invoke all
|
* the current music loading state. There can be only one [Listener] 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 callback The [Callback] to add.
|
* @param listener The [Listener] to add.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun registerCallback(callback: Callback) {
|
fun registerListener(listener: Listener) {
|
||||||
if (BuildConfig.DEBUG && this.callback != null) {
|
if (BuildConfig.DEBUG && this.listener != null) {
|
||||||
logW("Listener is already registered")
|
logW("Listener is already registered")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -120,24 +120,24 @@ class Indexer private constructor() {
|
||||||
// Initialize the listener with the current state.
|
// Initialize the listener with the current state.
|
||||||
val currentState =
|
val currentState =
|
||||||
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
|
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
|
||||||
callback.onIndexerStateChanged(currentState)
|
listener.onIndexerStateChanged(currentState)
|
||||||
this.callback = callback
|
this.listener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregister a [Callback] from this instance, preventing it from recieving any further updates.
|
* Unregister a [Listener] from this instance, preventing it from recieving any further updates.
|
||||||
* @param callback The [Callback] to unregister. Must be the current [Callback]. Does nothing if
|
* @param listener The [Listener] to unregister. Must be the current [Listener]. Does nothing if
|
||||||
* invoked by another [Callback] implementation.
|
* invoked by another [Listener] implementation.
|
||||||
* @see Callback
|
* @see Listener
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun unregisterCallback(callback: Callback) {
|
fun unregisterListener(listener: Listener) {
|
||||||
if (BuildConfig.DEBUG && this.callback !== callback) {
|
if (BuildConfig.DEBUG && this.listener !== listener) {
|
||||||
logW("Given controller did not match current controller")
|
logW("Given controller did not match current controller")
|
||||||
return
|
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.
|
* be written, but no cache entries will be loaded into the new library.
|
||||||
*/
|
*/
|
||||||
suspend fun index(context: Context, withCache: Boolean) {
|
suspend fun index(context: Context, withCache: Boolean) {
|
||||||
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
val result =
|
||||||
PackageManager.PERMISSION_DENIED) {
|
|
||||||
// No permissions, signal that we can't do anything.
|
|
||||||
emitCompletion(Response.NoPerms)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val response =
|
|
||||||
try {
|
try {
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
val library = indexImpl(context, withCache)
|
val library = indexImpl(context, withCache)
|
||||||
if (library != null) {
|
logD(
|
||||||
// Successfully loaded a library.
|
"Music indexing completed successfully in " +
|
||||||
logD(
|
"${System.currentTimeMillis() - start}ms")
|
||||||
"Music indexing completed successfully in " +
|
Result.success(library)
|
||||||
"${System.currentTimeMillis() - start}ms")
|
|
||||||
Response.Ok(library)
|
|
||||||
} else {
|
|
||||||
// Loaded a library, but it contained no music.
|
|
||||||
logE("No music found")
|
|
||||||
Response.NoMusic
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
// Got cancelled, propagate upwards to top-level co-routine.
|
// Got cancelled, propagate upwards to top-level co-routine.
|
||||||
logD("Loading routine was cancelled")
|
logD("Loading routine was cancelled")
|
||||||
|
@ -178,10 +164,9 @@ class Indexer private constructor() {
|
||||||
// Music loading process failed due to something we have not handled.
|
// Music loading process failed due to something we have not handled.
|
||||||
logE("Music indexing failed")
|
logE("Music indexing failed")
|
||||||
logE(e.stackTraceToString())
|
logE(e.stackTraceToString())
|
||||||
Response.Err(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
|
emitCompletion(result)
|
||||||
emitCompletion(response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -212,9 +197,17 @@ class Indexer private constructor() {
|
||||||
* @param context [Context] required to load music.
|
* @param context [Context] required to load music.
|
||||||
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
|
* @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.
|
* 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
|
// Create the chain of extractors. Each extractor builds on the previous and
|
||||||
// enables version-specific features in order to create the best possible music
|
// enables version-specific features in order to create the best possible music
|
||||||
// experience.
|
// experience.
|
||||||
|
@ -236,12 +229,8 @@ class Indexer private constructor() {
|
||||||
|
|
||||||
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
|
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
|
||||||
|
|
||||||
val songs = buildSongs(metadataExtractor, Settings(context))
|
val songs =
|
||||||
if (songs.isEmpty()) {
|
buildSongs(metadataExtractor, Settings(context)).ifEmpty { throw NoMusicException() }
|
||||||
// No songs, nothing else to do.
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the rest of the music library from the song list. This is much more powerful
|
// 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.
|
// and reliable compared to using MediaStore to obtain grouping information.
|
||||||
val buildStart = System.currentTimeMillis()
|
val buildStart = System.currentTimeMillis()
|
||||||
|
@ -249,7 +238,6 @@ class Indexer private constructor() {
|
||||||
val artists = buildArtists(songs, albums)
|
val artists = buildArtists(songs, albums)
|
||||||
val genres = buildGenres(songs)
|
val genres = buildGenres(songs)
|
||||||
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
|
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
|
||||||
|
|
||||||
return MusicStore.Library(songs, albums, artists, genres)
|
return MusicStore.Library(songs, albums, artists, genres)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -388,17 +376,17 @@ class Indexer private constructor() {
|
||||||
val state =
|
val state =
|
||||||
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
|
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
|
||||||
controller?.onIndexerStateChanged(state)
|
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
|
* 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
|
* loading process to external code. Will check if the callee has not been canceled and thus has
|
||||||
* the ability to emit a new state
|
* 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.
|
* process.
|
||||||
*/
|
*/
|
||||||
private suspend fun emitCompletion(response: Response) {
|
private suspend fun emitCompletion(result: Result<MusicStore.Library>) {
|
||||||
yield()
|
yield()
|
||||||
// Swap to the Main thread so that downstream callbacks don't crash from being on
|
// 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.
|
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
|
||||||
|
@ -406,12 +394,12 @@ class Indexer private constructor() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
// Do not check for redundancy here, as we actually need to notify a switch
|
// Do not check for redundancy here, as we actually need to notify a switch
|
||||||
// from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
|
// from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
|
||||||
lastResponse = response
|
lastResponse = result
|
||||||
indexingState = null
|
indexingState = null
|
||||||
// Signal that the music loading process has been completed.
|
// Signal that the music loading process has been completed.
|
||||||
val state = State.Complete(response)
|
val state = State.Complete(result)
|
||||||
controller?.onIndexerStateChanged(state)
|
controller?.onIndexerStateChanged(state)
|
||||||
callback?.onIndexerStateChanged(state)
|
listener?.onIndexerStateChanged(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -427,10 +415,9 @@ class Indexer private constructor() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Music loading has completed.
|
* Music loading has completed.
|
||||||
* @param response The outcome of the music loading process.
|
* @param result The outcome of the music loading process.
|
||||||
* @see Response
|
|
||||||
*/
|
*/
|
||||||
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()
|
class Songs(val current: Int, val total: Int) : Indexing()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Represents the possible outcomes of the music loading process. */
|
/** Thrown when the required permissions to load the music library have not been granted yet. */
|
||||||
sealed class Response {
|
class NoPermissionException : Exception() {
|
||||||
/**
|
override val message: String
|
||||||
* Music load was successful and produced a [MusicStore.Library].
|
get() = "Not granted permissions to load music library"
|
||||||
* @param library The loaded [MusicStore.Library].
|
}
|
||||||
*/
|
|
||||||
data class Ok(val library: MusicStore.Library) : Response()
|
|
||||||
|
|
||||||
/**
|
/** Thrown when no music was found on the device. */
|
||||||
* Music loading encountered an unexpected error.
|
class NoMusicException : Exception() {
|
||||||
* @param throwable The error thrown.
|
override val message: String
|
||||||
*/
|
get() = "Unable to find any music"
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A listener for rapid-fire changes in the music loading state.
|
* 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.
|
* 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].
|
* the [MusicStore.Library].
|
||||||
*/
|
*/
|
||||||
interface Callback {
|
interface Listener {
|
||||||
/**
|
/**
|
||||||
* Called when the current state of the Indexer changed.
|
* 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
|
* 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.
|
* 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
|
* Called when a new music loading process was requested. Implementations should forward
|
||||||
* this to [index].
|
* this to [index].
|
||||||
|
@ -514,8 +492,7 @@ class Indexer private constructor() {
|
||||||
* system to load audio.
|
* system to load audio.
|
||||||
*/
|
*/
|
||||||
val PERMISSION_READ_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
|
// READ_EXTERNAL_STORAGE was superseded by READ_MEDIA_AUDIO in Android 13
|
||||||
Manifest.permission.READ_MEDIA_AUDIO
|
Manifest.permission.READ_MEDIA_AUDIO
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -72,7 +72,6 @@ class IndexingNotification(private val context: Context) :
|
||||||
// Determinate state, show an active progress meter. Since these updates arrive
|
// Determinate state, show an active progress meter. Since these updates arrive
|
||||||
// highly rapidly, only update every 1.5 seconds to prevent notification rate
|
// highly rapidly, only update every 1.5 seconds to prevent notification rate
|
||||||
// limiting.
|
// limiting.
|
||||||
// TODO: Can I port this to the playback notification somehow?
|
|
||||||
val now = SystemClock.elapsedRealtime()
|
val now = SystemClock.elapsedRealtime()
|
||||||
if (lastUpdateTime > -1 && (now - lastUpdateTime) < 1500) {
|
if (lastUpdateTime > -1 && (now - lastUpdateTime) < 1500) {
|
||||||
return false
|
return false
|
||||||
|
|
|
@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.system
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
@ -33,7 +34,7 @@ import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
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.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.service.ForegroundManager
|
import org.oxycblt.auxio.service.ForegroundManager
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
|
@ -54,7 +55,8 @@ import org.oxycblt.auxio.util.logD
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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 indexer = Indexer.getInstance()
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private val playbackManager = PlaybackStateManager.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
|
// 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.
|
// condition to cause us to load music before we were fully initialize.
|
||||||
indexerContentObserver = SystemContentObserver()
|
indexerContentObserver = SystemContentObserver()
|
||||||
settings = Settings(this, this)
|
settings = Settings(this)
|
||||||
|
settings.addListener(this)
|
||||||
indexer.registerController(this)
|
indexer.registerController(this)
|
||||||
// An indeterminate indexer and a missing library implies we are extremely early
|
// An indeterminate indexer and a missing library implies we are extremely early
|
||||||
// in app initialization so start loading music.
|
// 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
|
// Then cancel the listener-dependent components to ensure that stray reloading
|
||||||
// events will not occur.
|
// events will not occur.
|
||||||
indexerContentObserver.release()
|
indexerContentObserver.release()
|
||||||
settings.release()
|
settings.removeListener(this)
|
||||||
indexer.unregisterController(this)
|
indexer.unregisterController(this)
|
||||||
// Then cancel any remaining music loading jobs.
|
// Then cancel any remaining music loading jobs.
|
||||||
serviceJob.cancel()
|
serviceJob.cancel()
|
||||||
|
@ -126,11 +129,11 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
||||||
|
|
||||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
override fun onIndexerStateChanged(state: Indexer.State?) {
|
||||||
when (state) {
|
when (state) {
|
||||||
|
is Indexer.State.Indexing -> updateActiveSession(state.indexing)
|
||||||
is Indexer.State.Complete -> {
|
is Indexer.State.Complete -> {
|
||||||
if (state.response is Indexer.Response.Ok &&
|
val newLibrary = state.result.getOrNull()
|
||||||
state.response.library != musicStore.library) {
|
if (newLibrary != null && newLibrary != musicStore.library) {
|
||||||
logD("Applying new library")
|
logD("Applying new library")
|
||||||
val newLibrary = state.response.library
|
|
||||||
// We only care if the newly-loaded library is going to replace a previously
|
// We only care if the newly-loaded library is going to replace a previously
|
||||||
// loaded library.
|
// loaded library.
|
||||||
if (musicStore.library != null) {
|
if (musicStore.library != null) {
|
||||||
|
@ -149,9 +152,6 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
||||||
// handled right now.
|
// handled right now.
|
||||||
updateIdleSession()
|
updateIdleSession()
|
||||||
}
|
}
|
||||||
is Indexer.State.Indexing -> {
|
|
||||||
updateActiveSession(state.indexing)
|
|
||||||
}
|
|
||||||
null -> {
|
null -> {
|
||||||
// Null is the indeterminate state that occurs on app startup or after
|
// 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
|
// 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,
|
// 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.
|
// 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
|
// 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)) {
|
if (!foregroundManager.tryStartForeground(observingNotification)) {
|
||||||
observingNotification.post()
|
observingNotification.post()
|
||||||
}
|
}
|
||||||
|
@ -230,7 +230,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
||||||
|
|
||||||
// --- SETTING CALLBACKS ---
|
// --- SETTING CALLBACKS ---
|
||||||
|
|
||||||
override fun onSettingChanged(key: String) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||||
when (key) {
|
when (key) {
|
||||||
// Hook changes in music settings to a new music loading event.
|
// Hook changes in music settings to a new music loading event.
|
||||||
getString(R.string.set_key_exclude_non_music),
|
getString(R.string.set_key_exclude_non_music),
|
||||||
|
@ -287,8 +287,8 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
|
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
|
||||||
private const val REINDEX_DELAY_MS = 500L
|
const val REINDEX_DELAY_MS = 500L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import android.media.audiofx.AudioEffect
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
@ -53,13 +54,7 @@ class PlaybackPanelFragment :
|
||||||
StyledSeekBar.Listener {
|
StyledSeekBar.Listener {
|
||||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||||
private val navModel: NavigationViewModel by activityViewModels()
|
private val navModel: NavigationViewModel by activityViewModels()
|
||||||
// AudioEffect expects you to use startActivityForResult with the panel intent. There is no
|
private var equalizerLauncher: ActivityResultLauncher<Intent>? = null
|
||||||
// contract analogue for this intent, so the generic contract is used instead.
|
|
||||||
private val equalizerLauncher by lifecycleObject {
|
|
||||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
// Nothing to do
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||||
FragmentPlaybackPanelBinding.inflate(inflater)
|
FragmentPlaybackPanelBinding.inflate(inflater)
|
||||||
|
@ -70,6 +65,13 @@ class PlaybackPanelFragment :
|
||||||
) {
|
) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
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 ---
|
// --- UI SETUP ---
|
||||||
binding.root.setOnApplyWindowInsetsListener { view, insets ->
|
binding.root.setOnApplyWindowInsetsListener { view, insets ->
|
||||||
val bars = insets.systemBarInsetsCompat
|
val bars = insets.systemBarInsetsCompat
|
||||||
|
@ -100,6 +102,7 @@ class PlaybackPanelFragment :
|
||||||
binding.playbackSeekBar.listener = this
|
binding.playbackSeekBar.listener = this
|
||||||
|
|
||||||
// Set up actions
|
// Set up actions
|
||||||
|
// TODO: Add better playback button accessibility
|
||||||
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
|
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
|
||||||
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
|
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
|
||||||
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
|
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
|
||||||
|
@ -116,6 +119,7 @@ class PlaybackPanelFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
|
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
|
||||||
|
equalizerLauncher = null
|
||||||
binding.playbackToolbar.setOnMenuItemClickListener(null)
|
binding.playbackToolbar.setOnMenuItemClickListener(null)
|
||||||
// Marquee elements leak if they are not disabled when the views are destroyed.
|
// Marquee elements leak if they are not disabled when the views are destroyed.
|
||||||
binding.playbackSong.isSelected = false
|
binding.playbackSong.isSelected = false
|
||||||
|
@ -127,10 +131,9 @@ class PlaybackPanelFragment :
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.action_open_equalizer -> {
|
R.id.action_open_equalizer -> {
|
||||||
// Launch the system equalizer app, if possible.
|
// Launch the system equalizer app, if possible.
|
||||||
// TODO: Move this to a utility
|
|
||||||
val equalizerIntent =
|
val equalizerIntent =
|
||||||
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
|
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.
|
// in particular.
|
||||||
.putExtra(
|
.putExtra(
|
||||||
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId)
|
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId)
|
||||||
|
@ -138,7 +141,10 @@ class PlaybackPanelFragment :
|
||||||
// music playback.
|
// music playback.
|
||||||
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
||||||
try {
|
try {
|
||||||
equalizerLauncher.launch(equalizerIntent)
|
requireNotNull(equalizerLauncher) {
|
||||||
|
"Equalizer panel launcher was not available"
|
||||||
|
}
|
||||||
|
.launch(equalizerIntent)
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
requireContext().showToast(R.string.err_no_app)
|
requireContext().showToast(R.string.err_no_app)
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ import org.oxycblt.auxio.util.context
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class PlaybackViewModel(application: Application) :
|
class PlaybackViewModel(application: Application) :
|
||||||
AndroidViewModel(application), PlaybackStateManager.Callback {
|
AndroidViewModel(application), PlaybackStateManager.Listener {
|
||||||
private val settings = Settings(application)
|
private val settings = Settings(application)
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
private val playbackManager = PlaybackStateManager.getInstance()
|
||||||
private var lastPositionJob: Job? = null
|
private var lastPositionJob: Job? = null
|
||||||
|
@ -70,8 +70,8 @@ class PlaybackViewModel(application: Application) :
|
||||||
|
|
||||||
private val _artistPlaybackPickerSong = MutableStateFlow<Song?>(null)
|
private val _artistPlaybackPickerSong = MutableStateFlow<Song?>(null)
|
||||||
/**
|
/**
|
||||||
* Flag signaling to open a picker dialog in order to resolve an ambiguous choice when
|
* Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a
|
||||||
* playing a [Song] from one of it's [Artist]s.
|
* [Song] from one of it's [Artist]s.
|
||||||
* @see playFromArtist
|
* @see playFromArtist
|
||||||
*/
|
*/
|
||||||
val artistPickerSong: StateFlow<Song?>
|
val artistPickerSong: StateFlow<Song?>
|
||||||
|
@ -79,8 +79,8 @@ class PlaybackViewModel(application: Application) :
|
||||||
|
|
||||||
private val _genrePlaybackPickerSong = MutableStateFlow<Song?>(null)
|
private val _genrePlaybackPickerSong = MutableStateFlow<Song?>(null)
|
||||||
/**
|
/**
|
||||||
* Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing
|
* Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a
|
||||||
* a [Song] from one of it's [Genre]s.
|
* [Song] from one of it's [Genre]s.
|
||||||
*/
|
*/
|
||||||
val genrePickerSong: StateFlow<Song?>
|
val genrePickerSong: StateFlow<Song?>
|
||||||
get() = _genrePlaybackPickerSong
|
get() = _genrePlaybackPickerSong
|
||||||
|
@ -93,11 +93,11 @@ class PlaybackViewModel(application: Application) :
|
||||||
get() = playbackManager.currentAudioSessionId
|
get() = playbackManager.currentAudioSessionId
|
||||||
|
|
||||||
init {
|
init {
|
||||||
playbackManager.addCallback(this)
|
playbackManager.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
playbackManager.removeCallback(this)
|
playbackManager.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIndexMoved(index: Int) {
|
override fun onIndexMoved(index: Int) {
|
||||||
|
|
|
@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback.queue
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.drawable.LayerDrawable
|
import android.graphics.drawable.LayerDrawable
|
||||||
import android.view.MotionEvent
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
|
@ -27,6 +26,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
|
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.PlayingIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||||
import org.oxycblt.auxio.list.recycler.SyncListDiffer
|
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.
|
* 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)
|
* @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)
|
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK)
|
||||||
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this
|
// 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
|
// 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 getItemCount() = differ.currentList.size
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
QueueSongViewHolder.new(parent)
|
QueueSongViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: QueueSongViewHolder, position: Int) =
|
override fun onBindViewHolder(holder: QueueSongViewHolder, position: Int) =
|
||||||
throw IllegalStateException()
|
throw IllegalStateException()
|
||||||
|
@ -121,29 +122,13 @@ class QueueAdapter(private val listener: Listener) : RecyclerView.Adapter<QueueS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A listener for queue list events. */
|
private companion object {
|
||||||
interface Listener {
|
val PAYLOAD_UPDATE_POSITION = Any()
|
||||||
/**
|
|
||||||
* 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
* instance.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
|
@ -190,26 +175,17 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
|
||||||
/**
|
/**
|
||||||
* Bind new data to this instance.
|
* Bind new data to this instance.
|
||||||
* @param song The new [Song] to bind.
|
* @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")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
fun bind(song: Song, listener: QueueAdapter.Listener) {
|
fun bind(song: Song, listener: EditableListListener) {
|
||||||
binding.body.setOnClickListener { listener.onClick(this) }
|
listener.bind(song, this, bodyView, binding.songDragHandle)
|
||||||
|
|
||||||
binding.songAlbumCover.bind(song)
|
binding.songAlbumCover.bind(song)
|
||||||
binding.songName.text = song.resolveName(binding.context)
|
binding.songName.text = song.resolveName(binding.context)
|
||||||
binding.songInfo.text = song.resolveArtistContents(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
|
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) {
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(parent: View) =
|
fun from(parent: View) =
|
||||||
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
|
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** 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.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import kotlin.math.min
|
||||||
import org.oxycblt.auxio.databinding.FragmentQueueBinding
|
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.music.Song
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||||
|
@ -36,13 +39,11 @@ import org.oxycblt.auxio.util.logD
|
||||||
* A [ViewBindingFragment] that displays an editable queue.
|
* A [ViewBindingFragment] that displays an editable queue.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.Listener {
|
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener {
|
||||||
private val queueModel: QueueViewModel by activityViewModels()
|
private val queueModel: QueueViewModel by activityViewModels()
|
||||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||||
private val queueAdapter = QueueAdapter(this)
|
private val queueAdapter = QueueAdapter(this)
|
||||||
private val touchHelper: ItemTouchHelper by lifecycleObject {
|
private var touchHelper: ItemTouchHelper? = null
|
||||||
ItemTouchHelper(QueueDragCallback(queueModel))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
|
override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater)
|
||||||
|
|
||||||
|
@ -52,7 +53,10 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
binding.queueRecycler.apply {
|
binding.queueRecycler.apply {
|
||||||
adapter = queueAdapter
|
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
|
// 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
|
binding.queueRecycler.adapter = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(viewHolder: RecyclerView.ViewHolder) {
|
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
||||||
// Clicking on a queue item should start playing it.
|
|
||||||
queueModel.goto(viewHolder.bindingAdapterPosition)
|
queueModel.goto(viewHolder.bindingAdapterPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
||||||
touchHelper.startDrag(viewHolder)
|
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateDivider() {
|
private fun updateDivider() {
|
||||||
|
@ -108,17 +111,25 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), QueueAdapter.
|
||||||
queueModel.finishReplace()
|
queueModel.finishReplace()
|
||||||
|
|
||||||
// If requested, scroll to a new item (occurs when the index moves)
|
// If requested, scroll to a new item (occurs when the index moves)
|
||||||
// TODO: Scroll to center/top instead of bottom
|
|
||||||
val scrollTo = queueModel.scrollTo
|
val scrollTo = queueModel.scrollTo
|
||||||
if (scrollTo != null) {
|
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 lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
|
||||||
val start = lmm.findFirstCompletelyVisibleItemPosition()
|
val start = lmm.findFirstCompletelyVisibleItemPosition()
|
||||||
val end = lmm.findLastCompletelyVisibleItemPosition()
|
val end = lmm.findLastCompletelyVisibleItemPosition()
|
||||||
if (scrollTo !in start..end) {
|
val notInitialized =
|
||||||
logD("Scrolling to new position")
|
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)
|
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()
|
queueModel.finishScrollTo()
|
||||||
|
|
|
@ -29,7 +29,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
|
class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
private val playbackManager = PlaybackStateManager.getInstance()
|
||||||
|
|
||||||
private val _queue = MutableStateFlow(listOf<Song>())
|
private val _queue = MutableStateFlow(listOf<Song>())
|
||||||
|
@ -47,7 +47,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
|
||||||
var scrollTo: Int? = null
|
var scrollTo: Int? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
playbackManager.addCallback(this)
|
playbackManager.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -135,6 +135,6 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback {
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.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.databinding.DialogPreAmpBinding
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp].
|
* aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp].
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
|
class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
|
||||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater)
|
override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater)
|
||||||
|
|
||||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||||
|
@ -42,9 +39,12 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
|
||||||
.setTitle(R.string.set_pre_amp)
|
.setTitle(R.string.set_pre_amp)
|
||||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
settings.replayGainPreAmp =
|
Settings(requireContext()).replayGainPreAmp =
|
||||||
ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value)
|
ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value)
|
||||||
}
|
}
|
||||||
|
.setNeutralButton(R.string.lbl_reset) { _, _ ->
|
||||||
|
Settings(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f)
|
||||||
|
}
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
.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
|
// 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
|
// settings. After this, the sliders save their own state, so we do not need to
|
||||||
// do any restore behavior.
|
// do any restore behavior.
|
||||||
val preAmp = settings.replayGainPreAmp
|
val preAmp = Settings(requireContext()).replayGainPreAmp
|
||||||
binding.withTagsSlider.value = preAmp.with
|
binding.withTagsSlider.value = preAmp.with
|
||||||
binding.withoutTagsSlider.value = preAmp.without
|
binding.withoutTagsSlider.value = preAmp.without
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,33 +18,38 @@
|
||||||
package org.oxycblt.auxio.playback.replaygain
|
package org.oxycblt.auxio.playback.replaygain
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import com.google.android.exoplayer2.C
|
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.AudioProcessor
|
||||||
import com.google.android.exoplayer2.audio.BaseAudioProcessor
|
import com.google.android.exoplayer2.audio.BaseAudioProcessor
|
||||||
import com.google.android.exoplayer2.metadata.Metadata
|
import com.google.android.exoplayer2.util.MimeTypes
|
||||||
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 java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.extractor.Tags
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.logD
|
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.
|
* 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
|
* 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.
|
* 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)
|
* @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 playbackManager = PlaybackStateManager.getInstance()
|
||||||
private val settings = Settings(context)
|
private val settings = Settings(context)
|
||||||
|
private var lastFormat: Format? = null
|
||||||
|
|
||||||
private var volume = 1f
|
private var volume = 1f
|
||||||
set(value) {
|
set(value) {
|
||||||
|
@ -53,20 +58,69 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
||||||
flush()
|
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 ---
|
// --- REPLAYGAIN PARSING ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the volume adjustment based on the given [Metadata].
|
* Updates the volume adjustment based on the given [Format].
|
||||||
* @param metadata The [Metadata] of the currently playing track, or null if the track has no
|
* @param format The [Format] of the currently playing track, or null if nothing is playing.
|
||||||
* [Metadata].
|
|
||||||
*/
|
*/
|
||||||
fun applyReplayGain(metadata: Metadata?) {
|
private fun applyReplayGain(format: Format?) {
|
||||||
// TODO: Allow this to automatically obtain it's own [Metadata].
|
lastFormat = format
|
||||||
val gain = metadata?.let(::parseReplayGain)
|
val gain = parseReplayGain(format ?: return)
|
||||||
val preAmp = settings.replayGainPreAmp
|
val preAmp = settings.replayGainPreAmp
|
||||||
|
|
||||||
val adjust =
|
val adjust =
|
||||||
if (gain != null) {
|
if (gain != null) {
|
||||||
|
logD("Found ReplayGain adjustment $gain")
|
||||||
// ReplayGain is configurable, so determine what to do based off of the mode.
|
// ReplayGain is configurable, so determine what to do based off of the mode.
|
||||||
val useAlbumGain =
|
val useAlbumGain =
|
||||||
when (settings.replayGainMode) {
|
when (settings.replayGainMode) {
|
||||||
|
@ -109,104 +163,58 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse ReplayGain information from the given [Metadata].
|
* Parse ReplayGain information from the given [Format].
|
||||||
* @param metadata The [Metadata] to parse.
|
* @param format The [Format] to parse.
|
||||||
* @return A [Gain] adjustment, or null if there was no adjustments to parse.
|
* @return A [Adjustment] adjustment, or null if there were no valid adjustments.
|
||||||
*/
|
*/
|
||||||
private fun parseReplayGain(metadata: Metadata): Gain? {
|
private fun parseReplayGain(format: Format): Adjustment? {
|
||||||
// TODO: Unify this parser with the music parser? They both grok Metadata.
|
val tags = Tags(format.metadata ?: return null)
|
||||||
|
|
||||||
var trackGain = 0f
|
var trackGain = 0f
|
||||||
var albumGain = 0f
|
var albumGain = 0f
|
||||||
var found = false
|
|
||||||
|
|
||||||
val tags = mutableListOf<GainTag>()
|
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
|
||||||
|
// replaygain_*_gain tag.
|
||||||
for (i in 0 until metadata.length()) {
|
if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
|
||||||
val entry = metadata.get(i)
|
tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
|
||||||
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
val key: String?
|
?.let { trackGain = it }
|
||||||
val value: String
|
tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
|
||||||
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
when (entry) {
|
?.let { albumGain = it }
|
||||||
// ID3v2 text information frame, usually these are formatted in lowercase
|
tags.vorbis[TAG_RG_ALBUM_GAIN]
|
||||||
// (like "replaygain_track_gain"), but can also be uppercase. Make sure that
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
// capitalization is consistent before continuing.
|
?.let { trackGain = it }
|
||||||
is TextInformationFrame -> {
|
tags.vorbis[TAG_RG_TRACK_GAIN]
|
||||||
key = entry.description
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
value = entry.values[0]
|
?.let { albumGain = it }
|
||||||
}
|
} else {
|
||||||
// Internal Frame. This is actually MP4's "----" atom, but mapped to an ID3v2
|
// Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the
|
||||||
// frame by ExoPlayer (presumably to reduce duplication).
|
// adjustment by 256 to get the gain. This is used alongside the base adjustment
|
||||||
is InternalFrame -> {
|
// intrinsic to the format to create the normalized adjustment. That base adjustment
|
||||||
key = entry.description
|
// is already handled by the media framework, so we just need to apply the more
|
||||||
value = entry.text
|
// specific adjustments.
|
||||||
}
|
tags.vorbis[TAG_R128_TRACK_GAIN]
|
||||||
// Vorbis comment. These are nearly always uppercase, so a check for such is
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
// skipped.
|
?.let { trackGain = it / 256f }
|
||||||
is VorbisComment -> {
|
tags.vorbis[TAG_R128_ALBUM_GAIN]
|
||||||
key = entry.key
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
value = entry.value
|
?.let { albumGain = it / 256f }
|
||||||
}
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 1: Normal ReplayGain, most commonly found on MPEG files.
|
return if (trackGain != 0f || albumGain != 0f) {
|
||||||
tags
|
Adjustment(trackGain, albumGain)
|
||||||
.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)
|
|
||||||
} else {
|
} else {
|
||||||
null
|
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 ---
|
// --- AUDIO PROCESSOR IMPLEMENTATION ---
|
||||||
|
|
||||||
override fun onConfigure(
|
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 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.
|
* @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)
|
||||||
|
|
||||||
/**
|
private companion object {
|
||||||
* A raw ReplayGain adjustment.
|
const val TAG_RG_TRACK_GAIN = "replaygain_track_gain"
|
||||||
* @param key The tag's key.
|
const val TAG_RG_ALBUM_GAIN = "replaygain_album_gain"
|
||||||
* @param value The tag's adjustment, in dB.
|
const val TAG_R128_TRACK_GAIN = "r128_track_gain"
|
||||||
*/
|
const val TAG_R128_ALBUM_GAIN = "r128_album_gain"
|
||||||
private data class GainTag(val key: String, val value: Float)
|
|
||||||
// TODO: Try to phase this out
|
|
||||||
|
|
||||||
companion object {
|
/**
|
||||||
private const val TAG_RG_TRACK = "replaygain_track_gain"
|
* Matches non-float information from ReplayGain adjustments. Derived from vanilla music:
|
||||||
private const val TAG_RG_ALBUM = "replaygain_album_gain"
|
* https://github.com/vanilla-music/vanilla
|
||||||
private const val R128_TRACK = "r128_track_gain"
|
*/
|
||||||
private const val R128_ALBUM = "r128_album_gain"
|
val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX = Regex("[^\\d.-]")
|
||||||
|
|
||||||
private val REPLAY_GAIN_TAGS = arrayOf(TAG_RG_TRACK, TAG_RG_ALBUM, R128_ALBUM, R128_TRACK)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,6 +85,9 @@ interface InternalPlayer {
|
||||||
data class Open(val uri: Uri) : Action()
|
data class Open(val uri: Uri) : Action()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A representation of the current state of audio playback. Use [from] to create an instance.
|
||||||
|
*/
|
||||||
class State
|
class State
|
||||||
private constructor(
|
private constructor(
|
||||||
/** Whether the player is actively playing audio or set to play audio in the future. */
|
/** 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 isAdvancing Whether the player is actively playing audio in this moment.
|
||||||
* @param positionMs The current position of the player.
|
* @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(
|
State(
|
||||||
isPlaying,
|
isPlaying,
|
||||||
// Minor sanity check: Make sure that advancing can't occur if already paused.
|
// 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.MusicParent
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
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.settings.Settings
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logE
|
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
|
* - If you want to use the playback state with the ExoPlayer instance or system-side things, use
|
||||||
* [org.oxycblt.auxio.playback.system.PlaybackService].
|
* [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].
|
* itself should instead use [InternalPlayer].
|
||||||
*
|
*
|
||||||
* All access should be done with [PlaybackStateManager.getInstance].
|
* All access should be done with [PlaybackStateManager.getInstance].
|
||||||
|
@ -54,35 +54,40 @@ import org.oxycblt.auxio.util.logW
|
||||||
*/
|
*/
|
||||||
class PlaybackStateManager private constructor() {
|
class PlaybackStateManager private constructor() {
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private val callbacks = mutableListOf<Callback>()
|
private val listeners = mutableListOf<Listener>()
|
||||||
private var internalPlayer: InternalPlayer? = null
|
@Volatile private var internalPlayer: InternalPlayer? = null
|
||||||
private var pendingAction: InternalPlayer.Action? = null
|
@Volatile private var pendingAction: InternalPlayer.Action? = null
|
||||||
private var isInitialized = false
|
@Volatile private var isInitialized = false
|
||||||
|
|
||||||
/** The currently playing [Song]. Null if nothing is playing. */
|
/** The currently playing [Song]. Null if nothing is playing. */
|
||||||
val song
|
val song
|
||||||
get() = queue.getOrNull(index)
|
get() = queue.getOrNull(index)
|
||||||
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
|
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
|
||||||
|
@Volatile
|
||||||
var parent: MusicParent? = null
|
var parent: MusicParent? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private var _queue = mutableListOf<Song>()
|
@Volatile private var _queue = mutableListOf<Song>()
|
||||||
/** The current queue. */
|
/** The current queue. */
|
||||||
val queue
|
val queue
|
||||||
get() = _queue
|
get() = _queue
|
||||||
/** The position of the currently playing item in the queue. */
|
/** The position of the currently playing item in the queue. */
|
||||||
|
@Volatile
|
||||||
var index = -1
|
var index = -1
|
||||||
private set
|
private set
|
||||||
/** The current [InternalPlayer] state. */
|
/** 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
|
private set
|
||||||
/** The current [RepeatMode] */
|
/** The current [RepeatMode] */
|
||||||
|
@Volatile
|
||||||
var repeatMode = RepeatMode.NONE
|
var repeatMode = RepeatMode.NONE
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
notifyRepeatModeChanged()
|
notifyRepeatModeChanged()
|
||||||
}
|
}
|
||||||
/** Whether the queue is shuffled. */
|
/** Whether the queue is shuffled. */
|
||||||
|
@Volatile
|
||||||
var isShuffled = false
|
var isShuffled = false
|
||||||
private set
|
private set
|
||||||
/**
|
/**
|
||||||
|
@ -93,32 +98,32 @@ class PlaybackStateManager private constructor() {
|
||||||
get() = internalPlayer?.audioSessionId
|
get() = internalPlayer?.audioSessionId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a [Callback] to this instance. This can be used to receive changes in the playback state.
|
* Add a [Listener] 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.
|
* Will immediately invoke [Listener] methods to initialize the instance with the current state.
|
||||||
* @param callback The [Callback] to add.
|
* @param listener The [Listener] to add.
|
||||||
* @see Callback
|
* @see Listener
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addCallback(callback: Callback) {
|
fun addListener(listener: Listener) {
|
||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
callback.onNewPlayback(index, queue, parent)
|
listener.onNewPlayback(index, queue, parent)
|
||||||
callback.onRepeatChanged(repeatMode)
|
listener.onRepeatChanged(repeatMode)
|
||||||
callback.onShuffledChanged(isShuffled)
|
listener.onShuffledChanged(isShuffled)
|
||||||
callback.onStateChanged(playerState)
|
listener.onStateChanged(playerState)
|
||||||
}
|
}
|
||||||
|
|
||||||
callbacks.add(callback)
|
listeners.add(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a [Callback] from this instance, preventing it from recieving any further updates.
|
* Remove a [Listener] 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
|
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
|
||||||
* the first place.
|
* the first place.
|
||||||
* @see Callback
|
* @see Listener
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun removeCallback(callback: Callback) {
|
fun removeListener(listener: Listener) {
|
||||||
callbacks.remove(callback)
|
listeners.remove(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -521,10 +526,9 @@ class PlaybackStateManager private constructor() {
|
||||||
* @param database The [PlaybackStateDatabase] to clear te state from
|
* @param database The [PlaybackStateDatabase] to clear te state from
|
||||||
* @return If the state was cleared, false otherwise.
|
* @return If the state was cleared, false otherwise.
|
||||||
*/
|
*/
|
||||||
suspend fun wipeState(database: PlaybackStateDatabase): Boolean {
|
suspend fun wipeState(database: PlaybackStateDatabase) =
|
||||||
logD("Wiping state")
|
try {
|
||||||
|
logD("Wiping state")
|
||||||
return try {
|
|
||||||
withContext(Dispatchers.IO) { database.write(null) }
|
withContext(Dispatchers.IO) { database.write(null) }
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -532,7 +536,6 @@ class PlaybackStateManager private constructor() {
|
||||||
logE(e.stackTraceToString())
|
logE(e.stackTraceToString())
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the playback state to align with a new [MusicStore.Library].
|
* Update the playback state to align with a new [MusicStore.Library].
|
||||||
|
@ -586,52 +589,52 @@ class PlaybackStateManager private constructor() {
|
||||||
// --- CALLBACKS ---
|
// --- CALLBACKS ---
|
||||||
|
|
||||||
private fun notifyIndexMoved() {
|
private fun notifyIndexMoved() {
|
||||||
for (callback in callbacks) {
|
for (callback in listeners) {
|
||||||
callback.onIndexMoved(index)
|
callback.onIndexMoved(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyQueueChanged() {
|
private fun notifyQueueChanged() {
|
||||||
for (callback in callbacks) {
|
for (callback in listeners) {
|
||||||
callback.onQueueChanged(queue)
|
callback.onQueueChanged(queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyQueueReworked() {
|
private fun notifyQueueReworked() {
|
||||||
for (callback in callbacks) {
|
for (callback in listeners) {
|
||||||
callback.onQueueReworked(index, queue)
|
callback.onQueueReworked(index, queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyNewPlayback() {
|
private fun notifyNewPlayback() {
|
||||||
for (callback in callbacks) {
|
for (callback in listeners) {
|
||||||
callback.onNewPlayback(index, queue, parent)
|
callback.onNewPlayback(index, queue, parent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyStateChanged() {
|
private fun notifyStateChanged() {
|
||||||
for (callback in callbacks) {
|
for (callback in listeners) {
|
||||||
callback.onStateChanged(playerState)
|
callback.onStateChanged(playerState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyRepeatModeChanged() {
|
private fun notifyRepeatModeChanged() {
|
||||||
for (callback in callbacks) {
|
for (callback in listeners) {
|
||||||
callback.onRepeatChanged(repeatMode)
|
callback.onRepeatChanged(repeatMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyShuffledChanged() {
|
private fun notifyShuffledChanged() {
|
||||||
for (callback in callbacks) {
|
for (callback in listeners) {
|
||||||
callback.onShuffledChanged(isShuffled)
|
callback.onShuffledChanged(isShuffled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to
|
* 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
|
* Called when the position of the currently playing item has changed, changing the current
|
||||||
* [Song], but no other queue attribute has changed.
|
* [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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
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
|
* A component that mirrors the current playback state into the [MediaSessionCompat] and
|
||||||
* [NotificationComponent].
|
* [NotificationComponent].
|
||||||
* @param context [Context] required to initialize components.
|
* @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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class MediaSessionComponent(private val context: Context, private val callback: Callback) :
|
class MediaSessionComponent(private val context: Context, private val listener: Listener) :
|
||||||
MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback {
|
MediaSessionCompat.Callback(),
|
||||||
|
PlaybackStateManager.Listener,
|
||||||
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
private val mediaSession =
|
private val mediaSession =
|
||||||
MediaSessionCompat(context, context.packageName).apply {
|
MediaSessionCompat(context, context.packageName).apply {
|
||||||
isActive = true
|
isActive = true
|
||||||
|
@ -55,13 +58,13 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
||||||
}
|
}
|
||||||
|
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
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 notification = NotificationComponent(context, mediaSession.sessionToken)
|
||||||
private val provider = BitmapProvider(context)
|
private val provider = BitmapProvider(context)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
playbackManager.addCallback(this)
|
playbackManager.addListener(this)
|
||||||
mediaSession.setCallback(this)
|
mediaSession.setCallback(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,15 +82,15 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
||||||
*/
|
*/
|
||||||
fun release() {
|
fun release() {
|
||||||
provider.release()
|
provider.release()
|
||||||
settings.release()
|
settings.removeListener(this)
|
||||||
playbackManager.removeCallback(this)
|
playbackManager.removeListener(this)
|
||||||
mediaSession.apply {
|
mediaSession.apply {
|
||||||
isActive = false
|
isActive = false
|
||||||
release()
|
release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PLAYBACKSTATEMANAGER CALLBACKS ---
|
// --- PLAYBACKSTATEMANAGER OVERRIDES ---
|
||||||
|
|
||||||
override fun onIndexMoved(index: Int) {
|
override fun onIndexMoved(index: Int) {
|
||||||
updateMediaMetadata(playbackManager.song, playbackManager.parent)
|
updateMediaMetadata(playbackManager.song, playbackManager.parent)
|
||||||
|
@ -113,7 +116,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
||||||
invalidateSessionState()
|
invalidateSessionState()
|
||||||
notification.updatePlaying(playbackManager.playerState.isPlaying)
|
notification.updatePlaying(playbackManager.playerState.isPlaying)
|
||||||
if (!provider.isBusy) {
|
if (!provider.isBusy) {
|
||||||
callback.onPostNotification(notification)
|
listener.onPostNotification(notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,9 +142,9 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
||||||
invalidateSecondaryAction()
|
invalidateSecondaryAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SETTINGSMANAGER CALLBACKS ---
|
// --- SETTINGS OVERRIDES ---
|
||||||
|
|
||||||
override fun onSettingChanged(key: String) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||||
when (key) {
|
when (key) {
|
||||||
context.getString(R.string.set_key_cover_mode) ->
|
context.getString(R.string.set_key_cover_mode) ->
|
||||||
updateMediaMetadata(playbackManager.song, playbackManager.parent)
|
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?) {
|
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||||
super.onPlayFromMediaId(mediaId, extras)
|
super.onPlayFromMediaId(mediaId, extras)
|
||||||
|
@ -306,7 +309,7 @@ class MediaSessionComponent(private val context: Context, private val callback:
|
||||||
val metadata = builder.build()
|
val metadata = builder.build()
|
||||||
mediaSession.setMetadata(metadata)
|
mediaSession.setMetadata(metadata)
|
||||||
notification.updateMetadata(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) {
|
if (!provider.isBusy) {
|
||||||
callback.onPostNotification(notification)
|
listener.onPostNotification(notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An interface for handling changes in the notification configuration. */
|
/** An interface for handling changes in the notification configuration. */
|
||||||
interface Callback {
|
interface Listener {
|
||||||
/**
|
/**
|
||||||
* Called when the [NotificationComponent] changes, requiring it to be re-posed.
|
* Called when the [NotificationComponent] changes, requiring it to be re-posed.
|
||||||
* @param notification The new [NotificationComponent].
|
* @param notification The new [NotificationComponent].
|
||||||
|
|
|
@ -148,9 +148,9 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
||||||
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
|
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
/** Notification channel used by solely the playback notification. */
|
/** Notification channel used by solely the playback notification. */
|
||||||
private val CHANNEL_INFO =
|
val CHANNEL_INFO =
|
||||||
ChannelInfo(
|
ChannelInfo(
|
||||||
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
|
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
|
||||||
nameRes = R.string.lbl_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.PlaybackException
|
||||||
import com.google.android.exoplayer2.Player
|
import com.google.android.exoplayer2.Player
|
||||||
import com.google.android.exoplayer2.RenderersFactory
|
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.AudioAttributes
|
||||||
import com.google.android.exoplayer2.audio.AudioCapabilities
|
import com.google.android.exoplayer2.audio.AudioCapabilities
|
||||||
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
|
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
|
||||||
|
@ -44,7 +43,6 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||||
|
@ -79,9 +77,8 @@ class PlaybackService :
|
||||||
Service(),
|
Service(),
|
||||||
Player.Listener,
|
Player.Listener,
|
||||||
InternalPlayer,
|
InternalPlayer,
|
||||||
MediaSessionComponent.Callback,
|
MediaSessionComponent.Listener,
|
||||||
Settings.Callback,
|
MusicStore.Listener {
|
||||||
MusicStore.Callback {
|
|
||||||
// Player components
|
// Player components
|
||||||
private lateinit var player: ExoPlayer
|
private lateinit var player: ExoPlayer
|
||||||
private lateinit var replayGainProcessor: ReplayGainAudioProcessor
|
private lateinit var replayGainProcessor: ReplayGainAudioProcessor
|
||||||
|
@ -143,13 +140,14 @@ class PlaybackService :
|
||||||
true)
|
true)
|
||||||
.build()
|
.build()
|
||||||
.also { it.addListener(this) }
|
.also { it.addListener(this) }
|
||||||
|
replayGainProcessor.addToListeners(player)
|
||||||
// Initialize the core service components
|
// Initialize the core service components
|
||||||
settings = Settings(this, this)
|
settings = Settings(this)
|
||||||
foregroundManager = ForegroundManager(this)
|
foregroundManager = ForegroundManager(this)
|
||||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
// 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.
|
// condition to cause us to load music before we were fully initialize.
|
||||||
playbackManager.registerInternalPlayer(this)
|
playbackManager.registerInternalPlayer(this)
|
||||||
musicStore.addCallback(this)
|
musicStore.addListener(this)
|
||||||
widgetComponent = WidgetComponent(this)
|
widgetComponent = WidgetComponent(this)
|
||||||
mediaSessionComponent = MediaSessionComponent(this, this)
|
mediaSessionComponent = MediaSessionComponent(this, this)
|
||||||
registerReceiver(
|
registerReceiver(
|
||||||
|
@ -185,12 +183,11 @@ class PlaybackService :
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
||||||
foregroundManager.release()
|
foregroundManager.release()
|
||||||
settings.release()
|
|
||||||
|
|
||||||
// Pause just in case this destruction was unexpected.
|
// Pause just in case this destruction was unexpected.
|
||||||
playbackManager.setPlaying(false)
|
playbackManager.setPlaying(false)
|
||||||
playbackManager.unregisterInternalPlayer(this)
|
playbackManager.unregisterInternalPlayer(this)
|
||||||
musicStore.removeCallback(this)
|
musicStore.removeListener(this)
|
||||||
|
|
||||||
unregisterReceiver(systemReceiver)
|
unregisterReceiver(systemReceiver)
|
||||||
serviceJob.cancel()
|
serviceJob.cancel()
|
||||||
|
@ -198,6 +195,7 @@ class PlaybackService :
|
||||||
widgetComponent.release()
|
widgetComponent.release()
|
||||||
mediaSessionComponent.release()
|
mediaSessionComponent.release()
|
||||||
|
|
||||||
|
replayGainProcessor.releaseFromListeners(player)
|
||||||
player.release()
|
player.release()
|
||||||
if (openAudioEffectSession) {
|
if (openAudioEffectSession) {
|
||||||
// Make sure to close the audio session when we release the player.
|
// 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
|
get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
|
||||||
|
|
||||||
override fun getState(durationMs: Long) =
|
override fun getState(durationMs: Long) =
|
||||||
InternalPlayer.State.new(
|
InternalPlayer.State.from(
|
||||||
player.playWhenReady,
|
player.playWhenReady,
|
||||||
player.isPlaying,
|
player.isPlaying,
|
||||||
// The position value can be below zero or past the expected duration, make
|
// The position value can be below zero or past the expected duration, make
|
||||||
|
@ -302,24 +300,6 @@ class PlaybackService :
|
||||||
playbackManager.next()
|
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 ---
|
// --- MUSICSTORE OVERRIDES ---
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
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 ---
|
// --- OTHER FUNCTIONS ---
|
||||||
|
|
||||||
private fun broadcastAudioEffectAction(event: String) {
|
private fun broadcastAudioEffectAction(event: String) {
|
||||||
|
|
|
@ -51,11 +51,11 @@ class SearchAdapter(private val listener: SelectableListListener) :
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
when (viewType) {
|
when (viewType) {
|
||||||
SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent)
|
SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
|
||||||
AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.new(parent)
|
AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.from(parent)
|
||||||
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.new(parent)
|
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
|
||||||
GenreViewHolder.VIEW_TYPE -> GenreViewHolder.new(parent)
|
GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent)
|
||||||
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.new(parent)
|
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
|
||||||
else -> error("Invalid item type $viewType")
|
else -> error("Invalid item type $viewType")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,9 +81,9 @@ class SearchAdapter(private val listener: SelectableListListener) :
|
||||||
differ.submitList(newList, callback)
|
differ.submitList(newList, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
private val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleItemCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
|
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
|
||||||
when {
|
when {
|
||||||
|
|
|
@ -53,10 +53,8 @@ import org.oxycblt.auxio.util.*
|
||||||
class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
private val searchModel: SearchViewModel by androidViewModels()
|
private val searchModel: SearchViewModel by androidViewModels()
|
||||||
private val searchAdapter = SearchAdapter(this)
|
private val searchAdapter = SearchAdapter(this)
|
||||||
|
private var imm: InputMethodManager? = null
|
||||||
private var launchedKeyboard = false
|
private var launchedKeyboard = false
|
||||||
private val imm: InputMethodManager by lifecycleObject { binding ->
|
|
||||||
binding.context.getSystemServiceCompat(InputMethodManager::class)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -74,13 +72,15 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
|
imm = binding.context.getSystemServiceCompat(InputMethodManager::class)
|
||||||
|
|
||||||
binding.searchToolbar.apply {
|
binding.searchToolbar.apply {
|
||||||
// Initialize the current filtering mode.
|
// Initialize the current filtering mode.
|
||||||
menu.findItem(searchModel.getFilterOptionId()).isChecked = true
|
menu.findItem(searchModel.getFilterOptionId()).isChecked = true
|
||||||
|
|
||||||
setNavigationOnClickListener {
|
setNavigationOnClickListener {
|
||||||
// Keyboard is no longer needed.
|
// Keyboard is no longer needed.
|
||||||
imm.hide()
|
hideKeyboard()
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,7 +95,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
|
|
||||||
if (!launchedKeyboard) {
|
if (!launchedKeyboard) {
|
||||||
// Auto-open the keyboard when this view is shown
|
// Auto-open the keyboard when this view is shown
|
||||||
imm.show(this)
|
showKeyboard(this)
|
||||||
launchedKeyboard = true
|
launchedKeyboard = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -184,7 +184,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
else -> return
|
else -> return
|
||||||
}
|
}
|
||||||
// Keyboard is no longer needed.
|
// Keyboard is no longer needed.
|
||||||
imm.hide()
|
hideKeyboard()
|
||||||
findNavController().navigate(action)
|
findNavController().navigate(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -193,7 +193,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
|
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
|
||||||
selected.isNotEmpty()) {
|
selected.isNotEmpty()) {
|
||||||
// Make selection of obscured items easier by hiding the keyboard.
|
// 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].
|
* Safely focus the keyboard on a particular [View].
|
||||||
* @param view The [View] to focus the keyboard on.
|
* @param view The [View] to focus the keyboard on.
|
||||||
*/
|
*/
|
||||||
private fun InputMethodManager.show(view: View) {
|
private fun showKeyboard(view: View) {
|
||||||
view.apply {
|
view.apply {
|
||||||
requestFocus()
|
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. */
|
/** Safely hide the keyboard from this view. */
|
||||||
private fun InputMethodManager.hide() {
|
private fun hideKeyboard() {
|
||||||
hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SearchViewModel(application: Application) :
|
class SearchViewModel(application: Application) :
|
||||||
AndroidViewModel(application), MusicStore.Callback {
|
AndroidViewModel(application), MusicStore.Listener {
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private val settings = Settings(context)
|
private val settings = Settings(context)
|
||||||
private var lastQuery: String? = null
|
private var lastQuery: String? = null
|
||||||
|
@ -55,12 +55,12 @@ class SearchViewModel(application: Application) :
|
||||||
get() = _searchResults
|
get() = _searchResults
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicStore.addCallback(this)
|
musicStore.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
musicStore.removeCallback(this)
|
musicStore.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||||
|
@ -212,11 +212,11 @@ class SearchViewModel(application: Application) :
|
||||||
search(lastQuery)
|
search(lastQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
/**
|
/**
|
||||||
* Converts the output of [Normalizer] to remove any junk characters added by it's
|
* Converts the output of [Normalizer] to remove any junk characters added by it's
|
||||||
* replacements.
|
* 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)
|
startActivity(chooserIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
/** The URL to the source code. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** 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.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
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.image.CoverMode
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.Sort
|
import org.oxycblt.auxio.music.Sort
|
||||||
import org.oxycblt.auxio.music.storage.Directory
|
import org.oxycblt.auxio.music.filesystem.Directory
|
||||||
import org.oxycblt.auxio.music.storage.MusicDirectories
|
import org.oxycblt.auxio.music.filesystem.MusicDirectories
|
||||||
import org.oxycblt.auxio.playback.ActionMode
|
import org.oxycblt.auxio.playback.ActionMode
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
|
||||||
|
@ -40,20 +41,14 @@ import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Object
|
* A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Member
|
||||||
* mutability
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class Settings(private val context: Context, private val callback: Callback? = null) :
|
class Settings(private val context: Context) {
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
|
||||||
private val inner = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
|
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
|
* Migrate any settings from an old version into their modern counterparts. This can cause data
|
||||||
* loss depending on the feasibility of a migration.
|
* 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
|
* Add a [SharedPreferences.OnSharedPreferenceChangeListener] to monitor for settings updates.
|
||||||
* originally attached.
|
* @param listener The [SharedPreferences.OnSharedPreferenceChangeListener] to add.
|
||||||
*/
|
*/
|
||||||
fun release() {
|
fun addListener(listener: OnSharedPreferenceChangeListener) {
|
||||||
inner.unregisterOnSharedPreferenceChangeListener(this)
|
inner.registerOnSharedPreferenceChangeListener(listener)
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
|
||||||
unlikelyToBeNull(callback).onSettingChanged(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified callback for settings changes.
|
* Unregister a [SharedPreferences.OnSharedPreferenceChangeListener], preventing any further
|
||||||
|
* settings updates from being sent to ti.t
|
||||||
*/
|
*/
|
||||||
interface Callback {
|
fun removeListener(listener: OnSharedPreferenceChangeListener) {
|
||||||
// TODO: Refactor this lifecycle
|
inner.unregisterOnSharedPreferenceChangeListener(listener)
|
||||||
/**
|
|
||||||
* Called when a setting has changed.
|
|
||||||
* @param key The key of the setting that changed.
|
|
||||||
*/
|
|
||||||
fun onSettingChanged(key: String)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- VALUES ---
|
// --- VALUES ---
|
||||||
|
|
|
@ -162,8 +162,8 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private val PREFERENCE_DEFAULT_VALUE_FIELD: Field by
|
val PREFERENCE_DEFAULT_VALUE_FIELD: Field by
|
||||||
lazyReflectedField(Preference::class, "mDefaultValue")
|
lazyReflectedField(Preference::class, "mDefaultValue")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
|
class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
|
||||||
|
@ -62,11 +62,10 @@ class IntListPreferenceDialog : PreferenceDialogFragmentCompat() {
|
||||||
* @param preference The [IntListPreference] to display.
|
* @param preference The [IntListPreference] to display.
|
||||||
* @return A new instance.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun new(preference: IntListPreference): IntListPreferenceDialog {
|
fun from(preference: IntListPreference) =
|
||||||
return IntListPreferenceDialog().apply {
|
IntListPreferenceDialog().apply {
|
||||||
// Populate the key field required by PreferenceDialogFragmentCompat.
|
// Populate the key field required by PreferenceDialogFragmentCompat.
|
||||||
arguments = Bundle().apply { putString(ARG_KEY, preference.key) }
|
arguments = Bundle().apply { putString(ARG_KEY, preference.key) }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,7 +76,7 @@ class PreferenceFragment : PreferenceFragmentCompat() {
|
||||||
is IntListPreference -> {
|
is IntListPreference -> {
|
||||||
// Copy the built-in preference dialog launching code into our project so
|
// Copy the built-in preference dialog launching code into our project so
|
||||||
// we can automatically use the provided preference class.
|
// we can automatically use the provided preference class.
|
||||||
val dialog = IntListPreferenceDialog.new(preference)
|
val dialog = IntListPreferenceDialog.from(preference)
|
||||||
dialog.setTargetFragment(this, 0)
|
dialog.setTargetFragment(this, 0)
|
||||||
dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG)
|
dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG)
|
||||||
}
|
}
|
||||||
|
@ -104,46 +104,44 @@ class PreferenceFragment : PreferenceFragmentCompat() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
val context = requireContext()
|
|
||||||
|
|
||||||
// Hook generic preferences to their specified preferences
|
// Hook generic preferences to their specified preferences
|
||||||
// TODO: These seem like good things to put into a side navigation view, if I choose to
|
// TODO: These seem like good things to put into a side navigation view, if I choose to
|
||||||
// do one.
|
// do one.
|
||||||
when (preference.key) {
|
when (preference.key) {
|
||||||
context.getString(R.string.set_key_save_state) -> {
|
getString(R.string.set_key_save_state) -> {
|
||||||
playbackModel.savePlaybackState { saved ->
|
playbackModel.savePlaybackState { saved ->
|
||||||
// Use the nullable context, as we could try to show a toast when this
|
// Use the nullable context, as we could try to show a toast when this
|
||||||
// fragment is no longer attached.
|
// fragment is no longer attached.
|
||||||
if (saved) {
|
if (saved) {
|
||||||
this.context?.showToast(R.string.lbl_state_saved)
|
context?.showToast(R.string.lbl_state_saved)
|
||||||
} else {
|
} 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 ->
|
playbackModel.wipePlaybackState { wiped ->
|
||||||
if (wiped) {
|
if (wiped) {
|
||||||
// Use the nullable context, as we could try to show a toast when this
|
// Use the nullable context, as we could try to show a toast when this
|
||||||
// fragment is no longer attached.
|
// fragment is no longer attached.
|
||||||
this.context?.showToast(R.string.lbl_state_wiped)
|
context?.showToast(R.string.lbl_state_wiped)
|
||||||
} else {
|
} 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 ->
|
playbackModel.tryRestorePlaybackState { restored ->
|
||||||
if (restored) {
|
if (restored) {
|
||||||
// Use the nullable context, as we could try to show a toast when this
|
// Use the nullable context, as we could try to show a toast when this
|
||||||
// fragment is no longer attached.
|
// fragment is no longer attached.
|
||||||
this.context?.showToast(R.string.lbl_state_restored)
|
context?.showToast(R.string.lbl_state_restored)
|
||||||
} else {
|
} 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()
|
getString(R.string.set_key_reindex) -> musicModel.refresh()
|
||||||
context.getString(R.string.set_key_rescan) -> musicModel.rescan()
|
getString(R.string.set_key_rescan) -> musicModel.rescan()
|
||||||
else -> return super.onPreferenceTreeClick(preference)
|
else -> return super.onPreferenceTreeClick(preference)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,8 +149,7 @@ class PreferenceFragment : PreferenceFragmentCompat() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupPreference(preference: Preference) {
|
private fun setupPreference(preference: Preference) {
|
||||||
val context = requireActivity()
|
val settings = Settings(requireContext())
|
||||||
val settings = Settings(context)
|
|
||||||
|
|
||||||
if (!preference.isVisible) {
|
if (!preference.isVisible) {
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
|
@ -165,30 +162,31 @@ class PreferenceFragment : PreferenceFragmentCompat() {
|
||||||
}
|
}
|
||||||
|
|
||||||
when (preference.key) {
|
when (preference.key) {
|
||||||
context.getString(R.string.set_key_theme) -> {
|
getString(R.string.set_key_theme) -> {
|
||||||
preference.onPreferenceChangeListener =
|
preference.onPreferenceChangeListener =
|
||||||
Preference.OnPreferenceChangeListener { _, value ->
|
Preference.OnPreferenceChangeListener { _, value ->
|
||||||
AppCompatDelegate.setDefaultNightMode(value as Int)
|
AppCompatDelegate.setDefaultNightMode(value as Int)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
context.getString(R.string.set_key_accent) -> {
|
getString(R.string.set_key_accent) -> {
|
||||||
preference.summary = context.getString(settings.accent.name)
|
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 =
|
||||||
Preference.OnPreferenceChangeListener { _, _ ->
|
Preference.OnPreferenceChangeListener { _, _ ->
|
||||||
if (context.isNight) {
|
val activity = requireActivity()
|
||||||
context.recreate()
|
if (activity.isNight) {
|
||||||
|
activity.recreate()
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
context.getString(R.string.set_key_cover_mode) -> {
|
getString(R.string.set_key_cover_mode) -> {
|
||||||
preference.onPreferenceChangeListener =
|
preference.onPreferenceChangeListener =
|
||||||
Preference.OnPreferenceChangeListener { _, _ ->
|
Preference.OnPreferenceChangeListener { _, _ ->
|
||||||
Coil.imageLoader(context).memoryCache?.clear()
|
Coil.imageLoader(requireContext()).memoryCache?.clear()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
||||||
*
|
*
|
||||||
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
open class AuxioAppBarLayout
|
open class CoordinatorAppBarLayout
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||||
AppBarLayout(context, attrs, defStyleAttr) {
|
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
|
* Expand this [AppBarLayout] with respect to the current [RecyclerView] at
|
||||||
* jumping around.
|
* [liftOnScrollTargetViewId], preventing it from jumping around.
|
||||||
* @param recycler [RecyclerView] to expand with, or null if one is currently unavailable.
|
|
||||||
*/
|
*/
|
||||||
fun expandWithRecycler(recycler: RecyclerView?) {
|
fun expandWithScrollingRecycler() {
|
||||||
// TODO: Is it possible to use liftOnScrollTargetViewId to avoid the RecyclerView arg?
|
|
||||||
setExpanded(true)
|
setExpanded(true)
|
||||||
recycler?.let { addOnOffsetChangedListener(ExpansionHackListener(it)) }
|
(findScrollingChild() as? RecyclerView)?.let {
|
||||||
|
addOnOffsetChangedListener(ExpansionHackListener(it))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
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 */
|
/** @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].
|
* 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,
|
* @param song The [Song] to navigate with. If there are multiple parent [Artist]s, a picker
|
||||||
* a picker dialog will be shown.
|
* dialog will be shown.
|
||||||
*/
|
*/
|
||||||
fun exploreNavigateToParentArtist(song: Song) {
|
fun exploreNavigateToParentArtist(song: Song) {
|
||||||
exploreNavigateToParentArtistImpl(song, song.artists)
|
exploreNavigateToParentArtistImpl(song, song.artists)
|
||||||
|
@ -101,8 +101,8 @@ class NavigationViewModel : ViewModel() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to one of the parent [Artist]'s of the given [Album].
|
* 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,
|
* @param album The [Album] to navigate with. If there are multiple parent [Artist]s, a picker
|
||||||
* a picker dialog will be shown.
|
* dialog will be shown.
|
||||||
*/
|
*/
|
||||||
fun exploreNavigateToParentArtist(album: Album) {
|
fun exploreNavigateToParentArtist(album: Album) {
|
||||||
exploreNavigateToParentArtistImpl(album, album.artists)
|
exploreNavigateToParentArtistImpl(album, album.artists)
|
||||||
|
|
|
@ -23,11 +23,8 @@ import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
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.logD
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
@ -37,7 +34,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
*/
|
*/
|
||||||
abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
|
abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
|
||||||
private var _binding: VB? = null
|
private var _binding: VB? = null
|
||||||
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure the [AlertDialog.Builder] during [onCreateDialog].
|
* 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(
|
final override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
@ -119,9 +96,6 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
|
||||||
|
|
||||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
val binding = unlikelyToBeNull(_binding)
|
|
||||||
// Populate lifecycle-dependent objects
|
|
||||||
lifecycleObjects.forEach { it.populate(binding) }
|
|
||||||
// Configure binding
|
// Configure binding
|
||||||
onBindingCreated(requireBinding(), savedInstanceState)
|
onBindingCreated(requireBinding(), savedInstanceState)
|
||||||
// Apply the newly-configured view to the dialog.
|
// Apply the newly-configured view to the dialog.
|
||||||
|
@ -132,21 +106,8 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
|
||||||
final override fun onDestroyView() {
|
final override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
onDestroyBinding(unlikelyToBeNull(_binding))
|
onDestroyBinding(unlikelyToBeNull(_binding))
|
||||||
// Clear the lifecycle-dependent objects
|
|
||||||
lifecycleObjects.forEach { it.clear() }
|
|
||||||
// Clear binding
|
// Clear binding
|
||||||
_binding = null
|
_binding = null
|
||||||
logD("Fragment destroyed")
|
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 android.view.ViewGroup
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import kotlin.properties.ReadOnlyProperty
|
|
||||||
import kotlin.reflect.KProperty
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
@ -34,7 +32,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
*/
|
*/
|
||||||
abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
|
abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
|
||||||
private var _binding: VB? = null
|
private var _binding: VB? = null
|
||||||
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inflate the [ViewBinding] during [onCreateView].
|
* 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(
|
final override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
@ -103,9 +80,6 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
|
||||||
|
|
||||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
val binding = unlikelyToBeNull(_binding)
|
|
||||||
// Populate lifecycle-dependent objects
|
|
||||||
lifecycleObjects.forEach { it.populate(binding) }
|
|
||||||
// Configure binding
|
// Configure binding
|
||||||
onBindingCreated(requireBinding(), savedInstanceState)
|
onBindingCreated(requireBinding(), savedInstanceState)
|
||||||
logD("Fragment created")
|
logD("Fragment created")
|
||||||
|
@ -114,21 +88,8 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
|
||||||
final override fun onDestroyView() {
|
final override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
onDestroyBinding(unlikelyToBeNull(_binding))
|
onDestroyBinding(unlikelyToBeNull(_binding))
|
||||||
// Clear the lifecycle-dependent objects
|
|
||||||
lifecycleObjects.forEach { it.clear() }
|
|
||||||
// Clear binding
|
// Clear binding
|
||||||
_binding = null
|
_binding = null
|
||||||
logD("Fragment destroyed")
|
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 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) =
|
override fun onBindViewHolder(holder: AccentViewHolder, position: Int) =
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
|
@ -75,13 +76,13 @@ class AccentAdapter(private val listener: ClickableListListener) :
|
||||||
notifyItemChanged(accent.index, PAYLOAD_SELECTION_CHANGED)
|
notifyItemChanged(accent.index, PAYLOAD_SELECTION_CHANGED)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private val PAYLOAD_SELECTION_CHANGED = Any()
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class AccentViewHolder private constructor(private val binding: ItemAccentBinding) :
|
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.
|
* @param listener A [ClickableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(accent: Accent, listener: ClickableListListener) {
|
fun bind(accent: Accent, listener: ClickableListListener) {
|
||||||
|
listener.bind(accent, this, binding.accent)
|
||||||
binding.accent.apply {
|
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
|
// Add a Tooltip based on the content description so that the purpose of this
|
||||||
// button can be clear.
|
// button can be clear.
|
||||||
contentDescription = context.getString(accent.name)
|
contentDescription = context.getString(accent.name)
|
||||||
TooltipCompat.setTooltipText(this, contentDescription)
|
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.
|
* @param parent The parent to inflate this instance from.
|
||||||
* @return A new instance.
|
* @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.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogAccentBinding
|
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.list.Item
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
@ -38,7 +38,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
class AccentCustomizeDialog :
|
class AccentCustomizeDialog :
|
||||||
ViewBindingDialogFragment<DialogAccentBinding>(), ClickableListListener {
|
ViewBindingDialogFragment<DialogAccentBinding>(), ClickableListListener {
|
||||||
private var accentAdapter = AccentAdapter(this)
|
private var accentAdapter = AccentAdapter(this)
|
||||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater)
|
override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater)
|
||||||
|
|
||||||
|
@ -46,6 +45,7 @@ class AccentCustomizeDialog :
|
||||||
builder
|
builder
|
||||||
.setTitle(R.string.set_accent)
|
.setTitle(R.string.set_accent)
|
||||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||||
|
val settings = Settings(requireContext())
|
||||||
if (accentAdapter.selectedAccent == settings.accent) {
|
if (accentAdapter.selectedAccent == settings.accent) {
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return@setPositiveButton
|
return@setPositiveButton
|
||||||
|
@ -66,7 +66,7 @@ class AccentCustomizeDialog :
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState != null) {
|
||||||
Accent.from(savedInstanceState.getInt(KEY_PENDING_ACCENT))
|
Accent.from(savedInstanceState.getInt(KEY_PENDING_ACCENT))
|
||||||
} else {
|
} else {
|
||||||
settings.accent
|
Settings(requireContext()).accent
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,12 +80,12 @@ class AccentCustomizeDialog :
|
||||||
binding.accentRecycler.adapter = null
|
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}" }
|
check(item is Accent) { "Unexpected datatype: ${item::class.java}" }
|
||||||
accentAdapter.setSelectedAccent(item)
|
accentAdapter.setSelectedAccent(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
private const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT"
|
const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
package org.oxycblt.auxio.widgets
|
package org.oxycblt.auxio.widgets
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
|
@ -41,14 +42,15 @@ import org.oxycblt.auxio.util.logD
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class WidgetComponent(private val context: Context) :
|
class WidgetComponent(private val context: Context) :
|
||||||
PlaybackStateManager.Callback, Settings.Callback {
|
PlaybackStateManager.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
private val playbackManager = PlaybackStateManager.getInstance()
|
||||||
private val settings = Settings(context, this)
|
private val settings = Settings(context)
|
||||||
private val widgetProvider = WidgetProvider()
|
private val widgetProvider = WidgetProvider()
|
||||||
private val provider = BitmapProvider(context)
|
private val provider = BitmapProvider(context)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
playbackManager.addCallback(this)
|
playbackManager.addListener(this)
|
||||||
|
settings.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update [WidgetProvider] with the current playback state. */
|
/** 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. */
|
/** Release this instance, preventing any further events from updating the widget instances. */
|
||||||
fun release() {
|
fun release() {
|
||||||
provider.release()
|
provider.release()
|
||||||
settings.release()
|
settings.removeListener(this)
|
||||||
widgetProvider.reset(context)
|
widgetProvider.reset(context)
|
||||||
playbackManager.removeCallback(this)
|
playbackManager.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CALLBACKS ---
|
// --- CALLBACKS ---
|
||||||
|
@ -118,7 +120,8 @@ class WidgetComponent(private val context: Context) :
|
||||||
override fun onStateChanged(state: InternalPlayer.State) = update()
|
override fun onStateChanged(state: InternalPlayer.State) = update()
|
||||||
override fun onShuffledChanged(isShuffled: Boolean) = update()
|
override fun onShuffledChanged(isShuffled: Boolean) = update()
|
||||||
override fun onRepeatChanged(repeatMode: RepeatMode) = 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) ||
|
if (key == context.getString(R.string.set_key_cover_mode) ||
|
||||||
key == context.getString(R.string.set_key_round_mode)) {
|
key == context.getString(R.string.set_key_round_mode)) {
|
||||||
update()
|
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
|
* Update the app widget layouts corresponding to the given [WidgetProvider] [ComponentName] with an
|
||||||
* an adaptive layout, in a version-compatible manner.
|
* adaptive layout, in a version-compatible manner.
|
||||||
* @param context [Context] required to backport adaptive layout behavior.
|
* @param context [Context] required to backport adaptive layout behavior.
|
||||||
* @param component [ComponentName] of the app widget layout to update.
|
* @param component [ComponentName] of the app widget layout to update.
|
||||||
* @param views Mapping between different size classes and [RemoteViews] instances.
|
* @param views Mapping between different size classes and [RemoteViews] instances.
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
android:transitionGroup="true"
|
android:transitionGroup="true"
|
||||||
tools:context=".settings.AboutFragment">
|
tools:context=".settings.AboutFragment">
|
||||||
|
|
||||||
<org.oxycblt.auxio.ui.AuxioAppBarLayout
|
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||||
android:id="@+id/about_appbar"
|
android:id="@+id/about_appbar"
|
||||||
style="@style/Widget.Auxio.AppBarLayout"
|
style="@style/Widget.Auxio.AppBarLayout"
|
||||||
app:liftOnScroll="true">
|
app:liftOnScroll="true">
|
||||||
|
@ -21,7 +21,7 @@
|
||||||
app:navigationIcon="@drawable/ic_back_24"
|
app:navigationIcon="@drawable/ic_back_24"
|
||||||
app:title="@string/lbl_about" />
|
app:title="@string/lbl_about" />
|
||||||
|
|
||||||
</org.oxycblt.auxio.ui.AuxioAppBarLayout>
|
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
|
||||||
|
|
||||||
<androidx.core.widget.NestedScrollView
|
<androidx.core.widget.NestedScrollView
|
||||||
android:id="@+id/about_contents"
|
android:id="@+id/about_contents"
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
android:background="?attr/colorSurface"
|
android:background="?attr/colorSurface"
|
||||||
android:transitionGroup="true">
|
android:transitionGroup="true">
|
||||||
|
|
||||||
<org.oxycblt.auxio.ui.AuxioAppBarLayout
|
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||||
android:id="@+id/home_appbar"
|
android:id="@+id/home_appbar"
|
||||||
style="@style/Widget.Auxio.AppBarLayout"
|
style="@style/Widget.Auxio.AppBarLayout"
|
||||||
android:fitsSystemWindows="true">
|
android:fitsSystemWindows="true">
|
||||||
|
@ -37,7 +37,7 @@
|
||||||
app:tabGravity="start"
|
app:tabGravity="start"
|
||||||
app:tabMode="scrollable" />
|
app:tabMode="scrollable" />
|
||||||
|
|
||||||
</org.oxycblt.auxio.ui.AuxioAppBarLayout>
|
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
|
||||||
|
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
android:background="?attr/colorSurface"
|
android:background="?attr/colorSurface"
|
||||||
android:transitionGroup="true">
|
android:transitionGroup="true">
|
||||||
|
|
||||||
<org.oxycblt.auxio.ui.AuxioAppBarLayout
|
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||||
style="@style/Widget.Auxio.AppBarLayout"
|
style="@style/Widget.Auxio.AppBarLayout"
|
||||||
app:liftOnScroll="true"
|
app:liftOnScroll="true"
|
||||||
app:liftOnScrollTargetViewId="@id/search_recycler">
|
app:liftOnScrollTargetViewId="@id/search_recycler">
|
||||||
|
@ -51,7 +51,7 @@
|
||||||
|
|
||||||
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
|
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
|
||||||
|
|
||||||
</org.oxycblt.auxio.ui.AuxioAppBarLayout>
|
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
|
||||||
|
|
||||||
<org.oxycblt.auxio.list.recycler.AuxioRecyclerView
|
<org.oxycblt.auxio.list.recycler.AuxioRecyclerView
|
||||||
android:id="@+id/search_recycler"
|
android:id="@+id/search_recycler"
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:transitionGroup="true">
|
android:transitionGroup="true">
|
||||||
|
|
||||||
<org.oxycblt.auxio.ui.AuxioAppBarLayout
|
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||||
android:id="@+id/settings_appbar"
|
android:id="@+id/settings_appbar"
|
||||||
style="@style/Widget.Auxio.AppBarLayout"
|
style="@style/Widget.Auxio.AppBarLayout"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
app:navigationIcon="@drawable/ic_back_24"
|
app:navigationIcon="@drawable/ic_back_24"
|
||||||
app:title="@string/set_title" />
|
app:title="@string/set_title" />
|
||||||
|
|
||||||
</org.oxycblt.auxio.ui.AuxioAppBarLayout>
|
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
|
||||||
|
|
||||||
<androidx.fragment.app.FragmentContainerView
|
<androidx.fragment.app.FragmentContainerView
|
||||||
android:id="@+id/settings_list_fragment"
|
android:id="@+id/settings_list_fragment"
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
style="@style/Widget.Auxio.Button.Icon.Small"
|
style="@style/Widget.Auxio.Button.Icon.Small"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
android:layout_marginEnd="@dimen/spacing_mid_large"
|
android:layout_marginEnd="@dimen/spacing_mid_large"
|
||||||
android:contentDescription="@string/desc_music_dir_delete"
|
android:contentDescription="@string/desc_music_dir_delete"
|
||||||
app:icon="@drawable/ic_delete_24"
|
app:icon="@drawable/ic_delete_24"
|
||||||
|
|
|
@ -106,12 +106,12 @@
|
||||||
tools:layout="@layout/dialog_pre_amp" />
|
tools:layout="@layout/dialog_pre_amp" />
|
||||||
<dialog
|
<dialog
|
||||||
android:id="@+id/music_dirs_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"
|
android:label="music_dirs_dialog"
|
||||||
tools:layout="@layout/dialog_music_dirs" />
|
tools:layout="@layout/dialog_music_dirs" />
|
||||||
<dialog
|
<dialog
|
||||||
android:id="@+id/separators_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"
|
android:label="music_dirs_dialog"
|
||||||
tools:layout="@layout/dialog_separators" />
|
tools:layout="@layout/dialog_separators" />
|
||||||
|
|
||||||
|
|
|
@ -268,4 +268,6 @@
|
||||||
<string name="lbl_shuffle_selected">Náhodně přehrát vybrané</string>
|
<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="set_playback_mode_genre">Přehrát z žánru</string>
|
||||||
<string name="lbl_wiki">Wiki</string>
|
<string name="lbl_wiki">Wiki</string>
|
||||||
|
<string name="fmt_list">%1$s, %2$s</string>
|
||||||
|
<string name="lbl_reset">Obnovit</string>
|
||||||
</resources>
|
</resources>
|
|
@ -259,4 +259,5 @@
|
||||||
<string name="fmt_selected">%d ausgewählt</string>
|
<string name="fmt_selected">%d ausgewählt</string>
|
||||||
<string name="set_playback_mode_genre">Vom Genre abspielen</string>
|
<string name="set_playback_mode_genre">Vom Genre abspielen</string>
|
||||||
<string name="lbl_wiki">Wiki</string>
|
<string name="lbl_wiki">Wiki</string>
|
||||||
|
<string name="fmt_list">%1$s, %2$s</string>
|
||||||
</resources>
|
</resources>
|
|
@ -128,12 +128,10 @@
|
||||||
<string name="fmt_lib_song_count">Canciones cargadas: %d</string>
|
<string name="fmt_lib_song_count">Canciones cargadas: %d</string>
|
||||||
<plurals name="fmt_song_count">
|
<plurals name="fmt_song_count">
|
||||||
<item quantity="one">%d canción</item>
|
<item quantity="one">%d canción</item>
|
||||||
<item quantity="many">%d canciones</item>
|
|
||||||
<item quantity="other">%d canciones</item>
|
<item quantity="other">%d canciones</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="fmt_album_count">
|
<plurals name="fmt_album_count">
|
||||||
<item quantity="one">%d álbum</item>
|
<item quantity="one">%d álbum</item>
|
||||||
<item quantity="many">%d álbumes</item>
|
|
||||||
<item quantity="other">%d álbumes</item>
|
<item quantity="other">%d álbumes</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="lbl_size">Tamaño</string>
|
<string name="lbl_size">Tamaño</string>
|
||||||
|
@ -263,4 +261,5 @@
|
||||||
<string name="lbl_play_selected">Reproducir los seleccionados</string>
|
<string name="lbl_play_selected">Reproducir los seleccionados</string>
|
||||||
<string name="set_playback_mode_genre">Reproducir desde el género</string>
|
<string name="set_playback_mode_genre">Reproducir desde el género</string>
|
||||||
<string name="lbl_wiki">Wiki</string>
|
<string name="lbl_wiki">Wiki</string>
|
||||||
|
<string name="fmt_list">%1$s, %2$s</string>
|
||||||
</resources>
|
</resources>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue