all: relog project
Fill in a lot of code paths in the project with log statements in order to improve the debugging experience.
This commit is contained in:
parent
b037cfb166
commit
699227c1a8
106 changed files with 1068 additions and 346 deletions
|
@ -36,6 +36,7 @@ import org.oxycblt.auxio.playback.system.PlaybackService
|
|||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.isNight
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/**
|
||||
|
@ -121,6 +122,7 @@ class MainActivity : AppCompatActivity() {
|
|||
private fun startIntentAction(intent: Intent?): Boolean {
|
||||
if (intent == null) {
|
||||
// Nothing to do.
|
||||
logD("No intent to handle")
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -129,6 +131,7 @@ class MainActivity : AppCompatActivity() {
|
|||
// This is because onStart can run multiple times, and thus we really don't
|
||||
// want to return false and override the original delayed action with a
|
||||
// RestoreState action.
|
||||
logD("Already used this intent")
|
||||
return true
|
||||
}
|
||||
intent.putExtra(KEY_INTENT_USED, true)
|
||||
|
@ -137,8 +140,12 @@ class MainActivity : AppCompatActivity() {
|
|||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
|
||||
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll
|
||||
else -> return false
|
||||
else -> {
|
||||
logW("Unexpected intent ${intent.action}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
logD("Translated intent to $action")
|
||||
playbackModel.startAction(action)
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -57,6 +57,7 @@ import org.oxycblt.auxio.util.context
|
|||
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
@ -66,6 +67,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
* high-level navigation features.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Break up the god navigation setup going on here
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainFragment :
|
||||
|
@ -115,9 +118,11 @@ class MainFragment :
|
|||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
if (queueSheetBehavior != null) {
|
||||
// Bottom sheet mode, set up click listeners.
|
||||
// In portrait mode, set up click listeners on the stacked sheets.
|
||||
logD("Configuring stacked bottom sheets")
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
// TODO: Use the material handle
|
||||
unlikelyToBeNull(binding.handleWrapper).setOnClickListener {
|
||||
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
|
||||
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
||||
|
@ -127,6 +132,7 @@ class MainFragment :
|
|||
}
|
||||
} else {
|
||||
// Dual-pane mode, manually style the static queue sheet.
|
||||
logD("Configuring dual-pane bottom sheet")
|
||||
binding.queueSheet.apply {
|
||||
// Emulate the elevated bottom sheet style.
|
||||
background =
|
||||
|
@ -280,19 +286,15 @@ class MainFragment :
|
|||
}
|
||||
|
||||
private fun handleMainNavigation(action: MainNavigationAction?) {
|
||||
if (action == null) {
|
||||
// Nothing to do.
|
||||
return
|
||||
if (action != null) {
|
||||
when (action) {
|
||||
is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel()
|
||||
is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel()
|
||||
is MainNavigationAction.Directions ->
|
||||
findNavController().navigateSafe(action.directions)
|
||||
}
|
||||
navModel.mainNavigationAction.consume()
|
||||
}
|
||||
|
||||
when (action) {
|
||||
is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel()
|
||||
is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel()
|
||||
is MainNavigationAction.Directions ->
|
||||
findNavController().navigateSafe(action.directions)
|
||||
}
|
||||
|
||||
navModel.mainNavigationAction.consume()
|
||||
}
|
||||
|
||||
private fun handleExploreNavigation(item: Music?) {
|
||||
|
@ -377,6 +379,7 @@ class MainFragment :
|
|||
|
||||
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
||||
// Playback sheet is not expanded and not hidden, we can expand it.
|
||||
logD("Expanding playback sheet")
|
||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
|
||||
return
|
||||
}
|
||||
|
@ -387,6 +390,7 @@ class MainFragment :
|
|||
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||
// Queue sheet and playback sheet is expanded, close the queue sheet so the
|
||||
// playback panel can eb shown.
|
||||
logD("Collapsing queue sheet")
|
||||
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
@ -397,6 +401,7 @@ class MainFragment :
|
|||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||
// Playback sheet (and possibly queue) needs to be collapsed.
|
||||
logD("Closing playback and queue sheets")
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||
|
@ -409,6 +414,7 @@ class MainFragment :
|
|||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||
logD("Unhiding and enabling playback sheet")
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
// Queue sheet behavior is either collapsed or expanded, no hiding needed
|
||||
|
@ -429,6 +435,8 @@ class MainFragment :
|
|||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
|
||||
logD("Hiding and disabling playback and queue sheets")
|
||||
|
||||
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
|
||||
queueSheetBehavior?.apply {
|
||||
isDraggable = false
|
||||
|
@ -458,6 +466,7 @@ class MainFragment :
|
|||
if (queueSheetBehavior != null &&
|
||||
queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
|
||||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||
logD("Hiding queue sheet")
|
||||
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||
return
|
||||
}
|
||||
|
@ -465,21 +474,25 @@ class MainFragment :
|
|||
// If expanded, collapse the playback sheet next.
|
||||
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
|
||||
playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||
logD("Hiding playback sheet")
|
||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||
return
|
||||
}
|
||||
|
||||
// Clear out pending playlist edits.
|
||||
if (detailModel.dropPlaylistEdit()) {
|
||||
logD("Dropping playlist edits")
|
||||
return
|
||||
}
|
||||
|
||||
// Clear out any prior selections.
|
||||
if (selectionModel.drop()) {
|
||||
logD("Dropping selection")
|
||||
return
|
||||
}
|
||||
|
||||
// Then try to navigate out of the explore navigation fragments (i.e Detail Views)
|
||||
logD("Navigate away from explore view")
|
||||
binding.exploreNavHost.findNavController().navigateUp()
|
||||
}
|
||||
|
||||
|
@ -500,6 +513,10 @@ class MainFragment :
|
|||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
val exploreNavController = binding.exploreNavHost.findNavController()
|
||||
|
||||
// TODO: Debug why this fails sometimes on the playback sheet
|
||||
// TODO: Add playlist editing
|
||||
// TODO: Can this be split up?
|
||||
|
||||
isEnabled =
|
||||
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
||||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
||||
|
|
|
@ -55,6 +55,7 @@ import org.oxycblt.auxio.util.canScroll
|
|||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.share
|
||||
|
@ -168,7 +169,10 @@ class AlbumDetailFragment :
|
|||
requireContext().share(currentAlbum)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -222,7 +226,7 @@ class AlbumDetailFragment :
|
|||
|
||||
private fun updateAlbum(album: Album?) {
|
||||
if (album == null) {
|
||||
// Album we were showing no longer exists.
|
||||
logD("No album to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
@ -231,12 +235,8 @@ class AlbumDetailFragment :
|
|||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) {
|
||||
albumListAdapter.setPlaying(song, isPlaying)
|
||||
} else {
|
||||
// Clear the ViewHolders if the mode isn't ALL_SONGS
|
||||
albumListAdapter.setPlaying(null, isPlaying)
|
||||
}
|
||||
albumListAdapter.setPlaying(
|
||||
song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying)
|
||||
}
|
||||
|
||||
private fun handleNavigation(item: Music?) {
|
||||
|
@ -303,7 +303,7 @@ class AlbumDetailFragment :
|
|||
boxStart: Int,
|
||||
boxEnd: Int,
|
||||
snapPreference: Int
|
||||
): Int =
|
||||
) =
|
||||
(boxStart + (boxEnd - boxStart) / 2) -
|
||||
(viewStart + (viewEnd - viewStart) / 2)
|
||||
}
|
||||
|
|
|
@ -52,6 +52,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
|
|||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.share
|
||||
|
@ -164,7 +165,10 @@ class ArtistDetailFragment :
|
|||
requireContext().share(currentArtist)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -233,7 +237,7 @@ class ArtistDetailFragment :
|
|||
|
||||
private fun updateArtist(artist: Artist?) {
|
||||
if (artist == null) {
|
||||
// Artist we were showing no longer exists.
|
||||
logD("No artist to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
@ -242,6 +246,9 @@ class ArtistDetailFragment :
|
|||
|
||||
// Disable options that make no sense with an empty artist
|
||||
val playable = artist.songs.isNotEmpty()
|
||||
if (!playable) {
|
||||
logD("Artist is empty, disabling playback/playlist/share options")
|
||||
}
|
||||
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||
menu.findItem(R.id.action_queue_add).isEnabled = playable
|
||||
menu.findItem(R.id.action_playlist_add).isEnabled = playable
|
||||
|
|
|
@ -35,6 +35,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
|
||||
|
@ -77,7 +78,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
(TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
|
||||
// We can never properly initialize the title view's state before draw time,
|
||||
// so we just set it's alpha to 0f to produce a less jarring initialization
|
||||
// animation..
|
||||
// animation.
|
||||
alpha = 0f
|
||||
}
|
||||
|
||||
|
@ -101,12 +102,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
if (titleShown == visible) return
|
||||
titleShown = visible
|
||||
|
||||
val titleAnimator = titleAnimator
|
||||
if (titleAnimator != null) {
|
||||
titleAnimator.cancel()
|
||||
this.titleAnimator = null
|
||||
}
|
||||
|
||||
// Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
|
||||
// the title view's alpha instead of the AppBarLayout's elevation.
|
||||
val titleView = findTitleView()
|
||||
|
@ -126,7 +121,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
return
|
||||
}
|
||||
|
||||
this.titleAnimator =
|
||||
logD("Changing title visibility [from: $from to: $to]")
|
||||
titleAnimator?.cancel()
|
||||
titleAnimator =
|
||||
ValueAnimator.ofFloat(from, to).apply {
|
||||
addUpdateListener { titleView.alpha = it.animatedValue as Float }
|
||||
duration =
|
||||
|
|
|
@ -53,6 +53,7 @@ import org.oxycblt.auxio.playback.PlaybackSettings
|
|||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
|
||||
|
@ -229,9 +230,9 @@ constructor(
|
|||
if (changes.userLibrary && userLibrary != null) {
|
||||
val playlist = currentPlaylist.value
|
||||
if (playlist != null) {
|
||||
logD("Updated playlist to ${currentPlaylist.value}")
|
||||
_currentPlaylist.value =
|
||||
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
|
||||
logD("Updated playlist to ${currentPlaylist.value}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -243,8 +244,11 @@ constructor(
|
|||
* @param uid The UID of the [Song] to load. Must be valid.
|
||||
*/
|
||||
fun setSong(uid: Music.UID) {
|
||||
logD("Opening Song [uid: $uid]")
|
||||
logD("Opening song $uid")
|
||||
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
|
||||
if (_currentSong.value == null) {
|
||||
logW("Given song UID was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -254,9 +258,12 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
|
||||
*/
|
||||
fun setAlbum(uid: Music.UID) {
|
||||
logD("Opening Album [uid: $uid]")
|
||||
logD("Opening album $uid")
|
||||
_currentAlbum.value =
|
||||
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
|
||||
if (_currentAlbum.value == null) {
|
||||
logW("Given album UID was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -266,9 +273,12 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
|
||||
*/
|
||||
fun setArtist(uid: Music.UID) {
|
||||
logD("Opening Artist [uid: $uid]")
|
||||
logD("Opening artist $uid")
|
||||
_currentArtist.value =
|
||||
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
|
||||
if (_currentArtist.value == null) {
|
||||
logW("Given artist UID was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -278,9 +288,12 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
|
||||
*/
|
||||
fun setGenre(uid: Music.UID) {
|
||||
logD("Opening Genre [uid: $uid]")
|
||||
logD("Opening genre $uid")
|
||||
_currentGenre.value =
|
||||
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
|
||||
if (_currentGenre.value == null) {
|
||||
logW("Given genre UID was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -290,9 +303,12 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
|
||||
*/
|
||||
fun setPlaylist(uid: Music.UID) {
|
||||
logD("Opening Playlist [uid: $uid]")
|
||||
logD("Opening playlist $uid")
|
||||
_currentPlaylist.value =
|
||||
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
|
||||
if (_currentPlaylist.value == null) {
|
||||
logW("Given playlist UID was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
|
||||
|
@ -310,6 +326,7 @@ constructor(
|
|||
fun savePlaylistEdit() {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
val editedPlaylist = _editedPlaylist.value ?: return
|
||||
logD("Committing playlist edits")
|
||||
viewModelScope.launch {
|
||||
musicRepository.rewritePlaylist(playlist, editedPlaylist)
|
||||
// TODO: The user could probably press some kind of button if they were fast enough.
|
||||
|
@ -330,6 +347,7 @@ constructor(
|
|||
// Nothing to do.
|
||||
return false
|
||||
}
|
||||
logD("Discarding playlist edits")
|
||||
_editedPlaylist.value = null
|
||||
refreshPlaylistList(playlist)
|
||||
return true
|
||||
|
@ -351,6 +369,7 @@ constructor(
|
|||
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
|
||||
return false
|
||||
}
|
||||
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
|
||||
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
|
||||
_editedPlaylist.value = editedPlaylist
|
||||
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
|
||||
|
@ -369,6 +388,7 @@ constructor(
|
|||
if (realAt !in editedPlaylist.indices) {
|
||||
return
|
||||
}
|
||||
logD("Removing playlist song at $realAt [$at]")
|
||||
editedPlaylist.removeAt(realAt)
|
||||
_editedPlaylist.value = editedPlaylist
|
||||
refreshPlaylistList(
|
||||
|
@ -376,11 +396,13 @@ constructor(
|
|||
if (editedPlaylist.isNotEmpty()) {
|
||||
UpdateInstructions.Remove(at, 1)
|
||||
} else {
|
||||
logD("Playlist will be empty after removal, removing header")
|
||||
UpdateInstructions.Remove(at - 2, 3)
|
||||
})
|
||||
}
|
||||
|
||||
private fun refreshAudioInfo(song: Song) {
|
||||
logD("Refreshing audio info")
|
||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||
currentSongJob?.cancel()
|
||||
_songAudioProperties.value = null
|
||||
|
@ -388,6 +410,7 @@ constructor(
|
|||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val info = audioPropertiesFactory.extract(song)
|
||||
yield()
|
||||
logD("Updating audio info to $info")
|
||||
_songAudioProperties.value = info
|
||||
}
|
||||
}
|
||||
|
@ -421,6 +444,7 @@ constructor(
|
|||
list.addAll(songs)
|
||||
}
|
||||
|
||||
logD("Update album list to ${list.size} items with $instructions")
|
||||
_albumInstructions.put(instructions)
|
||||
_albumList.value = list
|
||||
}
|
||||
|
@ -454,6 +478,7 @@ constructor(
|
|||
// groupByTo normally returns a mapping to a MutableList mapping. Since MutableList
|
||||
// inherits list, we can cast upwards and save a copy by directly inserting the
|
||||
// implicit album list into the mapping.
|
||||
logD("Implicit albums present, adding to list")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(grouping as MutableMap<AlbumGrouping, List<Album>>)[AlbumGrouping.APPEARANCES] =
|
||||
artist.implicitAlbums
|
||||
|
@ -482,6 +507,7 @@ constructor(
|
|||
list.addAll(artistSongSort.songs(artist.songs))
|
||||
}
|
||||
|
||||
logD("Updating artist list to ${list.size} items with $instructions")
|
||||
_artistInstructions.put(instructions)
|
||||
_artistList.value = list.toList()
|
||||
}
|
||||
|
@ -500,12 +526,14 @@ constructor(
|
|||
list.add(songHeader)
|
||||
val instructions =
|
||||
if (replace) {
|
||||
// Intentional so that the header item isn't replaced with the songs
|
||||
// Intentional so that the header item isn't replaced alongside the songs
|
||||
UpdateInstructions.Replace(list.size)
|
||||
} else {
|
||||
UpdateInstructions.Diff
|
||||
}
|
||||
list.addAll(genreSongSort.songs(genre.songs))
|
||||
|
||||
logD("Updating genre list to ${list.size} items with $instructions")
|
||||
_genreInstructions.put(instructions)
|
||||
_genreList.value = list
|
||||
}
|
||||
|
@ -525,6 +553,7 @@ constructor(
|
|||
list.addAll(songs)
|
||||
}
|
||||
|
||||
logD("Updating playlist list to ${list.size} items with $instructions")
|
||||
_playlistInstructions.put(instructions)
|
||||
_playlistList.value = list
|
||||
}
|
||||
|
|
|
@ -53,6 +53,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
|
|||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.share
|
||||
|
@ -163,7 +164,10 @@ class GenreDetailFragment :
|
|||
requireContext().share(currentGenre)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -230,7 +234,7 @@ class GenreDetailFragment :
|
|||
|
||||
private fun updatePlaylist(genre: Genre?) {
|
||||
if (genre == null) {
|
||||
// Genre we were showing no longer exists.
|
||||
logD("No genre to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
|
|
@ -56,6 +56,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
|
|||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.share
|
||||
|
@ -218,7 +219,10 @@ class PlaylistDetailFragment :
|
|||
detailModel.savePlaylistEdit()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -259,6 +263,9 @@ class PlaylistDetailFragment :
|
|||
title = playlist.name.resolve(requireContext())
|
||||
// Disable options that make no sense with an empty playlist
|
||||
val playable = playlist.songs.isNotEmpty()
|
||||
if (!playable) {
|
||||
logD("Playlist is empty, disabling playback/share options")
|
||||
}
|
||||
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||
menu.findItem(R.id.action_queue_add).isEnabled = playable
|
||||
menu.findItem(R.id.action_share).isEnabled = playable
|
||||
|
@ -269,13 +276,9 @@ class PlaylistDetailFragment :
|
|||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
// Prefer songs that might be playing from this playlist.
|
||||
if (parent is Playlist &&
|
||||
parent.uid == unlikelyToBeNull(detailModel.currentPlaylist.value).uid) {
|
||||
playlistListAdapter.setPlaying(song, isPlaying)
|
||||
} else {
|
||||
playlistListAdapter.setPlaying(null, isPlaying)
|
||||
}
|
||||
// Prefer songs that are playing from this playlist.
|
||||
playlistListAdapter.setPlaying(
|
||||
song.takeIf { parent == detailModel.currentPlaylist.value }, isPlaying)
|
||||
}
|
||||
|
||||
private fun handleNavigation(item: Music?) {
|
||||
|
@ -312,6 +315,7 @@ class PlaylistDetailFragment :
|
|||
selectionModel.drop()
|
||||
|
||||
if (editedPlaylist != null) {
|
||||
logD("Updating save button state")
|
||||
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
|
||||
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
|
||||
}
|
||||
|
@ -333,9 +337,18 @@ class PlaylistDetailFragment :
|
|||
private fun updateMultiToolbar() {
|
||||
val id =
|
||||
when {
|
||||
detailModel.editedPlaylist.value != null -> R.id.detail_edit_toolbar
|
||||
selectionModel.selected.value.isNotEmpty() -> R.id.detail_selection_toolbar
|
||||
else -> R.id.detail_normal_toolbar
|
||||
detailModel.editedPlaylist.value != null -> {
|
||||
logD("Currently editing playlist, showing edit toolbar")
|
||||
R.id.detail_edit_toolbar
|
||||
}
|
||||
selectionModel.selected.value.isNotEmpty() -> {
|
||||
logD("Currently selecting, showing selection toolbar")
|
||||
R.id.detail_selection_toolbar
|
||||
}
|
||||
else -> {
|
||||
logD("Using normal toolbar")
|
||||
R.id.detail_normal_toolbar
|
||||
}
|
||||
}
|
||||
|
||||
requireBinding().detailToolbar.setVisible(id)
|
||||
|
|
|
@ -41,6 +41,7 @@ import org.oxycblt.auxio.playback.formatDurationMs
|
|||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.concatLocalized
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewBindingDialogFragment] that shows information about a Song.
|
||||
|
@ -73,7 +74,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
|
||||
private fun updateSong(song: Song?, info: AudioProperties?) {
|
||||
if (song == null) {
|
||||
// Song we were showing no longer exists.
|
||||
logD("No song to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
@ -86,7 +87,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
|
||||
add(SongProperty(R.string.lbl_artists, song.artists.zipNames(context)))
|
||||
add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(context)))
|
||||
song.date?.let { add(SongProperty(R.string.lbl_date, it.resolveDate(context))) }
|
||||
song.date?.let { add(SongProperty(R.string.lbl_date, it.resolve(context))) }
|
||||
song.track?.let {
|
||||
add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.detail.header
|
|||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
|
||||
|
@ -47,6 +48,7 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
|
|||
* @param parent The new [MusicParent] to show.
|
||||
*/
|
||||
fun setParent(parent: T) {
|
||||
logD("Updating parent [old: $currentParent new: $parent]")
|
||||
currentParent = parent
|
||||
rebindParent()
|
||||
}
|
||||
|
@ -55,6 +57,7 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
|
|||
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
|
||||
*/
|
||||
protected fun rebindParent() {
|
||||
logD("Rebinding parent")
|
||||
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
|
||||
}
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ import org.oxycblt.auxio.playback.formatDurationMs
|
|||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [DetailHeaderAdapter] that shows [Playlist] information.
|
||||
|
@ -57,6 +58,7 @@ class PlaylistDetailHeaderAdapter(private val listener: Listener) :
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Updating editing state [old: ${editedPlaylist?.size} new: ${songs?.size}")
|
||||
editedPlaylist = songs
|
||||
rebindParent()
|
||||
}
|
||||
|
@ -102,12 +104,17 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
|
|||
binding.context.getString(R.string.def_song_count)
|
||||
}
|
||||
|
||||
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
|
||||
if (!playable) {
|
||||
logD("Playlist is being edited or is empty, disabling playback options")
|
||||
}
|
||||
|
||||
binding.detailPlayButton.apply {
|
||||
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
|
||||
isEnabled = playable
|
||||
setOnClickListener { listener.onPlay() }
|
||||
}
|
||||
binding.detailShuffleButton.apply {
|
||||
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
|
||||
isEnabled = playable
|
||||
setOnClickListener { listener.onShuffle() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,6 +37,7 @@ import org.oxycblt.auxio.music.info.Disc
|
|||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
|
||||
|
@ -116,6 +117,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
|||
isGone = text == null
|
||||
}
|
||||
} else {
|
||||
logD("Disc is null, defaulting to no disc")
|
||||
binding.discNumber.text = binding.context.getString(R.string.def_disc)
|
||||
binding.discName.isGone = true
|
||||
}
|
||||
|
|
|
@ -26,7 +26,6 @@ import androidx.recyclerview.widget.DiffUtil
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
|
||||
import org.oxycblt.auxio.detail.list.DetailListAdapter.Listener
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Header
|
||||
|
|
|
@ -48,6 +48,7 @@ import org.oxycblt.auxio.util.context
|
|||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
|
||||
|
@ -98,6 +99,7 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Updating editing state [old: $isEditing new: $editing]")
|
||||
this.isEditing = editing
|
||||
notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED)
|
||||
}
|
||||
|
|
|
@ -44,23 +44,32 @@ constructor(
|
|||
|
||||
override fun show() {
|
||||
// Will already show eventually, need to do nothing.
|
||||
if (flipping) return
|
||||
if (flipping) {
|
||||
logD("Already flipping, aborting show")
|
||||
return
|
||||
}
|
||||
// Apply the new configuration possibly set in flipTo. This should occur even if
|
||||
// a flip was canceled by a hide.
|
||||
pendingConfig?.run {
|
||||
this@FlipFloatingActionButton.logD("Applying pending configuration")
|
||||
setImageResource(iconRes)
|
||||
contentDescription = context.getString(contentDescriptionRes)
|
||||
setOnClickListener(clickListener)
|
||||
}
|
||||
pendingConfig = null
|
||||
logD("Beginning show")
|
||||
super.show()
|
||||
}
|
||||
|
||||
override fun hide() {
|
||||
if (flipping) {
|
||||
logD("Hide was called, aborting flip")
|
||||
}
|
||||
// Not flipping anymore, disable the flag so that the FAB is not re-shown.
|
||||
flipping = false
|
||||
// Don't pass any kind of listener so that future flip operations will not be able
|
||||
// to show the FAB again.
|
||||
logD("Beginning hide")
|
||||
super.hide()
|
||||
}
|
||||
|
||||
|
@ -82,9 +91,12 @@ constructor(
|
|||
|
||||
// Already hiding for whatever reason, apply the configuration when the FAB is shown again.
|
||||
if (!isOrWillBeHidden) {
|
||||
logD("Starting hide for flip")
|
||||
flipping = true
|
||||
// We will re-show the FAB later, assuming that there was not a prior flip operation.
|
||||
super.hide(FlipVisibilityListener())
|
||||
} else {
|
||||
logD("Already hiding, will apply config later")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -97,7 +109,7 @@ constructor(
|
|||
private inner class FlipVisibilityListener : OnVisibilityChangedListener() {
|
||||
override fun onHidden(fab: FloatingActionButton) {
|
||||
if (!flipping) return
|
||||
logD("Showing for a flip operation")
|
||||
logD("Starting show for flip")
|
||||
flipping = false
|
||||
show()
|
||||
}
|
||||
|
|
|
@ -76,6 +76,7 @@ import org.oxycblt.auxio.util.collectImmediately
|
|||
import org.oxycblt.auxio.util.getColorCompat
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
|
@ -210,54 +211,65 @@ class HomeFragment :
|
|||
return true
|
||||
}
|
||||
|
||||
when (item.itemId) {
|
||||
return when (item.itemId) {
|
||||
// Handle main actions (Search, Settings, About)
|
||||
R.id.action_search -> {
|
||||
logD("Navigating to search")
|
||||
setupAxisTransitions(MaterialSharedAxis.Z)
|
||||
findNavController().navigateSafe(HomeFragmentDirections.actionShowSearch())
|
||||
true
|
||||
}
|
||||
R.id.action_settings -> {
|
||||
logD("Navigating to settings")
|
||||
navModel.mainNavigateTo(
|
||||
MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings()))
|
||||
true
|
||||
}
|
||||
R.id.action_about -> {
|
||||
logD("Navigating to about")
|
||||
navModel.mainNavigateTo(
|
||||
MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout()))
|
||||
true
|
||||
}
|
||||
|
||||
// Handle sort menu
|
||||
R.id.submenu_sorting -> {
|
||||
// Junk click event when opening the menu
|
||||
true
|
||||
}
|
||||
R.id.option_sort_asc -> {
|
||||
logD("Switching to ascending sorting")
|
||||
item.isChecked = true
|
||||
homeModel.setSortForCurrentTab(
|
||||
homeModel
|
||||
.getSortForTab(homeModel.currentTabMode.value)
|
||||
.withDirection(Sort.Direction.ASCENDING))
|
||||
true
|
||||
}
|
||||
R.id.option_sort_dec -> {
|
||||
logD("Switching to descending sorting")
|
||||
item.isChecked = true
|
||||
homeModel.setSortForCurrentTab(
|
||||
homeModel
|
||||
.getSortForTab(homeModel.currentTabMode.value)
|
||||
.withDirection(Sort.Direction.DESCENDING))
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
// Sorting option was selected, mark it as selected and update the mode
|
||||
item.isChecked = true
|
||||
homeModel.setSortForCurrentTab(
|
||||
homeModel
|
||||
.getSortForTab(homeModel.currentTabMode.value)
|
||||
.withMode(requireNotNull(Sort.Mode.fromItemId(item.itemId))))
|
||||
val newMode = Sort.Mode.fromItemId(item.itemId)
|
||||
if (newMode != null) {
|
||||
// Sorting option was selected, mark it as selected and update the mode
|
||||
logD("Updating sort mode")
|
||||
item.isChecked = true
|
||||
homeModel.setSortForCurrentTab(
|
||||
homeModel.getSortForTab(homeModel.currentTabMode.value).withMode(newMode))
|
||||
true
|
||||
} else {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always handling it one way or another, so always return true
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setupPager(binding: FragmentHomeBinding) {
|
||||
|
@ -268,6 +280,7 @@ class HomeFragment :
|
|||
if (homeModel.currentTabModes.size == 1) {
|
||||
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
||||
// behavior.
|
||||
logD("Single tab shown, disabling TabLayout")
|
||||
binding.homeTabs.isVisible = false
|
||||
binding.homeAppbar.setExpanded(true, false)
|
||||
toolbarParams.scrollFlags = 0
|
||||
|
@ -292,17 +305,26 @@ class HomeFragment :
|
|||
val isVisible: (Int) -> Boolean =
|
||||
when (tabMode) {
|
||||
// Disallow sorting by count for songs
|
||||
MusicMode.SONGS -> { id -> id != R.id.option_sort_count }
|
||||
MusicMode.SONGS -> {
|
||||
logD("Using song-specific menu options")
|
||||
({ id -> id != R.id.option_sort_count })
|
||||
}
|
||||
// Disallow sorting by album for albums
|
||||
MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album }
|
||||
MusicMode.ALBUMS -> {
|
||||
logD("Using album-specific menu options")
|
||||
({ id -> id != R.id.option_sort_album })
|
||||
}
|
||||
// Only allow sorting by name, count, and duration for parents
|
||||
else -> { id ->
|
||||
else -> {
|
||||
logD("Using parent-specific menu options")
|
||||
({ id ->
|
||||
id == R.id.option_sort_asc ||
|
||||
id == R.id.option_sort_dec ||
|
||||
id == R.id.option_sort_name ||
|
||||
id == R.id.option_sort_count ||
|
||||
id == R.id.option_sort_duration
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
val sortMenu =
|
||||
|
@ -310,18 +332,29 @@ class HomeFragment :
|
|||
val toHighlight = homeModel.getSortForTab(tabMode)
|
||||
|
||||
for (option in sortMenu) {
|
||||
// Check the ascending option and corresponding sort option to align with
|
||||
val isCurrentMode = option.itemId == toHighlight.mode.itemId
|
||||
val isCurrentlyAscending =
|
||||
option.itemId == R.id.option_sort_asc &&
|
||||
toHighlight.direction == Sort.Direction.ASCENDING
|
||||
val isCurrentlyDescending =
|
||||
option.itemId == R.id.option_sort_dec &&
|
||||
toHighlight.direction == Sort.Direction.DESCENDING
|
||||
// Check the corresponding direction and mode sort options to align with
|
||||
// the current sort of the tab.
|
||||
if (option.itemId == toHighlight.mode.itemId ||
|
||||
(option.itemId == R.id.option_sort_asc &&
|
||||
toHighlight.direction == Sort.Direction.ASCENDING) ||
|
||||
(option.itemId == R.id.option_sort_dec &&
|
||||
toHighlight.direction == Sort.Direction.DESCENDING)) {
|
||||
if (isCurrentMode || isCurrentlyAscending || isCurrentlyDescending) {
|
||||
logD(
|
||||
"Checking $option [mode: $isCurrentMode asc: $$isCurrentlyAscending dec: $isCurrentlyDescending]")
|
||||
// Note: We cannot inline this boolean assignment since it unchecks all other radio
|
||||
// buttons (even when setting it to false), which would result in nothing being
|
||||
// selected.
|
||||
option.isChecked = true
|
||||
}
|
||||
|
||||
// Disable options that are not allowed by the isVisible lambda
|
||||
option.isVisible = isVisible(option.itemId)
|
||||
if (!option.isVisible) {
|
||||
logD("Hiding $option")
|
||||
}
|
||||
}
|
||||
|
||||
// Update the scrolling view in AppBarLayout to align with the current tab's
|
||||
|
@ -337,10 +370,12 @@ class HomeFragment :
|
|||
}
|
||||
|
||||
if (tabMode != MusicMode.PLAYLISTS) {
|
||||
logD("Flipping to shuffle button")
|
||||
binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
|
||||
playbackModel.shuffleAll()
|
||||
}
|
||||
} else {
|
||||
logD("Flipping to playlist button")
|
||||
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
|
||||
musicModel.createPlaylist()
|
||||
}
|
||||
|
@ -350,6 +385,7 @@ class HomeFragment :
|
|||
private fun handleRecreate(recreate: Unit?) {
|
||||
if (recreate == null) return
|
||||
val binding = requireBinding()
|
||||
logD("Recreating ViewPager")
|
||||
// Move back to position zero, as there must be a tab there.
|
||||
binding.homePager.currentItem = 0
|
||||
// Make sure tabs are set up to also follow the new ViewPager configuration.
|
||||
|
@ -386,7 +422,7 @@ class HomeFragment :
|
|||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||
when (error) {
|
||||
is NoAudioPermissionException -> {
|
||||
logD("Updating UI to permission request state")
|
||||
logD("Showing permission prompt")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
||||
// Configure the action to act as a permission launcher.
|
||||
binding.homeIndexingAction.apply {
|
||||
|
@ -401,7 +437,7 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
is NoMusicException -> {
|
||||
logD("Updating UI to no music state")
|
||||
logD("Showing no music error")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingAction.apply {
|
||||
|
@ -411,7 +447,7 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
else -> {
|
||||
logD("Updating UI to error state")
|
||||
logD("Showing generic error")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingAction.apply {
|
||||
|
@ -431,11 +467,13 @@ class HomeFragment :
|
|||
|
||||
when (progress) {
|
||||
is IndexingProgress.Indeterminate -> {
|
||||
logD("Showing generic progress")
|
||||
// In a query/initialization state, show a generic loading status.
|
||||
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
|
||||
binding.homeIndexingProgress.isIndeterminate = true
|
||||
}
|
||||
is IndexingProgress.Songs -> {
|
||||
logD("Showing song progress")
|
||||
// Actively loading songs, show the current progress.
|
||||
binding.homeIndexingStatus.text =
|
||||
getString(R.string.fmt_indexing, progress.current, progress.total)
|
||||
|
@ -454,8 +492,10 @@ class HomeFragment :
|
|||
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
|
||||
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
|
||||
if (songs.isEmpty() || isFastScrolling) {
|
||||
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling")
|
||||
binding.homeFab.hide()
|
||||
} else {
|
||||
logD("Showing fab")
|
||||
binding.homeFab.show()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -67,15 +68,18 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
|||
|
||||
override fun migrate() {
|
||||
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
|
||||
logD("Migrating tab setting")
|
||||
val oldTabs =
|
||||
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
|
||||
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
|
||||
logD("Old tabs: $oldTabs")
|
||||
|
||||
// The playlist tab is now parsed, but it needs to be made visible.
|
||||
val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS }
|
||||
if (playlistIndex > -1) { // Sanity check
|
||||
oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS)
|
||||
}
|
||||
check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
|
||||
oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS)
|
||||
logD("New tabs: $oldTabs")
|
||||
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
|
||||
remove(OLD_KEY_LIB_TABS)
|
||||
|
@ -85,8 +89,14 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
|||
|
||||
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
|
||||
when (key) {
|
||||
getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
|
||||
getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged()
|
||||
getString(R.string.set_key_home_tabs) -> {
|
||||
logD("Dispatching tab setting change")
|
||||
listener.onTabsChanged()
|
||||
}
|
||||
getString(R.string.set_key_hide_collaborators) -> {
|
||||
logD("Dispatching collaborator setting change")
|
||||
listener.onHideCollaboratorsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -75,8 +75,7 @@ constructor(
|
|||
private val _artistsList = MutableStateFlow(listOf<Artist>())
|
||||
/**
|
||||
* A list of [Artist]s, sorted by the preferred [Sort], to be shown in the home view. Note that
|
||||
* if "Hide collaborators" is on, this list will not include [Artist]s where
|
||||
* [Artist.isCollaborator] is true.
|
||||
* if "Hide collaborators" is on, this list will not include collaborator [Artist]s.
|
||||
*/
|
||||
val artistsList: MutableStateFlow<List<Artist>>
|
||||
get() = _artistsList
|
||||
|
@ -157,9 +156,11 @@ constructor(
|
|||
_artistsList.value =
|
||||
musicSettings.artistSort.artists(
|
||||
if (homeSettings.shouldHideCollaborators) {
|
||||
logD("Filtering collaborator artists")
|
||||
// Hide Collaborators is enabled, filter out collaborators.
|
||||
deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() }
|
||||
} else {
|
||||
logD("Using all artists")
|
||||
deviceLibrary.artists
|
||||
})
|
||||
_genresInstructions.put(UpdateInstructions.Diff)
|
||||
|
@ -177,12 +178,14 @@ constructor(
|
|||
override fun onTabsChanged() {
|
||||
// Tabs changed, update the current tabs and set up a re-create event.
|
||||
currentTabModes = makeTabModes()
|
||||
logD("Updating tabs: ${currentTabMode.value}")
|
||||
_shouldRecreate.put(Unit)
|
||||
}
|
||||
|
||||
override fun onHideCollaboratorsChanged() {
|
||||
// Changes in the hide collaborator setting will change the artist contents
|
||||
// of the library, consider it a library update.
|
||||
logD("Collaborator setting changed, forwarding update")
|
||||
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
|
||||
}
|
||||
|
||||
|
@ -207,30 +210,34 @@ constructor(
|
|||
* @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab].
|
||||
*/
|
||||
fun setSortForCurrentTab(sort: Sort) {
|
||||
logD("Updating ${_currentTabMode.value} sort to $sort")
|
||||
// Can simply re-sort the current list of items without having to access the library.
|
||||
when (_currentTabMode.value) {
|
||||
when (val mode = _currentTabMode.value) {
|
||||
MusicMode.SONGS -> {
|
||||
logD("Updating song [$mode] sort mode to $sort")
|
||||
musicSettings.songSort = sort
|
||||
_songsInstructions.put(UpdateInstructions.Replace(0))
|
||||
_songsList.value = sort.songs(_songsList.value)
|
||||
}
|
||||
MusicMode.ALBUMS -> {
|
||||
logD("Updating album [$mode] sort mode to $sort")
|
||||
musicSettings.albumSort = sort
|
||||
_albumsInstructions.put(UpdateInstructions.Replace(0))
|
||||
_albumsLists.value = sort.albums(_albumsLists.value)
|
||||
}
|
||||
MusicMode.ARTISTS -> {
|
||||
logD("Updating artist [$mode] sort mode to $sort")
|
||||
musicSettings.artistSort = sort
|
||||
_artistsInstructions.put(UpdateInstructions.Replace(0))
|
||||
_artistsList.value = sort.artists(_artistsList.value)
|
||||
}
|
||||
MusicMode.GENRES -> {
|
||||
logD("Updating genre [$mode] sort mode to $sort")
|
||||
musicSettings.genreSort = sort
|
||||
_genresInstructions.put(UpdateInstructions.Replace(0))
|
||||
_genresList.value = sort.genres(_genresList.value)
|
||||
}
|
||||
MusicMode.PLAYLISTS -> {
|
||||
logD("Updating playlist [$mode] sort mode to $sort")
|
||||
musicSettings.playlistSort = sort
|
||||
_playlistsInstructions.put(UpdateInstructions.Replace(0))
|
||||
_playlistsList.value = sort.playlists(_playlistsList.value)
|
||||
|
|
|
@ -107,7 +107,7 @@ class AlbumListFragment :
|
|||
is Sort.Mode.ByArtist -> album.artists[0].name.thumb
|
||||
|
||||
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
|
||||
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
|
||||
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
||||
|
@ -156,8 +156,8 @@ class AlbumListFragment :
|
|||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
// Only highlight the album if it is currently playing, and if the currently
|
||||
// playing song is also contained within.
|
||||
val playlist = (parent as? Album)?.takeIf { song?.album == it }
|
||||
albumAdapter.setPlaying(playlist, isPlaying)
|
||||
val album = (parent as? Album)?.takeIf { song?.album == it }
|
||||
albumAdapter.setPlaying(album, isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -44,7 +44,6 @@ import org.oxycblt.auxio.navigation.NavigationViewModel
|
|||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
|
||||
/**
|
||||
|
@ -123,7 +122,7 @@ class ArtistListFragment :
|
|||
}
|
||||
|
||||
private fun updateArtists(artists: List<Artist>) {
|
||||
artistAdapter.update(artists, homeModel.artistsInstructions.consume().also { logD(it) })
|
||||
artistAdapter.update(artists, homeModel.artistsInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
|
@ -133,8 +132,8 @@ class ArtistListFragment :
|
|||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
// Only highlight the artist if it is currently playing, and if the currently
|
||||
// playing song is also contained within.
|
||||
val playlist = (parent as? Artist)?.takeIf { song?.run { artists.contains(it) } ?: false }
|
||||
artistAdapter.setPlaying(playlist, isPlaying)
|
||||
val artist = (parent as? Artist)?.takeIf { song?.run { artists.contains(it) } ?: false }
|
||||
artistAdapter.setPlaying(artist, isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -44,7 +44,6 @@ import org.oxycblt.auxio.navigation.NavigationViewModel
|
|||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Genre]s.
|
||||
|
@ -122,7 +121,7 @@ class GenreListFragment :
|
|||
}
|
||||
|
||||
private fun updateGenres(genres: List<Genre>) {
|
||||
genreAdapter.update(genres, homeModel.genresInstructions.consume().also { logD(it) })
|
||||
genreAdapter.update(genres, homeModel.genresInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
|
@ -132,8 +131,8 @@ class GenreListFragment :
|
|||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
// Only highlight the genre if it is currently playing, and if the currently
|
||||
// playing song is also contained within.
|
||||
val playlist = (parent as? Genre)?.takeIf { song?.run { genres.contains(it) } ?: false }
|
||||
genreAdapter.setPlaying(playlist, isPlaying)
|
||||
val genre = (parent as? Genre)?.takeIf { song?.run { genres.contains(it) } ?: false }
|
||||
genreAdapter.setPlaying(genre, isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -43,7 +43,6 @@ import org.oxycblt.auxio.navigation.NavigationViewModel
|
|||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Playlist]s.
|
||||
|
@ -120,8 +119,7 @@ class PlaylistListFragment :
|
|||
}
|
||||
|
||||
private fun updatePlaylists(playlists: List<Playlist>) {
|
||||
playlistAdapter.update(
|
||||
playlists, homeModel.playlistsInstructions.consume().also { logD(it) })
|
||||
playlistAdapter.update(playlists, homeModel.playlistsInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
|
|
|
@ -23,7 +23,6 @@ import com.google.android.material.tabs.TabLayout
|
|||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
|
||||
|
@ -67,20 +66,11 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicMode>) :
|
|||
// Use expected sw* size thresholds when choosing a configuration.
|
||||
when {
|
||||
// On small screens, only display an icon.
|
||||
width < 370 -> {
|
||||
logD("Using icon-only configuration")
|
||||
tab.setIcon(icon).setContentDescription(string)
|
||||
}
|
||||
width < 370 -> tab.setIcon(icon).setContentDescription(string)
|
||||
// On large screens, display an icon and text.
|
||||
width < 600 -> {
|
||||
logD("Using text-only configuration")
|
||||
tab.setText(string)
|
||||
}
|
||||
width < 600 -> tab.setText(string)
|
||||
// On medium-size screens, display text.
|
||||
else -> {
|
||||
logD("Using icon-and-text configuration")
|
||||
tab.setIcon(icon).setText(string)
|
||||
}
|
||||
else -> tab.setIcon(icon).setText(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.home.tabs
|
|||
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A representation of a library tab suitable for configuration.
|
||||
|
@ -84,6 +85,10 @@ sealed class Tab(open val mode: MusicMode) {
|
|||
fun toIntCode(tabs: Array<Tab>): Int {
|
||||
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
|
||||
val distinct = tabs.distinctBy { it.mode }
|
||||
if (tabs.size != distinct.size) {
|
||||
logW(
|
||||
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
||||
}
|
||||
|
||||
var sequence = 0
|
||||
var shift = MAX_SEQUENCE_IDX * 4
|
||||
|
@ -127,6 +132,10 @@ sealed class Tab(open val mode: MusicMode) {
|
|||
|
||||
// Make sure there are no duplicate tabs
|
||||
val distinct = tabs.distinctBy { it.mode }
|
||||
if (tabs.size != distinct.size) {
|
||||
logW(
|
||||
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
||||
}
|
||||
|
||||
// For safety, return null if we have an empty or larger-than-expected tab array.
|
||||
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
|
||||
|
|
|
@ -28,6 +28,7 @@ import org.oxycblt.auxio.list.EditClickListListener
|
|||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
|
||||
|
@ -52,6 +53,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
|||
* @param newTabs The new array of tabs to show.
|
||||
*/
|
||||
fun submitTabs(newTabs: Array<Tab>) {
|
||||
logD("Force-updating tab information")
|
||||
tabs = newTabs
|
||||
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
|
||||
}
|
||||
|
@ -63,6 +65,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
|||
* @param tab The new tab.
|
||||
*/
|
||||
fun setTab(at: Int, tab: Tab) {
|
||||
logD("Updating tab [at: $at, tab: $tab]")
|
||||
tabs[at] = tab
|
||||
// Use a payload to avoid an item change animation.
|
||||
notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
|
||||
|
@ -75,6 +78,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
|||
* @param b The position of the second tab to swap.
|
||||
*/
|
||||
fun swapTabs(a: Int, b: Int) {
|
||||
logD("Swapping tabs [a: $a, b: $b]")
|
||||
val tmp = tabs[b]
|
||||
tabs[b] = tabs[a]
|
||||
tabs[a] = tmp
|
||||
|
|
|
@ -91,14 +91,15 @@ class TabCustomizeDialog :
|
|||
// We will need the exact index of the tab to update on in order to
|
||||
// notify the adapter of the change.
|
||||
val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode }
|
||||
val tab = tabAdapter.tabs[index]
|
||||
tabAdapter.setTab(
|
||||
index,
|
||||
when (tab) {
|
||||
val old = tabAdapter.tabs[index]
|
||||
val new =
|
||||
when (old) {
|
||||
// Invert the visibility of the tab
|
||||
is Tab.Visible -> Tab.Invisible(tab.mode)
|
||||
is Tab.Invisible -> Tab.Visible(tab.mode)
|
||||
})
|
||||
is Tab.Visible -> Tab.Invisible(old.mode)
|
||||
is Tab.Invisible -> Tab.Visible(old.mode)
|
||||
}
|
||||
logD("Flipping tab visibility [from: $old to: $new]")
|
||||
tabAdapter.setTab(index, new)
|
||||
|
||||
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.
|
||||
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =
|
||||
|
|
|
@ -63,7 +63,9 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac
|
|||
return true
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
|
||||
// We use a custom drag handle, so disable the long press action.
|
||||
override fun isLongPressDragEnabled() = false
|
||||
|
|
|
@ -73,6 +73,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
|
||||
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
|
||||
if (key == getString(R.string.set_key_cover_mode)) {
|
||||
logD("Dispatching cover mode setting change")
|
||||
listener.onCoverModeChanged()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,8 +53,7 @@ import org.oxycblt.auxio.image.ImageSettings
|
|||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
class CoverExtractor
|
||||
@Inject
|
||||
|
@ -97,7 +96,7 @@ constructor(
|
|||
CoverMode.QUALITY -> extractQualityCover(album)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logW("Unable to extract album cover due to an error: $e")
|
||||
logE("Unable to extract album cover due to an error: $e")
|
||||
null
|
||||
}
|
||||
|
||||
|
@ -154,7 +153,6 @@ constructor(
|
|||
}
|
||||
|
||||
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
|
||||
logD("Front cover found")
|
||||
stream = ByteArrayInputStream(pic)
|
||||
break
|
||||
} else if (stream == null) {
|
||||
|
|
|
@ -31,8 +31,7 @@ import kotlin.math.min
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SquareFrameTransform : Transformation {
|
||||
override val cacheKey: String
|
||||
get() = "SquareFrameTransform"
|
||||
override val cacheKey = "SquareFrameTransform"
|
||||
|
||||
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
|
||||
// Find the smaller dimension and then take a center portion of the image that
|
||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.list
|
|||
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
// TODO: Consider breaking this up into sealed classes for individual adapters
|
||||
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
|
||||
interface Item
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@ import org.oxycblt.auxio.music.Song
|
|||
import org.oxycblt.auxio.navigation.MainNavigationAction
|
||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.share
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
|
@ -94,33 +95,40 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
|||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(song)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(song)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_go_artist -> {
|
||||
navModel.exploreNavigateToParentArtist(song)
|
||||
true
|
||||
}
|
||||
R.id.action_go_album -> {
|
||||
navModel.exploreNavigateTo(song.album)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(song)
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(song)
|
||||
true
|
||||
}
|
||||
R.id.action_song_detail -> {
|
||||
navModel.mainNavigateTo(
|
||||
MainNavigationAction.Directions(
|
||||
MainFragmentDirections.actionShowDetails(song.uid)))
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -141,32 +149,39 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
|||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(album)
|
||||
true
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(album)
|
||||
true
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(album)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(album)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_go_artist -> {
|
||||
navModel.exploreNavigateToParentArtist(album)
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(album)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(album)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -184,6 +199,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
|||
|
||||
openMenu(anchor, menuRes) {
|
||||
val playable = artist.songs.isNotEmpty()
|
||||
if (!playable) {
|
||||
logD("Artist is empty, disabling playback/playlist/share options")
|
||||
}
|
||||
menu.findItem(R.id.action_play).isEnabled = playable
|
||||
menu.findItem(R.id.action_shuffle).isEnabled = playable
|
||||
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||
|
@ -195,29 +213,35 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
|||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(artist)
|
||||
true
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(artist)
|
||||
true
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(artist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(artist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(artist)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(artist)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -238,29 +262,35 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
|||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(genre)
|
||||
true
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(genre)
|
||||
true
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(genre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(genre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(genre)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(genre)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -288,32 +318,39 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
|||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(playlist)
|
||||
true
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(playlist)
|
||||
true
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(playlist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(playlist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_rename -> {
|
||||
musicModel.renamePlaylist(playlist)
|
||||
true
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
musicModel.deletePlaylist(playlist)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(playlist)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -332,6 +369,8 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
|||
return
|
||||
}
|
||||
|
||||
logD("Opening popup menu menu")
|
||||
|
||||
currentMenu =
|
||||
PopupMenu(requireContext(), anchor).apply {
|
||||
inflate(menuRes)
|
||||
|
|
|
@ -22,8 +22,6 @@ import androidx.annotation.IdRes
|
|||
import kotlin.math.max
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.Sort.Direction
|
||||
import org.oxycblt.auxio.list.Sort.Mode
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
|
|
|
@ -25,6 +25,7 @@ import androidx.recyclerview.widget.AsyncDifferConfig
|
|||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import java.util.concurrent.Executor
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A variant of ListDiffer with more flexible updates.
|
||||
|
@ -46,15 +47,18 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
/**
|
||||
* Update the adapter with new data.
|
||||
*
|
||||
* @param newData The new list of data to update with.
|
||||
* @param newList The new list of data to update with.
|
||||
* @param instructions The [UpdateInstructions] to visually update the list with.
|
||||
* @param callback Called when the update is completed. May be done asynchronously.
|
||||
*/
|
||||
fun update(
|
||||
newData: List<T>,
|
||||
newList: List<T>,
|
||||
instructions: UpdateInstructions?,
|
||||
callback: (() -> Unit)? = null
|
||||
) = differ.update(newData, instructions, callback)
|
||||
) {
|
||||
logD("Updating list to ${newList.size} items with $instructions")
|
||||
differ.update(newList, instructions, callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -165,6 +169,7 @@ private class FlexibleListDiffer<T>(
|
|||
) {
|
||||
// fast simple remove all
|
||||
if (newList.isEmpty()) {
|
||||
logD("Short-circuiting diff to remove all")
|
||||
val countRemoved = oldList.size
|
||||
currentList = emptyList()
|
||||
// notify last, after list is updated
|
||||
|
@ -175,6 +180,7 @@ private class FlexibleListDiffer<T>(
|
|||
|
||||
// fast simple first insert
|
||||
if (oldList.isEmpty()) {
|
||||
logD("Short-circuiting diff to insert all")
|
||||
currentList = newList
|
||||
// notify last, after list is updated
|
||||
updateCallback.onInserted(0, newList.size)
|
||||
|
@ -233,8 +239,10 @@ private class FlexibleListDiffer<T>(
|
|||
throw AssertionError()
|
||||
}
|
||||
})
|
||||
|
||||
mainThreadExecutor.execute {
|
||||
if (maxScheduledGeneration == runGeneration) {
|
||||
logD("Applying calculated diff")
|
||||
currentList = newList
|
||||
result.dispatchUpdatesTo(updateCallback)
|
||||
callback?.invoke()
|
||||
|
|
|
@ -58,6 +58,8 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
* @param isPlaying Whether playback is ongoing or paused.
|
||||
*/
|
||||
fun setPlaying(item: T?, isPlaying: Boolean) {
|
||||
logD("Updating playing item [old: $currentItem new: $item]")
|
||||
|
||||
var updatedItem = false
|
||||
if (currentItem != item) {
|
||||
val oldItem = currentItem
|
||||
|
|
|
@ -22,6 +22,7 @@ import android.view.View
|
|||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
|
||||
|
@ -54,6 +55,7 @@ abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
|
||||
|
||||
selectedItems = newSelectedItems
|
||||
for (i in currentList.indices) {
|
||||
|
|
|
@ -68,7 +68,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
// this is only done once when the item is initially picked up.
|
||||
// TODO: I think this is possible to improve with a raw ValueAnimator.
|
||||
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||
logD("Lifting item")
|
||||
logD("Lifting ViewHolder")
|
||||
|
||||
val bg = holder.background
|
||||
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
|
||||
|
@ -110,7 +110,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
// This function can be called multiple times, so only start the animation when the view's
|
||||
// translationZ is already non-zero.
|
||||
if (holder.root.translationZ != 0f) {
|
||||
logD("Dropping item")
|
||||
logD("Lifting ViewHolder")
|
||||
|
||||
val bg = holder.background
|
||||
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
|
||||
|
@ -137,7 +137,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
// Long-press events are too buggy, only allow dragging with the handle.
|
||||
final override fun isLongPressDragEnabled() = false
|
||||
|
||||
/** Required [RecyclerView.ViewHolder] implementation that exposes the following. */
|
||||
/** Required [RecyclerView.ViewHolder] implementation that exposes required fields */
|
||||
interface ViewHolder {
|
||||
/** Whether this [ViewHolder] can be moved right now. */
|
||||
val enabled: Boolean
|
||||
|
|
|
@ -27,10 +27,12 @@ import org.oxycblt.auxio.music.Album
|
|||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewModel] that manages the current selection.
|
||||
|
@ -83,10 +85,19 @@ constructor(
|
|||
* @param music The [Music] item to select.
|
||||
*/
|
||||
fun select(music: Music) {
|
||||
if (music is MusicParent && music.songs.isEmpty()) {
|
||||
logD("Cannot select empty parent, ignoring operation")
|
||||
return
|
||||
}
|
||||
|
||||
val selected = _selected.value.toMutableList()
|
||||
if (!selected.remove(music)) {
|
||||
logD("Adding $music to selection")
|
||||
selected.add(music)
|
||||
} else {
|
||||
logD("Removed $music from selection")
|
||||
}
|
||||
|
||||
_selected.value = selected
|
||||
}
|
||||
|
||||
|
@ -95,8 +106,9 @@ constructor(
|
|||
*
|
||||
* @return A list of [Song]s collated from each item selected.
|
||||
*/
|
||||
fun take() =
|
||||
_selected.value
|
||||
fun take(): List<Song> {
|
||||
logD("Taking selection")
|
||||
return _selected.value
|
||||
.flatMap {
|
||||
when (it) {
|
||||
is Song -> listOf(it)
|
||||
|
@ -106,12 +118,16 @@ constructor(
|
|||
is Playlist -> it.songs
|
||||
}
|
||||
}
|
||||
.also { drop() }
|
||||
.also { _selected.value = listOf() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection.
|
||||
*
|
||||
* @return true if the prior selection was non-empty, false otherwise.
|
||||
*/
|
||||
fun drop() = _selected.value.isNotEmpty().also { _selected.value = listOf() }
|
||||
fun drop(): Boolean {
|
||||
logD("Dropping selection [empty=${_selected.value.isEmpty()}]")
|
||||
return _selected.value.isNotEmpty().also { _selected.value = listOf() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,9 +34,6 @@ import kotlinx.coroutines.channels.Channel
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.music.MusicRepository.IndexingListener
|
||||
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
|
||||
import org.oxycblt.auxio.music.MusicRepository.UpdateListener
|
||||
import org.oxycblt.auxio.music.cache.CacheRepository
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.device.RawSong
|
||||
|
@ -55,6 +52,8 @@ import org.oxycblt.auxio.util.logW
|
|||
* music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Switch listener to set
|
||||
*/
|
||||
interface MusicRepository {
|
||||
/** The current music information found on the device. */
|
||||
|
@ -289,36 +288,42 @@ constructor(
|
|||
|
||||
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||
logD("Creating playlist $name with ${songs.size} songs")
|
||||
userLibrary.createPlaylist(name, songs)
|
||||
notifyUserLibraryChange()
|
||||
}
|
||||
|
||||
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||
logD("Renaming $playlist to $name")
|
||||
userLibrary.renamePlaylist(playlist, name)
|
||||
notifyUserLibraryChange()
|
||||
}
|
||||
|
||||
override suspend fun deletePlaylist(playlist: Playlist) {
|
||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||
logD("Deleting $playlist")
|
||||
userLibrary.deletePlaylist(playlist)
|
||||
notifyUserLibraryChange()
|
||||
}
|
||||
|
||||
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||
logD("Adding ${songs.size} songs to $playlist")
|
||||
userLibrary.addToPlaylist(playlist, songs)
|
||||
notifyUserLibraryChange()
|
||||
}
|
||||
|
||||
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||
logD("Rewriting $playlist with ${songs.size} songs")
|
||||
userLibrary.rewritePlaylist(playlist, songs)
|
||||
notifyUserLibraryChange()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun notifyUserLibraryChange() {
|
||||
logD("Dispatching user library change")
|
||||
for (listener in updateListeners) {
|
||||
listener.onMusicChanges(
|
||||
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
|
||||
|
@ -327,6 +332,7 @@ constructor(
|
|||
|
||||
@Synchronized
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
logD("Requesting index operation [cache=$withCache]")
|
||||
indexingWorker?.requestIndex(withCache)
|
||||
}
|
||||
|
||||
|
@ -353,7 +359,7 @@ constructor(
|
|||
private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) {
|
||||
if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) ==
|
||||
PackageManager.PERMISSION_DENIED) {
|
||||
logE("Permission check failed")
|
||||
logE("Permissions were not granted")
|
||||
// No permissions, signal that we can't do anything.
|
||||
throw NoAudioPermissionException()
|
||||
}
|
||||
|
@ -363,14 +369,16 @@ constructor(
|
|||
emitLoading(IndexingProgress.Indeterminate)
|
||||
|
||||
// Do the initial query of the cache and media databases in parallel.
|
||||
logD("Starting queries")
|
||||
logD("Starting MediaStore query")
|
||||
val mediaStoreQueryJob = worker.scope.tryAsync { mediaStoreExtractor.query() }
|
||||
val cache =
|
||||
if (withCache) {
|
||||
logD("Reading cache")
|
||||
cacheRepository.readCache()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
logD("Awaiting MediaStore query")
|
||||
val query = mediaStoreQueryJob.await().getOrThrow()
|
||||
|
||||
// Now start processing the queried song information in parallel. Songs that can't be
|
||||
|
@ -379,11 +387,13 @@ constructor(
|
|||
logD("Starting song discovery")
|
||||
val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
|
||||
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
|
||||
logD("Started MediaStore discovery")
|
||||
val mediaStoreJob =
|
||||
worker.scope.tryAsync {
|
||||
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
|
||||
incompleteSongs.close()
|
||||
}
|
||||
logD("Started ExoPlayer discovery")
|
||||
val metadataJob =
|
||||
worker.scope.tryAsync {
|
||||
tagExtractor.consume(incompleteSongs, completeSongs)
|
||||
|
@ -396,7 +406,8 @@ constructor(
|
|||
rawSongs.add(rawSong)
|
||||
emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
|
||||
}
|
||||
// These should be no-ops
|
||||
logD("Awaiting discovery completion")
|
||||
// These should be no-ops, but we need the error state to see if we should keep going.
|
||||
mediaStoreJob.await().getOrThrow()
|
||||
metadataJob.await().getOrThrow()
|
||||
|
||||
|
@ -411,25 +422,35 @@ constructor(
|
|||
// TODO: Indicate playlist state in loading process?
|
||||
emitLoading(IndexingProgress.Indeterminate)
|
||||
val deviceLibraryChannel = Channel<DeviceLibrary>()
|
||||
logD("Starting DeviceLibrary creation")
|
||||
val deviceLibraryJob =
|
||||
worker.scope.tryAsync(Dispatchers.Main) {
|
||||
deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) }
|
||||
}
|
||||
logD("Starting UserLibrary creation")
|
||||
val userLibraryJob =
|
||||
worker.scope.tryAsync {
|
||||
userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() }
|
||||
}
|
||||
if (cache == null || cache.invalidated) {
|
||||
logD("Writing cache [why=${cache?.invalidated}]")
|
||||
cacheRepository.writeCache(rawSongs)
|
||||
}
|
||||
logD("Awaiting library creation")
|
||||
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
|
||||
val userLibrary = userLibraryJob.await().getOrThrow()
|
||||
|
||||
logD("Successfully indexed music library [device=$deviceLibrary user=$userLibrary]")
|
||||
withContext(Dispatchers.Main) {
|
||||
emitComplete(null)
|
||||
emitData(deviceLibrary, userLibrary)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An extension of [async] that forces the outcome to a [Result] to allow exceptions to bubble
|
||||
* upwards instead of crashing the entire app.
|
||||
*/
|
||||
private inline fun <R> CoroutineScope.tryAsync(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
crossinline block: suspend () -> R
|
||||
|
@ -457,6 +478,7 @@ constructor(
|
|||
synchronized(this) {
|
||||
previousCompletedState = IndexingState.Completed(error)
|
||||
currentIndexingState = null
|
||||
logD("Dispatching completion state [error=$error]")
|
||||
for (listener in indexingListeners) {
|
||||
listener.onIndexingStateChanged()
|
||||
}
|
||||
|
@ -472,6 +494,7 @@ constructor(
|
|||
this.deviceLibrary = deviceLibrary
|
||||
this.userLibrary = userLibrary
|
||||
val changes = MusicRepository.Changes(deviceLibraryChanged, userLibraryChanged)
|
||||
logD("Dispatching library change [changes=$changes]")
|
||||
for (listener in updateListeners) {
|
||||
listener.onMusicChanges(changes)
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.fs.Directory
|
|||
import org.oxycblt.auxio.music.fs.MusicDirectories
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* User configuration specific to music system.
|
||||
|
@ -231,8 +232,14 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
getString(R.string.set_key_music_dirs),
|
||||
getString(R.string.set_key_music_dirs_include),
|
||||
getString(R.string.set_key_separators),
|
||||
getString(R.string.set_key_auto_sort_names) -> listener.onIndexingSettingChanged()
|
||||
getString(R.string.set_key_observing) -> listener.onObservingChanged()
|
||||
getString(R.string.set_key_auto_sort_names) -> {
|
||||
logD("Dispatching indexing setting change for $key")
|
||||
listener.onIndexingSettingChanged()
|
||||
}
|
||||
getString(R.string.set_key_observing) -> {
|
||||
logD("Dispatching observing setting change")
|
||||
listener.onObservingChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewModel] providing data specific to the music loading process.
|
||||
|
@ -89,6 +90,7 @@ constructor(
|
|||
deviceLibrary.artists.size,
|
||||
deviceLibrary.genres.size,
|
||||
deviceLibrary.songs.sumOf { it.durationMs })
|
||||
logD("Updated statistics: ${_statistics.value}")
|
||||
}
|
||||
|
||||
override fun onIndexingStateChanged() {
|
||||
|
@ -97,11 +99,13 @@ constructor(
|
|||
|
||||
/** Requests that the music library should be re-loaded while leveraging the cache. */
|
||||
fun refresh() {
|
||||
logD("Refreshing library")
|
||||
musicRepository.requestIndex(true)
|
||||
}
|
||||
|
||||
/** Requests that the music library be re-loaded without the cache. */
|
||||
fun rescan() {
|
||||
logD("Rescanning library")
|
||||
musicRepository.requestIndex(false)
|
||||
}
|
||||
|
||||
|
@ -113,8 +117,10 @@ constructor(
|
|||
*/
|
||||
fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) {
|
||||
if (name != null) {
|
||||
logD("Creating $name with ${songs.size} songs]")
|
||||
viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) }
|
||||
} else {
|
||||
logD("Launching creation dialog for ${songs.size} songs")
|
||||
_newPlaylistSongs.put(songs)
|
||||
}
|
||||
}
|
||||
|
@ -127,8 +133,10 @@ constructor(
|
|||
*/
|
||||
fun renamePlaylist(playlist: Playlist, name: String? = null) {
|
||||
if (name != null) {
|
||||
logD("Renaming $playlist to $name")
|
||||
viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) }
|
||||
} else {
|
||||
logD("Launching rename dialog for $playlist")
|
||||
_playlistToRename.put(playlist)
|
||||
}
|
||||
}
|
||||
|
@ -142,8 +150,10 @@ constructor(
|
|||
*/
|
||||
fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
|
||||
if (rude) {
|
||||
logD("Deleting $playlist")
|
||||
viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) }
|
||||
} else {
|
||||
logD("Launching deletion dialog for $playlist")
|
||||
_playlistToDelete.put(playlist)
|
||||
}
|
||||
}
|
||||
|
@ -155,6 +165,7 @@ constructor(
|
|||
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
||||
*/
|
||||
fun addToPlaylist(song: Song, playlist: Playlist? = null) {
|
||||
logD("Adding $song to playlist")
|
||||
addToPlaylist(listOf(song), playlist)
|
||||
}
|
||||
|
||||
|
@ -165,6 +176,7 @@ constructor(
|
|||
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
||||
*/
|
||||
fun addToPlaylist(album: Album, playlist: Playlist? = null) {
|
||||
logD("Adding $album to playlist")
|
||||
addToPlaylist(musicSettings.albumSongSort.songs(album.songs), playlist)
|
||||
}
|
||||
|
||||
|
@ -175,6 +187,7 @@ constructor(
|
|||
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
||||
*/
|
||||
fun addToPlaylist(artist: Artist, playlist: Playlist? = null) {
|
||||
logD("Adding $artist to playlist")
|
||||
addToPlaylist(musicSettings.artistSongSort.songs(artist.songs), playlist)
|
||||
}
|
||||
|
||||
|
@ -185,6 +198,7 @@ constructor(
|
|||
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
|
||||
*/
|
||||
fun addToPlaylist(genre: Genre, playlist: Playlist? = null) {
|
||||
logD("Adding $genre to playlist")
|
||||
addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist)
|
||||
}
|
||||
|
||||
|
@ -196,8 +210,10 @@ constructor(
|
|||
*/
|
||||
fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) {
|
||||
if (playlist != null) {
|
||||
logD("Adding ${songs.size} songs to $playlist")
|
||||
viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) }
|
||||
} else {
|
||||
logD("Launching addition dialog for songs=${songs.size}")
|
||||
_songsToAdd.put(songs)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.cache
|
|||
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.device.RawSong
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
/**
|
||||
|
@ -49,7 +50,9 @@ class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: Cached
|
|||
try {
|
||||
// Faster to load the whole database into memory than do a query on each
|
||||
// populate call.
|
||||
CacheImpl(cachedSongsDao.readSongs())
|
||||
val songs = cachedSongsDao.readSongs()
|
||||
logD("Successfully read ${songs.size} songs from cache")
|
||||
CacheImpl(songs)
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to load cache database.")
|
||||
logE(e.stackTraceToString())
|
||||
|
@ -60,7 +63,9 @@ class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: Cached
|
|||
try {
|
||||
// Still write out whatever data was extracted.
|
||||
cachedSongsDao.nukeSongs()
|
||||
logD("Successfully deleted old cache")
|
||||
cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
|
||||
logD("Successfully wrote ${rawSongs.size} songs to cache")
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to save cache database.")
|
||||
logE(e.stackTraceToString())
|
||||
|
@ -96,7 +101,6 @@ private class CacheImpl(cachedSongs: List<CachedSong>) : Cache {
|
|||
|
||||
override var invalidated = false
|
||||
override fun populate(rawSong: RawSong): Boolean {
|
||||
|
||||
// For a cached raw song to be used, it must exist within the cache and have matching
|
||||
// addition and modification timestamps. Technically the addition timestamp doesn't
|
||||
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we
|
||||
|
|
|
@ -149,6 +149,9 @@ private class DeviceLibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings
|
|||
return hashCode
|
||||
}
|
||||
|
||||
override fun toString() =
|
||||
"DeviceLibrary(songs=${songs.size}, albums=${albums.size}, artists=${artists.size}, genres=${genres.size})"
|
||||
|
||||
override fun findSong(uid: Music.UID) = songUidMap[uid]
|
||||
override fun findAlbum(uid: Music.UID) = albumUidMap[uid]
|
||||
override fun findArtist(uid: Music.UID) = artistUidMap[uid]
|
||||
|
|
|
@ -96,6 +96,7 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
|
|||
override fun hashCode() = 31 * uid.hashCode() + rawSong.hashCode()
|
||||
override fun equals(other: Any?) =
|
||||
other is SongImpl && uid == other.uid && rawSong == other.rawSong
|
||||
override fun toString() = "Song(uid=$uid, name=$name)"
|
||||
|
||||
private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
|
||||
private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
|
||||
|
@ -262,6 +263,8 @@ class AlbumImpl(
|
|||
override fun equals(other: Any?) =
|
||||
other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs
|
||||
|
||||
override fun toString() = "Album(uid=$uid, name=$name)"
|
||||
|
||||
private val _artists = mutableListOf<ArtistImpl>()
|
||||
override val artists: List<Artist>
|
||||
get() = _artists
|
||||
|
@ -363,6 +366,8 @@ class ArtistImpl(
|
|||
rawArtist == other.rawArtist &&
|
||||
songs == other.songs
|
||||
|
||||
override fun toString() = "Artist(uid=$uid, name=$name)"
|
||||
|
||||
override lateinit var genres: List<Genre>
|
||||
|
||||
init {
|
||||
|
@ -449,6 +454,8 @@ class GenreImpl(
|
|||
override fun equals(other: Any?) =
|
||||
other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs
|
||||
|
||||
override fun toString() = "Genre(uid=$uid, name=$name)"
|
||||
|
||||
init {
|
||||
val distinctAlbums = mutableSetOf<Album>()
|
||||
val distinctArtists = mutableSetOf<Artist>()
|
||||
|
|
|
@ -25,6 +25,7 @@ import org.oxycblt.auxio.databinding.ItemMusicDirBinding
|
|||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* [RecyclerView.Adapter] that manages a list of [Directory] instances.
|
||||
|
@ -54,10 +55,8 @@ class DirectoryAdapter(private val listener: Listener) :
|
|||
* @param dir The [Directory] to add.
|
||||
*/
|
||||
fun add(dir: Directory) {
|
||||
if (_dirs.contains(dir)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (_dirs.contains(dir)) return
|
||||
logD("Adding $dir")
|
||||
_dirs.add(dir)
|
||||
notifyItemInserted(_dirs.lastIndex)
|
||||
}
|
||||
|
@ -65,9 +64,10 @@ class DirectoryAdapter(private val listener: Listener) :
|
|||
/**
|
||||
* Add a list of [Directory] instances to the end of the list.
|
||||
*
|
||||
* @param dirs The [Directory instances to add.
|
||||
* @param dirs The [Directory] instances to add.
|
||||
*/
|
||||
fun addAll(dirs: List<Directory>) {
|
||||
logD("Adding ${dirs.size} directories")
|
||||
val oldLastIndex = dirs.lastIndex
|
||||
_dirs.addAll(dirs)
|
||||
notifyItemRangeInserted(oldLastIndex, dirs.size)
|
||||
|
@ -79,6 +79,7 @@ class DirectoryAdapter(private val listener: Listener) :
|
|||
* @param dir The [Directory] to remove. Must exist in the list.
|
||||
*/
|
||||
fun remove(dir: Directory) {
|
||||
logD("Removing $dir")
|
||||
val idx = _dirs.indexOf(dir)
|
||||
_dirs.removeAt(idx)
|
||||
notifyItemRemoved(idx)
|
||||
|
@ -86,6 +87,7 @@ class DirectoryAdapter(private val listener: Listener) :
|
|||
|
||||
/** A Listener for [DirectoryAdapter] interactions. */
|
||||
interface Listener {
|
||||
/** Called when the delete button on a directory item is clicked. */
|
||||
fun onRemoveDirectory(dir: Directory)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -145,18 +145,10 @@ data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolea
|
|||
* @param fromFormat The mime type obtained by analyzing the file format. Null if could not be
|
||||
* obtained.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Get around to simplifying this
|
||||
*/
|
||||
data class MimeType(val fromExtension: String, val fromFormat: String?) {
|
||||
|
||||
/**
|
||||
* Return a mime-type such as "audio/ogg"
|
||||
*
|
||||
* @return A raw mime-type string. Will first try [fromFormat], then falling back to
|
||||
* [fromExtension], and then null if that fails.
|
||||
*/
|
||||
val raw: String
|
||||
get() = fromFormat ?: fromExtension
|
||||
|
||||
/**
|
||||
* Resolve the mime type into a human-readable format name, such as "Ogg Vorbis".
|
||||
*
|
||||
|
|
|
@ -120,6 +120,7 @@ private abstract class BaseMediaStoreExtractor(
|
|||
if (dirs.dirs.isNotEmpty()) {
|
||||
selector += " AND "
|
||||
if (!dirs.shouldInclude) {
|
||||
logD("Excluding directories in selector")
|
||||
// Without a NOT, the query will be restricted to the specified paths, resulting
|
||||
// in the "Include" mode. With a NOT, the specified paths will not be included,
|
||||
// resulting in the "Exclude" mode.
|
||||
|
@ -144,14 +145,14 @@ private abstract class BaseMediaStoreExtractor(
|
|||
}
|
||||
|
||||
// Now we can actually query MediaStore.
|
||||
logD("Starting song query [proj: ${projection.toList()}, selector: $selector, args: $args]")
|
||||
logD("Starting song query [proj=${projection.toList()}, selector=$selector, args=$args]")
|
||||
val cursor =
|
||||
context.contentResolverSafe.safeQuery(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
selector,
|
||||
args.toTypedArray())
|
||||
logD("Song query succeeded [Projected total: ${cursor.count}]")
|
||||
logD("Successfully queried for ${cursor.count} songs")
|
||||
|
||||
val genreNamesMap = mutableMapOf<Long, String>()
|
||||
|
||||
|
@ -185,6 +186,7 @@ private abstract class BaseMediaStoreExtractor(
|
|||
}
|
||||
}
|
||||
}
|
||||
logD("Read ${genreNamesMap.size} genres from MediaStore")
|
||||
|
||||
logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
|
||||
return wrapQuery(cursor, genreNamesMap)
|
||||
|
|
|
@ -24,6 +24,7 @@ import java.text.SimpleDateFormat
|
|||
import kotlin.math.max
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.inRangeOrNull
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
|
||||
/**
|
||||
|
@ -51,27 +52,25 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
|||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
fun resolve(context: Context) =
|
||||
// Unable to create fine-grained date, just format as a year.
|
||||
return context.getString(R.string.fmt_number, year)
|
||||
month?.let { resolveFineGrained() } ?: context.getString(R.string.fmt_number, year)
|
||||
|
||||
private fun resolveFineGrained(): String? {
|
||||
// We can't directly load a date with our own
|
||||
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
|
||||
format.applyPattern("yyyy-MM")
|
||||
val date =
|
||||
try {
|
||||
format.parse("$year-$month")
|
||||
} catch (e: ParseException) {
|
||||
logE("Unable to parse fine-grained date: $e")
|
||||
return null
|
||||
}
|
||||
|
||||
// Reformat as a readable month and year
|
||||
format.applyPattern("MMM yyyy")
|
||||
return format.format(date)
|
||||
}
|
||||
|
||||
override fun hashCode() = tokens.hashCode()
|
||||
|
@ -139,9 +138,9 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
|||
fun resolveDate(context: Context) =
|
||||
if (min != max) {
|
||||
context.getString(
|
||||
R.string.fmt_date_range, min.resolveDate(context), max.resolveDate(context))
|
||||
R.string.fmt_date_range, min.resolve(context), max.resolve(context))
|
||||
} else {
|
||||
min.resolveDate(context)
|
||||
min.resolve(context)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?) = other is Range && min == other.min && max == other.max
|
||||
|
|
|
@ -27,6 +27,7 @@ import org.oxycblt.auxio.list.Item
|
|||
* @param name The name of the disc group, if any. Null if not present.
|
||||
*/
|
||||
class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
|
||||
// We don't want to group discs by differing subtitles, so only compare by the number
|
||||
override fun equals(other: Any?) = other is Disc && number == other.number
|
||||
override fun hashCode() = number.hashCode()
|
||||
override fun compareTo(other: Disc) = number.compareTo(other.number)
|
||||
|
|
|
@ -201,6 +201,7 @@ private data class IntelligentKnownName(override val raw: String, override val s
|
|||
// Separate each token into their numeric and lexicographic counterparts.
|
||||
if (token.first().isDigit()) {
|
||||
// The digit string comparison breaks with preceding zero digits, remove those
|
||||
// TODO: Handle zero digits in other languages
|
||||
val digits = token.trimStart('0').ifEmpty { token }
|
||||
// Other languages have other types of digit strings, still use collation keys
|
||||
collationKey = COLLATOR.getCollationKey(digits)
|
||||
|
|
|
@ -104,25 +104,23 @@ constructor(@ApplicationContext private val context: Context) : AudioProperties.
|
|||
null
|
||||
}
|
||||
|
||||
val resolvedMimeType =
|
||||
if (song.mimeType.fromFormat != null) {
|
||||
// ExoPlayer was already able to populate the format.
|
||||
song.mimeType
|
||||
} else {
|
||||
// ExoPlayer couldn't populate the format somehow, populate it here.
|
||||
val formatMimeType =
|
||||
try {
|
||||
format.getString(MediaFormat.KEY_MIME)
|
||||
} catch (e: NullPointerException) {
|
||||
logE("Unable to extract mime type field")
|
||||
null
|
||||
}
|
||||
|
||||
MimeType(song.mimeType.fromExtension, formatMimeType)
|
||||
// The song's mime type won't have a populated format field right now, try to
|
||||
// extract it ourselves.
|
||||
val formatMimeType =
|
||||
try {
|
||||
format.getString(MediaFormat.KEY_MIME)
|
||||
} catch (e: NullPointerException) {
|
||||
logE("Unable to extract mime type field")
|
||||
null
|
||||
}
|
||||
|
||||
extractor.release()
|
||||
|
||||
return AudioProperties(bitrate, sampleRate, resolvedMimeType)
|
||||
logD("Finished extracting audio properties")
|
||||
|
||||
return AudioProperties(
|
||||
bitrate,
|
||||
sampleRate,
|
||||
MimeType(fromExtension = song.mimeType.fromExtension, fromFormat = formatMimeType))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,12 +30,15 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to
|
||||
* split tags with multiple values.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Replace with unsplit names dialog
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
||||
|
@ -74,7 +77,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
|||
Separators.SLASH -> binding.separatorSlash.isChecked = true
|
||||
Separators.PLUS -> binding.separatorPlus.isChecked = true
|
||||
Separators.AND -> binding.separatorAnd.isChecked = true
|
||||
else -> error("Unexpected separator in settings data")
|
||||
else -> logW("Unexpected separator in settings data")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.music.device.RawSong
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
|
||||
|
@ -52,6 +53,8 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork
|
|||
// producing similar throughput's to other kinds of manual metadata extraction.
|
||||
val tagWorkerPool: Array<TagWorker?> = arrayOfNulls(TASK_CAPACITY)
|
||||
|
||||
logD("Beginning primary extraction loop")
|
||||
|
||||
for (incompleteRawSong in incompleteSongs) {
|
||||
spin@ while (true) {
|
||||
for (i in tagWorkerPool.indices) {
|
||||
|
@ -71,6 +74,8 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork
|
|||
}
|
||||
}
|
||||
|
||||
logD("All incomplete songs exhausted, starting cleanup loop")
|
||||
|
||||
do {
|
||||
var ongoingTasks = false
|
||||
for (i in tagWorkerPool.indices) {
|
||||
|
|
|
@ -89,12 +89,8 @@ private class TagWorkerImpl(
|
|||
} catch (e: Exception) {
|
||||
logW("Unable to extract metadata for ${rawSong.name}")
|
||||
logW(e.stackTraceToString())
|
||||
null
|
||||
return rawSong
|
||||
}
|
||||
if (format == null) {
|
||||
logD("Nothing could be extracted for ${rawSong.name}")
|
||||
return rawSong
|
||||
}
|
||||
|
||||
val metadata = format.metadata
|
||||
if (metadata != null) {
|
||||
|
|
|
@ -35,6 +35,7 @@ import org.oxycblt.auxio.music.MusicViewModel
|
|||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
/**
|
||||
|
@ -93,7 +94,7 @@ class AddToPlaylistDialog :
|
|||
|
||||
private fun updatePendingSongs(songs: List<Song>?) {
|
||||
if (songs == null) {
|
||||
// No songs to feasibly add to a playlist, leave.
|
||||
logD("No songs to show choices for, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel
|
|||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
|
@ -76,7 +77,7 @@ class DeletePlaylistDialog : ViewBindingDialogFragment<DialogDeletePlaylistBindi
|
|||
|
||||
private fun updatePlaylistToDelete(playlist: Playlist?) {
|
||||
if (playlist == null) {
|
||||
// Playlist does not exist anymore, leave
|
||||
logD("No playlist to delete, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
|
|||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
|
@ -89,6 +90,7 @@ class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>()
|
|||
|
||||
private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) {
|
||||
if (pendingPlaylist == null) {
|
||||
logD("No playlist to create, leaving")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
|
|
@ -31,6 +31,9 @@ import org.oxycblt.auxio.music.Music
|
|||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A [ViewModel] managing the state of the playlist picker dialogs.
|
||||
|
@ -84,6 +87,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
pendingPlaylist.preferredName,
|
||||
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) })
|
||||
}
|
||||
logD("Updated pending playlist: ${_currentPendingPlaylist.value?.preferredName}")
|
||||
|
||||
_currentSongsToAdd.value =
|
||||
_currentSongsToAdd.value?.let { pendingSongs ->
|
||||
pendingSongs
|
||||
|
@ -91,6 +96,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
.ifEmpty { null }
|
||||
.also { refreshChoicesWith = it }
|
||||
}
|
||||
logD("Updated songs to add: ${_currentSongsToAdd.value?.size} songs")
|
||||
}
|
||||
|
||||
val chosenName = _chosenName.value
|
||||
|
@ -102,6 +108,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
// Nothing to do.
|
||||
}
|
||||
}
|
||||
logD("Updated chosen name to $chosenName")
|
||||
refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value
|
||||
}
|
||||
|
||||
|
@ -119,19 +126,34 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
* @param songUids The [Music.UID]s of songs to be present in the playlist.
|
||||
*/
|
||||
fun setPendingPlaylist(context: Context, songUids: Array<Music.UID>) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val songs = songUids.mapNotNull(deviceLibrary::findSong)
|
||||
|
||||
logD("Opening ${songUids.size} songs to create a playlist from")
|
||||
val userLibrary = musicRepository.userLibrary ?: return
|
||||
var i = 1
|
||||
while (true) {
|
||||
val possibleName = context.getString(R.string.fmt_def_playlist, i)
|
||||
if (userLibrary.playlists.none { it.name.resolve(context) == possibleName }) {
|
||||
_currentPendingPlaylist.value = PendingPlaylist(possibleName, songs)
|
||||
return
|
||||
val songs =
|
||||
musicRepository.deviceLibrary
|
||||
?.let { songUids.mapNotNull(it::findSong) }
|
||||
?.also(::refreshPlaylistChoices)
|
||||
|
||||
val possibleName =
|
||||
musicRepository.userLibrary?.let {
|
||||
// Attempt to generate a unique default name for the playlist, like "Playlist 1".
|
||||
var i = 1
|
||||
var possibleName: String
|
||||
do {
|
||||
possibleName = context.getString(R.string.fmt_def_playlist, i)
|
||||
logD("Trying $possibleName as a playlist name")
|
||||
++i
|
||||
} while (userLibrary.playlists.any { it.name.resolve(context) == possibleName })
|
||||
logD("$possibleName is unique, using it as the playlist name")
|
||||
possibleName
|
||||
}
|
||||
|
||||
_currentPendingPlaylist.value =
|
||||
if (possibleName != null && songs != null) {
|
||||
PendingPlaylist(possibleName, songs)
|
||||
} else {
|
||||
logW("Given song UIDs to create were invalid")
|
||||
null
|
||||
}
|
||||
++i
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -140,7 +162,11 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
* @param playlistUid The [Music.UID]s of the [Playlist] to rename.
|
||||
*/
|
||||
fun setPlaylistToRename(playlistUid: Music.UID) {
|
||||
logD("Opening playlist $playlistUid to rename")
|
||||
_currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
||||
if (_currentPlaylistToDelete.value == null) {
|
||||
logW("Given playlist UID to rename was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -149,7 +175,11 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
* @param playlistUid The [Music.UID] of the [Playlist] to delete.
|
||||
*/
|
||||
fun setPlaylistToDelete(playlistUid: Music.UID) {
|
||||
logD("Opening playlist $playlistUid to delete")
|
||||
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
||||
if (_currentPlaylistToDelete.value == null) {
|
||||
logW("Given playlist UID to delete was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -158,16 +188,25 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
* @param name The new user-inputted name, or null if not present.
|
||||
*/
|
||||
fun updateChosenName(name: String?) {
|
||||
logD("Updating chosen name to $name")
|
||||
_chosenName.value =
|
||||
when {
|
||||
name.isNullOrEmpty() -> ChosenName.Empty
|
||||
name.isBlank() -> ChosenName.Blank
|
||||
name.isNullOrEmpty() -> {
|
||||
logE("Chosen name is empty")
|
||||
ChosenName.Empty
|
||||
}
|
||||
name.isBlank() -> {
|
||||
logE("Chosen name is blank")
|
||||
ChosenName.Blank
|
||||
}
|
||||
else -> {
|
||||
val trimmed = name.trim()
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
|
||||
logD("Chosen name is valid")
|
||||
ChosenName.Valid(trimmed)
|
||||
} else {
|
||||
logD("Chosen name already exists in library")
|
||||
ChosenName.AlreadyExists(trimmed)
|
||||
}
|
||||
}
|
||||
|
@ -180,14 +219,19 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
* @param songUids The [Music.UID]s of songs to add to a playlist.
|
||||
*/
|
||||
fun setSongsToAdd(songUids: Array<Music.UID>) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val songs = songUids.mapNotNull(deviceLibrary::findSong)
|
||||
_currentSongsToAdd.value = songs
|
||||
refreshPlaylistChoices(songs)
|
||||
logD("Opening ${songUids.size} songs to add to a playlist")
|
||||
_currentSongsToAdd.value =
|
||||
musicRepository.deviceLibrary
|
||||
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
|
||||
?.also(::refreshPlaylistChoices)
|
||||
if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) {
|
||||
logW("Given song UIDs to add were (partially) invalid")
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshPlaylistChoices(songs: List<Song>) {
|
||||
val userLibrary = musicRepository.userLibrary ?: return
|
||||
logD("Refreshing playlist choices")
|
||||
_playlistAddChoices.value =
|
||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
|
||||
val songSet = it.songs.toSet()
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.MusicViewModel
|
|||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
|
@ -86,7 +87,9 @@ class RenamePlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding
|
|||
}
|
||||
|
||||
if (!initializedField) {
|
||||
requireBinding().playlistName.setText(playlist.name.resolve(requireContext()))
|
||||
val default = playlist.name.resolve(requireContext())
|
||||
logD("Name input is not initialized, setting to $default")
|
||||
requireBinding().playlistName.setText(default)
|
||||
initializedField = true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -124,6 +124,7 @@ class IndexerService :
|
|||
// --- CONTROLLER CALLBACKS ---
|
||||
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
logD("Starting new indexing job")
|
||||
// Cancel the previous music loading job.
|
||||
currentIndexJob?.cancel()
|
||||
// Start a new music loading job on a co-routine.
|
||||
|
@ -137,6 +138,7 @@ class IndexerService :
|
|||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
logD("Music changed, updating shared objects")
|
||||
// Wipe possibly-invalidated outdated covers
|
||||
imageLoader.memoryCache?.clear()
|
||||
// Clear invalid models from PlaybackStateManager. This is not connected
|
||||
|
@ -192,11 +194,14 @@ class IndexerService :
|
|||
// and thus the music library will not be updated at all.
|
||||
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
|
||||
// this anymore, or at least I only have to use it when the app task is not removed.
|
||||
logD("Need to observe, staying in foreground")
|
||||
if (!foregroundManager.tryStartForeground(observingNotification)) {
|
||||
logD("Notification changed, re-posting notification")
|
||||
observingNotification.post()
|
||||
}
|
||||
} else {
|
||||
// Not observing and done loading, exit foreground.
|
||||
logD("Exiting foreground")
|
||||
foregroundManager.tryStopForeground()
|
||||
}
|
||||
// Release our wake lock (if we were using it)
|
||||
|
@ -237,6 +242,7 @@ class IndexerService :
|
|||
// setting changed. In such a case, the state will still be updated when
|
||||
// the music loading process ends.
|
||||
if (currentIndexJob == null) {
|
||||
logD("Not loading, updating idle session")
|
||||
updateIdleSession()
|
||||
}
|
||||
}
|
||||
|
@ -274,6 +280,7 @@ class IndexerService :
|
|||
// Check here if we should even start a reindex. This is much less bug-prone than
|
||||
// registering and de-registering this component as this setting changes.
|
||||
if (musicSettings.shouldBeObserving) {
|
||||
logD("MediaStore changed, starting re-index")
|
||||
requestIndex(true)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -67,6 +67,8 @@ private constructor(
|
|||
return hashCode
|
||||
}
|
||||
|
||||
override fun toString() = "Playlist(uid=$uid, name=$name)"
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance with a novel UID.
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
package org.oxycblt.auxio.music.user
|
||||
|
||||
import java.lang.Exception
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import org.oxycblt.auxio.music.Music
|
||||
|
@ -26,6 +27,8 @@ import org.oxycblt.auxio.music.MusicSettings
|
|||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
/**
|
||||
* Organized library information controlled by the user.
|
||||
|
@ -122,7 +125,14 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus
|
|||
UserLibrary.Factory {
|
||||
override suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): MutableUserLibrary {
|
||||
// While were waiting for the library, read our playlists out.
|
||||
val rawPlaylists = playlistDao.readRawPlaylists()
|
||||
val rawPlaylists =
|
||||
try {
|
||||
playlistDao.readRawPlaylists()
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to read playlists: $e")
|
||||
return UserLibraryImpl(playlistDao, mutableMapOf(), musicSettings)
|
||||
}
|
||||
logD("Successfully read ${rawPlaylists.size} playlists")
|
||||
val deviceLibrary = deviceLibraryChannel.receive()
|
||||
// Convert the database playlist information to actual usable playlists.
|
||||
val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
|
||||
|
@ -139,6 +149,8 @@ private class UserLibraryImpl(
|
|||
private val playlistMap: MutableMap<Music.UID, PlaylistImpl>,
|
||||
private val musicSettings: MusicSettings
|
||||
) : MutableUserLibrary {
|
||||
override fun toString() = "UserLibrary(playlists=${playlists.size})"
|
||||
|
||||
override val playlists: List<Playlist>
|
||||
get() = playlistMap.values.toList()
|
||||
|
||||
|
@ -153,34 +165,74 @@ private class UserLibraryImpl(
|
|||
RawPlaylist(
|
||||
PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw),
|
||||
playlistImpl.songs.map { PlaylistSong(it.uid) })
|
||||
playlistDao.insertPlaylist(rawPlaylist)
|
||||
try {
|
||||
playlistDao.insertPlaylist(rawPlaylist)
|
||||
logD("Successfully created playlist $name with ${songs.size} songs")
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to create playlist $name with ${songs.size} songs")
|
||||
logE(e.stackTraceToString())
|
||||
synchronized(this) { playlistMap.remove(playlistImpl.uid) }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
||||
val playlistImpl =
|
||||
requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" }
|
||||
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) }
|
||||
playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name))
|
||||
try {
|
||||
playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name))
|
||||
logD("Successfully renamed $playlist to $name")
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to rename $playlist to $name: $e")
|
||||
logE(e.stackTraceToString())
|
||||
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deletePlaylist(playlist: Playlist) {
|
||||
synchronized(this) {
|
||||
requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" }
|
||||
val playlistImpl =
|
||||
requireNotNull(playlistMap[playlist.uid]) { "Cannot remove invalid playlist" }
|
||||
synchronized(this) { playlistMap.remove(playlistImpl.uid) }
|
||||
try {
|
||||
playlistDao.deletePlaylist(playlist.uid)
|
||||
logD("Successfully deleted $playlist")
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to delete $playlist: $e")
|
||||
logE(e.stackTraceToString())
|
||||
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
|
||||
return
|
||||
}
|
||||
playlistDao.deletePlaylist(playlist.uid)
|
||||
}
|
||||
|
||||
override suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
|
||||
val playlistImpl =
|
||||
requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" }
|
||||
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } }
|
||||
playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
|
||||
try {
|
||||
playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
|
||||
logD("Successfully added ${songs.size} songs to $playlist")
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to add ${songs.size} songs to $playlist: $e")
|
||||
logE(e.stackTraceToString())
|
||||
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
||||
val playlistImpl =
|
||||
requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" }
|
||||
synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(songs) }
|
||||
playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
|
||||
try {
|
||||
playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) })
|
||||
logD("Successfully rewrote $playlist with ${songs.size} songs")
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to rewrite $playlist with ${songs.size} songs: $e")
|
||||
logE(e.stackTraceToString())
|
||||
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.logD
|
|||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: This whole system is very jankily designed, perhaps it's time for a refactor?
|
||||
* TODO: Unwind this into ViewModel-specific actions, and then reference those.
|
||||
*/
|
||||
class NavigationViewModel : ViewModel() {
|
||||
private val _mainNavigationAction = MutableEvent<MainNavigationAction>()
|
||||
|
@ -96,6 +96,7 @@ class NavigationViewModel : ViewModel() {
|
|||
* dialog will be shown.
|
||||
*/
|
||||
fun exploreNavigateToParentArtist(song: Song) {
|
||||
logD("Navigating to parent artist of $song")
|
||||
exploreNavigateToParentArtistImpl(song, song.artists)
|
||||
}
|
||||
|
||||
|
@ -106,6 +107,7 @@ class NavigationViewModel : ViewModel() {
|
|||
* dialog will be shown.
|
||||
*/
|
||||
fun exploreNavigateToParentArtist(album: Album) {
|
||||
logD("Navigating to parent artist of $album")
|
||||
exploreNavigateToParentArtistImpl(album, album.artists)
|
||||
}
|
||||
|
||||
|
|
|
@ -78,7 +78,7 @@ class NavigateToArtistDialog :
|
|||
|
||||
override fun onDestroyBinding(binding: DialogMusicChoicesBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
choiceAdapter
|
||||
binding.choiceRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
|
||||
|
|
|
@ -28,6 +28,7 @@ import org.oxycblt.auxio.music.Artist
|
|||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewModel] that stores the current information required for navigation picker dialogs
|
||||
|
@ -62,6 +63,7 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
|
|||
}
|
||||
else -> null
|
||||
}
|
||||
logD("Updated artist choices: ${_artistChoices.value}")
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
@ -75,12 +77,22 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
|
|||
* @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
|
||||
*/
|
||||
fun setArtistChoiceUid(itemUid: Music.UID) {
|
||||
logD("Opening navigation choices for $itemUid")
|
||||
// Support Songs and Albums, which have parent artists.
|
||||
_artistChoices.value =
|
||||
when (val music = musicRepository.find(itemUid)) {
|
||||
is Song -> SongArtistNavigationChoices(music)
|
||||
is Album -> AlbumArtistNavigationChoices(music)
|
||||
else -> null
|
||||
is Song -> {
|
||||
logD("Creating navigation choices for song")
|
||||
SongArtistNavigationChoices(music)
|
||||
}
|
||||
is Album -> {
|
||||
logD("Creating navigation choices for album")
|
||||
AlbumArtistNavigationChoices(music)
|
||||
}
|
||||
else -> {
|
||||
logD("Given song/album UID was invalid")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,6 +34,7 @@ import org.oxycblt.auxio.ui.ViewBindingFragment
|
|||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getColorCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewBindingFragment] that shows the current playback state in a compact manner.
|
||||
|
@ -93,6 +94,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) {
|
||||
when (actionMode) {
|
||||
ActionMode.NEXT -> {
|
||||
logD("Setting up skip next action")
|
||||
binding.playbackSecondaryAction.apply {
|
||||
setIconResource(R.drawable.ic_skip_next_24)
|
||||
contentDescription = getString(R.string.desc_skip_next)
|
||||
|
@ -101,6 +103,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
}
|
||||
}
|
||||
ActionMode.REPEAT -> {
|
||||
logD("Setting up repeat mode action")
|
||||
binding.playbackSecondaryAction.apply {
|
||||
contentDescription = getString(R.string.desc_change_repeat)
|
||||
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
|
||||
|
@ -109,6 +112,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
}
|
||||
}
|
||||
ActionMode.SHUFFLE -> {
|
||||
logD("Setting up shuffle action")
|
||||
binding.playbackSecondaryAction.apply {
|
||||
setIconResource(R.drawable.sel_shuffle_state_24)
|
||||
contentDescription = getString(R.string.desc_shuffle)
|
||||
|
@ -121,14 +125,17 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
if (song != null) {
|
||||
val context = requireContext()
|
||||
val binding = requireBinding()
|
||||
binding.playbackCover.bind(song)
|
||||
binding.playbackSong.text = song.name.resolve(context)
|
||||
binding.playbackInfo.text = song.artists.resolveNames(context)
|
||||
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
|
||||
if (song == null) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
val context = requireContext()
|
||||
val binding = requireBinding()
|
||||
binding.playbackCover.bind(song)
|
||||
binding.playbackSong.text = song.name.resolve(context)
|
||||
binding.playbackInfo.text = song.artists.resolveNames(context)
|
||||
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
|
||||
}
|
||||
|
||||
private fun updatePlaying(isPlaying: Boolean) {
|
||||
|
|
|
@ -43,6 +43,7 @@ import org.oxycblt.auxio.playback.state.RepeatMode
|
|||
import org.oxycblt.auxio.playback.ui.StyledSeekBar
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.share
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
@ -142,6 +143,7 @@ class PlaybackPanelFragment :
|
|||
when (item.itemId) {
|
||||
R.id.action_open_equalizer -> {
|
||||
// Launch the system equalizer app, if possible.
|
||||
logD("Launching equalizer")
|
||||
val equalizerIntent =
|
||||
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
|
||||
// Provide audio session ID so the equalizer can show options for this app
|
||||
|
@ -200,6 +202,7 @@ class PlaybackPanelFragment :
|
|||
|
||||
val binding = requireBinding()
|
||||
val context = requireContext()
|
||||
logD("Updating song display: $song")
|
||||
binding.playbackCover.bind(song)
|
||||
binding.playbackSong.text = song.name.resolve(context)
|
||||
binding.playbackArtist.text = song.artists.resolveNames(context)
|
||||
|
|
|
@ -198,8 +198,14 @@ class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Cont
|
|||
when (key) {
|
||||
getString(R.string.set_key_replay_gain),
|
||||
getString(R.string.set_key_pre_amp_with),
|
||||
getString(R.string.set_key_pre_amp_without) -> listener.onReplayGainSettingsChanged()
|
||||
getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged()
|
||||
getString(R.string.set_key_pre_amp_without) -> {
|
||||
logD("Dispatching ReplayGain setting change")
|
||||
listener.onReplayGainSettingsChanged()
|
||||
}
|
||||
getString(R.string.set_key_notif_action) -> {
|
||||
logD("Dispatching notification setting change")
|
||||
listener.onNotificationActionChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -43,6 +43,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
|||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* An [ViewModel] that provides a safe UI frontend for the current playback state.
|
||||
|
@ -124,27 +125,32 @@ constructor(
|
|||
}
|
||||
|
||||
override fun onIndexMoved(queue: Queue) {
|
||||
logD("Index moved, updating current song")
|
||||
_song.value = queue.currentSong
|
||||
}
|
||||
|
||||
override fun onQueueChanged(queue: Queue, change: Queue.Change) {
|
||||
// Other types of queue changes preserve the current song.
|
||||
if (change.type == Queue.Change.Type.SONG) {
|
||||
logD("Queue changed, updating current song")
|
||||
_song.value = queue.currentSong
|
||||
}
|
||||
}
|
||||
|
||||
override fun onQueueReordered(queue: Queue) {
|
||||
logD("Queue completely changed, updating current song")
|
||||
_isShuffled.value = queue.isShuffled
|
||||
}
|
||||
|
||||
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
|
||||
logD("New playback started, updating playback information")
|
||||
_song.value = queue.currentSong
|
||||
_parent.value = parent
|
||||
_isShuffled.value = queue.isShuffled
|
||||
}
|
||||
|
||||
override fun onStateChanged(state: InternalPlayer.State) {
|
||||
logD("Player state changed, starting new position polling")
|
||||
_isPlaying.value = state.isPlaying
|
||||
// Still need to update the position now due to co-routine launch delays
|
||||
_positionDs.value = state.calculateElapsedPositionMs().msToDs()
|
||||
|
@ -169,6 +175,7 @@ constructor(
|
|||
|
||||
/** Shuffle all songs in the music library. */
|
||||
fun shuffleAll() {
|
||||
logD("Shuffling all songs")
|
||||
playImpl(null, null, true)
|
||||
}
|
||||
|
||||
|
@ -184,6 +191,7 @@ constructor(
|
|||
* @param playbackMode The [MusicMode] to play from.
|
||||
*/
|
||||
fun playFrom(song: Song, playbackMode: MusicMode) {
|
||||
logD("Playing $song from $playbackMode")
|
||||
when (playbackMode) {
|
||||
MusicMode.SONGS -> playImpl(song, null)
|
||||
MusicMode.ALBUMS -> playImpl(song, song.album)
|
||||
|
@ -202,10 +210,13 @@ constructor(
|
|||
*/
|
||||
fun playFromArtist(song: Song, artist: Artist? = null) {
|
||||
if (artist != null) {
|
||||
logD("Playing $song from $artist")
|
||||
playImpl(song, artist)
|
||||
} else if (song.artists.size == 1) {
|
||||
logD("$song has one artist, playing from it")
|
||||
playImpl(song, song.artists[0])
|
||||
} else {
|
||||
logD("$song has multiple artists, showing choice dialog")
|
||||
_artistPlaybackPickerSong.put(song)
|
||||
}
|
||||
}
|
||||
|
@ -219,10 +230,13 @@ constructor(
|
|||
*/
|
||||
fun playFromGenre(song: Song, genre: Genre? = null) {
|
||||
if (genre != null) {
|
||||
logD("Playing $song from $genre")
|
||||
playImpl(song, genre)
|
||||
} else if (song.genres.size == 1) {
|
||||
logD("$song has one genre, playing from it")
|
||||
playImpl(song, song.genres[0])
|
||||
} else {
|
||||
logD("$song has multiple genres, showing choice dialog")
|
||||
_genrePlaybackPickerSong.put(song)
|
||||
}
|
||||
}
|
||||
|
@ -234,6 +248,7 @@ constructor(
|
|||
* @param playlist The [Playlist] to play from. Must be linked to the [Song].
|
||||
*/
|
||||
fun playFromPlaylist(song: Song, playlist: Playlist) {
|
||||
logD("Playing $song from $playlist")
|
||||
playImpl(song, playlist)
|
||||
}
|
||||
|
||||
|
@ -242,70 +257,100 @@ constructor(
|
|||
*
|
||||
* @param album The [Album] to play.
|
||||
*/
|
||||
fun play(album: Album) = playImpl(null, album, false)
|
||||
fun play(album: Album) {
|
||||
logD("Playing $album")
|
||||
playImpl(null, album, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play an [Artist].
|
||||
*
|
||||
* @param artist The [Artist] to play.
|
||||
*/
|
||||
fun play(artist: Artist) = playImpl(null, artist, false)
|
||||
fun play(artist: Artist) {
|
||||
logD("Playing $artist")
|
||||
playImpl(null, artist, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a [Genre].
|
||||
*
|
||||
* @param genre The [Genre] to play.
|
||||
*/
|
||||
fun play(genre: Genre) = playImpl(null, genre, false)
|
||||
fun play(genre: Genre) {
|
||||
logD("Playing $genre")
|
||||
playImpl(null, genre, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a [Playlist].
|
||||
*
|
||||
* @param playlist The [Playlist] to play.
|
||||
*/
|
||||
fun play(playlist: Playlist) = playImpl(null, playlist, false)
|
||||
fun play(playlist: Playlist) {
|
||||
logD("Playing $playlist")
|
||||
playImpl(null, playlist, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a list of [Song]s.
|
||||
*
|
||||
* @param songs The [Song]s to play.
|
||||
*/
|
||||
fun play(songs: List<Song>) = playbackManager.play(null, null, songs, false)
|
||||
fun play(songs: List<Song>) {
|
||||
logD("Playing ${songs.size} songs")
|
||||
playbackManager.play(null, null, songs, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle an [Album].
|
||||
*
|
||||
* @param album The [Album] to shuffle.
|
||||
*/
|
||||
fun shuffle(album: Album) = playImpl(null, album, true)
|
||||
fun shuffle(album: Album) {
|
||||
logD("Shuffling $album")
|
||||
playImpl(null, album, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle an [Artist].
|
||||
*
|
||||
* @param artist The [Artist] to shuffle.
|
||||
*/
|
||||
fun shuffle(artist: Artist) = playImpl(null, artist, true)
|
||||
fun shuffle(artist: Artist) {
|
||||
logD("Shuffling $artist")
|
||||
playImpl(null, artist, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle a [Genre].
|
||||
*
|
||||
* @param genre The [Genre] to shuffle.
|
||||
*/
|
||||
fun shuffle(genre: Genre) = playImpl(null, genre, true)
|
||||
fun shuffle(genre: Genre) {
|
||||
logD("Shuffling $genre")
|
||||
playImpl(null, genre, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle a [Playlist].
|
||||
*
|
||||
* @param playlist The [Playlist] to shuffle.
|
||||
*/
|
||||
fun shuffle(playlist: Playlist) = playImpl(null, playlist, true)
|
||||
fun shuffle(playlist: Playlist) {
|
||||
logD("Shuffling $playlist")
|
||||
playImpl(null, playlist, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle a list of [Song]s.
|
||||
*
|
||||
* @param songs The [Song]s to shuffle.
|
||||
*/
|
||||
fun shuffle(songs: List<Song>) = playbackManager.play(null, null, songs, true)
|
||||
fun shuffle(songs: List<Song>) {
|
||||
logD("Shuffling ${songs.size} songs")
|
||||
playbackManager.play(null, null, songs, true)
|
||||
}
|
||||
|
||||
private fun playImpl(
|
||||
song: Song?,
|
||||
|
@ -334,6 +379,7 @@ constructor(
|
|||
* @param action The [InternalPlayer.Action] to perform eventually.
|
||||
*/
|
||||
fun startAction(action: InternalPlayer.Action) {
|
||||
logD("Starting action $action")
|
||||
playbackManager.startAction(action)
|
||||
}
|
||||
|
||||
|
@ -345,6 +391,7 @@ constructor(
|
|||
* @param positionDs The position to seek to, in deci-seconds (1/10th of a second).
|
||||
*/
|
||||
fun seekTo(positionDs: Long) {
|
||||
logD("Seeking to ${positionDs}ds")
|
||||
playbackManager.seekTo(positionDs.dsToMs())
|
||||
}
|
||||
|
||||
|
@ -352,11 +399,13 @@ constructor(
|
|||
|
||||
/** Skip to the next [Song]. */
|
||||
fun next() {
|
||||
logD("Skipping to next song")
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
/** Skip to the previous [Song]. */
|
||||
fun prev() {
|
||||
logD("Skipping to previous song")
|
||||
playbackManager.prev()
|
||||
}
|
||||
|
||||
|
@ -366,6 +415,7 @@ constructor(
|
|||
* @param song The [Song] to add.
|
||||
*/
|
||||
fun playNext(song: Song) {
|
||||
logD("Playing $song next")
|
||||
playbackManager.playNext(song)
|
||||
}
|
||||
|
||||
|
@ -375,6 +425,7 @@ constructor(
|
|||
* @param album The [Album] to add.
|
||||
*/
|
||||
fun playNext(album: Album) {
|
||||
logD("Playing $album next")
|
||||
playbackManager.playNext(musicSettings.albumSongSort.songs(album.songs))
|
||||
}
|
||||
|
||||
|
@ -384,6 +435,7 @@ constructor(
|
|||
* @param artist The [Artist] to add.
|
||||
*/
|
||||
fun playNext(artist: Artist) {
|
||||
logD("Playing $artist next")
|
||||
playbackManager.playNext(musicSettings.artistSongSort.songs(artist.songs))
|
||||
}
|
||||
|
||||
|
@ -393,6 +445,7 @@ constructor(
|
|||
* @param genre The [Genre] to add.
|
||||
*/
|
||||
fun playNext(genre: Genre) {
|
||||
logD("Playing $genre next")
|
||||
playbackManager.playNext(musicSettings.genreSongSort.songs(genre.songs))
|
||||
}
|
||||
|
||||
|
@ -402,6 +455,7 @@ constructor(
|
|||
* @param playlist The [Playlist] to add.
|
||||
*/
|
||||
fun playNext(playlist: Playlist) {
|
||||
logD("Playing $playlist next")
|
||||
playbackManager.playNext(playlist.songs)
|
||||
}
|
||||
|
||||
|
@ -411,6 +465,7 @@ constructor(
|
|||
* @param songs The [Song]s to add.
|
||||
*/
|
||||
fun playNext(songs: List<Song>) {
|
||||
logD("Playing ${songs.size} songs next")
|
||||
playbackManager.playNext(songs)
|
||||
}
|
||||
|
||||
|
@ -420,6 +475,7 @@ constructor(
|
|||
* @param song The [Song] to add.
|
||||
*/
|
||||
fun addToQueue(song: Song) {
|
||||
logD("Adding $song to queue")
|
||||
playbackManager.addToQueue(song)
|
||||
}
|
||||
|
||||
|
@ -429,6 +485,7 @@ constructor(
|
|||
* @param album The [Album] to add.
|
||||
*/
|
||||
fun addToQueue(album: Album) {
|
||||
logD("Adding $album to queue")
|
||||
playbackManager.addToQueue(musicSettings.albumSongSort.songs(album.songs))
|
||||
}
|
||||
|
||||
|
@ -438,6 +495,7 @@ constructor(
|
|||
* @param artist The [Artist] to add.
|
||||
*/
|
||||
fun addToQueue(artist: Artist) {
|
||||
logD("Adding $artist to queue")
|
||||
playbackManager.addToQueue(musicSettings.artistSongSort.songs(artist.songs))
|
||||
}
|
||||
|
||||
|
@ -447,6 +505,7 @@ constructor(
|
|||
* @param genre The [Genre] to add.
|
||||
*/
|
||||
fun addToQueue(genre: Genre) {
|
||||
logD("Adding $genre to queue")
|
||||
playbackManager.addToQueue(musicSettings.genreSongSort.songs(genre.songs))
|
||||
}
|
||||
|
||||
|
@ -456,6 +515,7 @@ constructor(
|
|||
* @param playlist The [Playlist] to add.
|
||||
*/
|
||||
fun addToQueue(playlist: Playlist) {
|
||||
logD("Adding $playlist to queue")
|
||||
playbackManager.addToQueue(playlist.songs)
|
||||
}
|
||||
|
||||
|
@ -465,6 +525,7 @@ constructor(
|
|||
* @param songs The [Song]s to add.
|
||||
*/
|
||||
fun addToQueue(songs: List<Song>) {
|
||||
logD("Adding ${songs.size} songs to queue")
|
||||
playbackManager.addToQueue(songs)
|
||||
}
|
||||
|
||||
|
@ -472,11 +533,13 @@ constructor(
|
|||
|
||||
/** Toggle [isPlaying] (i.e from playing to paused) */
|
||||
fun togglePlaying() {
|
||||
logD("Toggling playing state")
|
||||
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
|
||||
}
|
||||
|
||||
/** Toggle [isShuffled] (ex. from on to off) */
|
||||
fun toggleShuffled() {
|
||||
logD("Toggling shuffled state")
|
||||
playbackManager.reorder(!playbackManager.queue.isShuffled)
|
||||
}
|
||||
|
||||
|
@ -486,6 +549,7 @@ constructor(
|
|||
* @see RepeatMode.increment
|
||||
*/
|
||||
fun toggleRepeatMode() {
|
||||
logD("Toggling repeat mode")
|
||||
playbackManager.repeatMode = playbackManager.repeatMode.increment()
|
||||
}
|
||||
|
||||
|
@ -497,6 +561,7 @@ constructor(
|
|||
* @param onDone Called when the save is completed with true if successful, and false otherwise.
|
||||
*/
|
||||
fun savePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
logD("Saving playback state")
|
||||
viewModelScope.launch {
|
||||
onDone(persistenceRepository.saveState(playbackManager.toSavedState()))
|
||||
}
|
||||
|
@ -508,6 +573,7 @@ constructor(
|
|||
* @param onDone Called when the wipe is completed with true if successful, and false otherwise.
|
||||
*/
|
||||
fun wipePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
logD("Wiping playback state")
|
||||
viewModelScope.launch { onDone(persistenceRepository.saveState(null)) }
|
||||
}
|
||||
|
||||
|
@ -518,6 +584,7 @@ constructor(
|
|||
* otherwise.
|
||||
*/
|
||||
fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
logD("Force-restoring playback state")
|
||||
viewModelScope.launch {
|
||||
val savedState = persistenceRepository.readState()
|
||||
if (savedState != null) {
|
||||
|
|
|
@ -61,7 +61,7 @@ constructor(
|
|||
heap = queueDao.getHeap()
|
||||
mapping = queueDao.getMapping()
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to load playback state data")
|
||||
logE("Unable read playback state")
|
||||
logE(e.stackTraceToString())
|
||||
return null
|
||||
}
|
||||
|
@ -74,7 +74,7 @@ constructor(
|
|||
}
|
||||
|
||||
val parent = playbackState.parentUid?.let { musicRepository.find(it) as? MusicParent }
|
||||
logD("Read playback state")
|
||||
logD("Successfully read playback state")
|
||||
|
||||
return PlaybackStateManager.SavedState(
|
||||
parent = parent,
|
||||
|
@ -90,8 +90,6 @@ constructor(
|
|||
}
|
||||
|
||||
override suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean {
|
||||
// Only bother saving a state if a song is actively playing from one.
|
||||
// This is not the case with a null state.
|
||||
try {
|
||||
playbackStateDao.nukeState()
|
||||
queueDao.nukeHeap()
|
||||
|
@ -101,7 +99,8 @@ constructor(
|
|||
logE(e.stackTraceToString())
|
||||
return false
|
||||
}
|
||||
logD("Cleared state")
|
||||
|
||||
logD("Successfully cleared previous state")
|
||||
if (state != null) {
|
||||
// Transform saved state into raw state, which can then be written to the database.
|
||||
val playbackState =
|
||||
|
@ -118,12 +117,14 @@ constructor(
|
|||
state.queueState.heap.mapIndexed { i, song ->
|
||||
QueueHeapItem(i, requireNotNull(song).uid)
|
||||
}
|
||||
|
||||
val mapping =
|
||||
state.queueState.orderedMapping.zip(state.queueState.shuffledMapping).mapIndexed {
|
||||
i,
|
||||
pair ->
|
||||
QueueMappingItem(i, pair.first, pair.second)
|
||||
}
|
||||
|
||||
try {
|
||||
playbackStateDao.insertState(playbackState)
|
||||
queueDao.insertHeap(heap)
|
||||
|
@ -133,8 +134,10 @@ constructor(
|
|||
logE(e.stackTraceToString())
|
||||
return false
|
||||
}
|
||||
logD("Wrote state")
|
||||
|
||||
logD("Successfully wrote new state")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ import org.oxycblt.auxio.music.Artist
|
|||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -72,6 +73,7 @@ class PlayFromArtistDialog :
|
|||
if (it != null) {
|
||||
choiceAdapter.update(it.artists, UpdateInstructions.Replace(0))
|
||||
} else {
|
||||
logD("No song to show choices for, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ import org.oxycblt.auxio.music.Genre
|
|||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -72,6 +73,7 @@ class PlayFromGenreDialog :
|
|||
if (it != null) {
|
||||
choiceAdapter.update(it.genres, UpdateInstructions.Replace(0))
|
||||
} else {
|
||||
logD("No song to show choices for, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,8 @@ import org.oxycblt.auxio.music.Artist
|
|||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A [ViewModel] that stores the choices shown in the playback picker dialogs.
|
||||
|
@ -62,6 +64,10 @@ class PlaybackPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
* @param uid The [Music.UID] of the item to show. Must be a [Song].
|
||||
*/
|
||||
fun setPickerSongUid(uid: Music.UID) {
|
||||
logD("Opening picker for song $uid")
|
||||
_currentPickerSong.value = musicRepository.deviceLibrary?.findSong(uid)
|
||||
if (_currentPickerSong.value != null) {
|
||||
logW("Given song UID was invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,8 +23,7 @@ import kotlin.random.nextInt
|
|||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.queue.Queue.Change.Type
|
||||
import org.oxycblt.auxio.playback.queue.Queue.SavedState
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A heap-backed play queue.
|
||||
|
@ -176,6 +175,8 @@ class EditableQueue : Queue {
|
|||
return
|
||||
}
|
||||
|
||||
logD("Reordering queue [shuffled=$shuffled]")
|
||||
|
||||
if (shuffled) {
|
||||
val trueIndex =
|
||||
if (shuffledMapping.isNotEmpty()) {
|
||||
|
@ -192,7 +193,7 @@ class EditableQueue : Queue {
|
|||
shuffledMapping.add(0, shuffledMapping.removeAt(shuffledMapping.indexOf(trueIndex)))
|
||||
index = 0
|
||||
} else if (shuffledMapping.isNotEmpty()) {
|
||||
// Un-shuffling, song to preserve is in the shuffled mapping.
|
||||
// Ordering queue, song to preserve is in the shuffled mapping.
|
||||
index = orderedMapping.indexOf(shuffledMapping[index])
|
||||
shuffledMapping = mutableListOf()
|
||||
}
|
||||
|
@ -206,15 +207,18 @@ class EditableQueue : Queue {
|
|||
* @return A [Queue.Change] instance that reflects the changes made.
|
||||
*/
|
||||
fun playNext(songs: List<Song>): Queue.Change {
|
||||
logD("Adding ${songs.size} songs to the front of the queue")
|
||||
val heapIndices = songs.map(::addSongToHeap)
|
||||
if (shuffledMapping.isNotEmpty()) {
|
||||
// Add the new songs in front of the current index in the shuffled mapping and in front
|
||||
// of the analogous list song in the ordered mapping.
|
||||
logD("Must append songs to shuffled mapping")
|
||||
val orderedIndex = orderedMapping.indexOf(shuffledMapping[index])
|
||||
orderedMapping.addAll(orderedIndex + 1, heapIndices)
|
||||
shuffledMapping.addAll(index + 1, heapIndices)
|
||||
} else {
|
||||
// Add the new song in front of the current index in the ordered mapping.
|
||||
logD("Only appending songs to ordered mapping")
|
||||
orderedMapping.addAll(index + 1, heapIndices)
|
||||
}
|
||||
check()
|
||||
|
@ -229,10 +233,12 @@ class EditableQueue : Queue {
|
|||
* @return A [Queue.Change] instance that reflects the changes made.
|
||||
*/
|
||||
fun addToQueue(songs: List<Song>): Queue.Change {
|
||||
logD("Adding ${songs.size} songs to the back of the queue")
|
||||
val heapIndices = songs.map(::addSongToHeap)
|
||||
// Can simple append the new songs to the end of both mappings.
|
||||
orderedMapping.addAll(heapIndices)
|
||||
if (shuffledMapping.isNotEmpty()) {
|
||||
logD("Appending songs to shuffled mapping")
|
||||
shuffledMapping.addAll(heapIndices)
|
||||
}
|
||||
check()
|
||||
|
@ -257,19 +263,33 @@ class EditableQueue : Queue {
|
|||
orderedMapping.add(dst, orderedMapping.removeAt(src))
|
||||
}
|
||||
|
||||
val oldIndex = index
|
||||
when (index) {
|
||||
// We are moving the currently playing song, correct the index to it's new position.
|
||||
src -> index = dst
|
||||
src -> {
|
||||
logD("Moving current song, shifting index")
|
||||
index = dst
|
||||
}
|
||||
// We have moved an song from behind the playing song to in front, shift back.
|
||||
in (src + 1)..dst -> index -= 1
|
||||
in (src + 1)..dst -> {
|
||||
logD("Moving song from behind -> front, shift backwards")
|
||||
index -= 1
|
||||
}
|
||||
// We have moved an song from in front of the playing song to behind, shift forward.
|
||||
in dst until src -> index += 1
|
||||
in dst until src -> {
|
||||
logD("Moving song from front -> behind, shift forward")
|
||||
index += 1
|
||||
}
|
||||
else -> {
|
||||
// Nothing to do.
|
||||
logD("Move preserved index")
|
||||
check()
|
||||
return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Move(src, dst))
|
||||
}
|
||||
}
|
||||
|
||||
logD("Move changed index: $oldIndex -> $index")
|
||||
|
||||
check()
|
||||
return Queue.Change(Queue.Change.Type.INDEX, UpdateInstructions.Move(src, dst))
|
||||
}
|
||||
|
@ -298,15 +318,23 @@ class EditableQueue : Queue {
|
|||
val type =
|
||||
when {
|
||||
// We just removed the currently playing song.
|
||||
index == at -> Queue.Change.Type.SONG
|
||||
index == at -> {
|
||||
logD("Removed current song")
|
||||
Queue.Change.Type.SONG
|
||||
}
|
||||
// Index was ahead of removed song, shift back to preserve consistency.
|
||||
index > at -> {
|
||||
logD("Removed before current song, shift back")
|
||||
index -= 1
|
||||
Queue.Change.Type.INDEX
|
||||
}
|
||||
// Nothing to do
|
||||
else -> Queue.Change.Type.MAPPING
|
||||
else -> {
|
||||
logD("Removal preserved index")
|
||||
Queue.Change.Type.MAPPING
|
||||
}
|
||||
}
|
||||
logD("Committing change of type $type")
|
||||
check()
|
||||
return Queue.Change(type, UpdateInstructions.Remove(at, 1))
|
||||
}
|
||||
|
@ -339,6 +367,8 @@ class EditableQueue : Queue {
|
|||
}
|
||||
}
|
||||
|
||||
logD("Serialized heap [max shift=$currentShift]")
|
||||
|
||||
heap = savedState.heap.filterNotNull().toMutableList()
|
||||
orderedMapping =
|
||||
savedState.orderedMapping.mapNotNullTo(mutableListOf()) { heapIndex ->
|
||||
|
@ -354,6 +384,7 @@ class EditableQueue : Queue {
|
|||
while (currentSong?.uid != savedState.songUid && index > -1) {
|
||||
index--
|
||||
}
|
||||
logD("Corrected index: ${savedState.index} -> $index")
|
||||
check()
|
||||
}
|
||||
|
||||
|
@ -373,13 +404,17 @@ class EditableQueue : Queue {
|
|||
orphanCandidates.add(entry.index)
|
||||
}
|
||||
}
|
||||
logD("Found orphans: ${orphanCandidates.map { heap[it] }}")
|
||||
orphanCandidates.removeAll(currentMapping.toSet())
|
||||
if (orphanCandidates.isNotEmpty()) {
|
||||
val orphan = orphanCandidates.first()
|
||||
logD("Found an orphan that could be re-used: ${heap[orphan]}")
|
||||
// There are orphaned songs, return the first one we find.
|
||||
return orphanCandidates.first()
|
||||
return orphan
|
||||
}
|
||||
}
|
||||
// Nothing to re-use, add this song to the queue
|
||||
logD("No orphan could be re-used")
|
||||
heap.add(song)
|
||||
return heap.lastIndex
|
||||
}
|
||||
|
|
|
@ -88,9 +88,13 @@ class QueueAdapter(private val listener: EditClickListListener<Song>) :
|
|||
|
||||
// Have to update not only the currently playing item, but also all items marked
|
||||
// as playing.
|
||||
// TODO: Optimize this by only updating the range between old and new indices?
|
||||
// TODO: Don't update when the index has not moved.
|
||||
if (currentIndex < lastIndex) {
|
||||
logD("Moved backwards, must update items above last index")
|
||||
notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_POSITION)
|
||||
} else {
|
||||
logD("Moved forwards, update items after index")
|
||||
notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_POSITION)
|
||||
}
|
||||
|
||||
|
@ -121,6 +125,10 @@ class QueueSongViewHolder private constructor(private val binding: ItemEditableS
|
|||
alpha = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this ViewHolder should be full-opacity to represent a future item, or greyed out to
|
||||
* represent a past item. True if former, false if latter.
|
||||
*/
|
||||
var isFuture: Boolean
|
||||
get() = binding.songAlbumCover.isEnabled
|
||||
set(value) {
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.Song
|
|||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewBindingFragment] that displays an editable queue.
|
||||
|
@ -122,13 +123,15 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditClickList
|
|||
// 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
|
||||
logD("Not scrolling downwards, no offset needed")
|
||||
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 how many completely visible items on-screen
|
||||
// can vary. This is considered okay.
|
||||
binding.queueRecycler.scrollToPosition(
|
||||
min(queue.lastIndex, scrollTo + (end - start)))
|
||||
val offset = scrollTo + (end - start)
|
||||
logD("Scrolling downwards, offsetting by $offset")
|
||||
binding.queueRecycler.scrollToPosition(min(queue.lastIndex, offset))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.Song
|
|||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewModel] that manages the current queue state and allows navigation through the queue.
|
||||
|
@ -60,22 +61,26 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
|
|||
}
|
||||
|
||||
override fun onIndexMoved(queue: Queue) {
|
||||
logD("Index moved, synchronizing and scrolling to new position")
|
||||
_scrollTo.put(queue.index)
|
||||
_index.value = queue.index
|
||||
}
|
||||
|
||||
override fun onQueueChanged(queue: Queue, change: Queue.Change) {
|
||||
// Queue changed trivially due to item mo -> Diff queue, stay at current index.
|
||||
logD("Updating queue display")
|
||||
_queueInstructions.put(change.instructions)
|
||||
_queue.value = queue.resolve()
|
||||
if (change.type != Queue.Change.Type.MAPPING) {
|
||||
// Index changed, make sure it remains updated without actually scrolling to it.
|
||||
logD("Index changed with queue, synchronizing new position")
|
||||
_index.value = queue.index
|
||||
}
|
||||
}
|
||||
|
||||
override fun onQueueReordered(queue: Queue) {
|
||||
// Queue changed completely -> Replace queue, update index
|
||||
logD("Queue changed completely, replacing queue and position")
|
||||
_queueInstructions.put(UpdateInstructions.Replace(0))
|
||||
_scrollTo.put(queue.index)
|
||||
_queue.value = queue.resolve()
|
||||
|
@ -84,6 +89,7 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
|
|||
|
||||
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
|
||||
// Entirely new queue -> Replace queue, update index
|
||||
logD("New playback, replacing queue and position")
|
||||
_queueInstructions.put(UpdateInstructions.Replace(0))
|
||||
_scrollTo.put(queue.index)
|
||||
_queue.value = queue.resolve()
|
||||
|
@ -102,6 +108,10 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
|
|||
* range.
|
||||
*/
|
||||
fun goto(adapterIndex: Int) {
|
||||
if (adapterIndex !in queue.value.indices) {
|
||||
return
|
||||
}
|
||||
logD("Going to position $adapterIndex in queue")
|
||||
playbackManager.goto(adapterIndex)
|
||||
}
|
||||
|
||||
|
@ -115,6 +125,7 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
|
|||
if (adapterIndex !in queue.value.indices) {
|
||||
return
|
||||
}
|
||||
logD("Removing item $adapterIndex in queue")
|
||||
playbackManager.removeQueueItem(adapterIndex)
|
||||
}
|
||||
|
||||
|
@ -129,6 +140,7 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
|
|||
if (adapterFrom !in queue.value.indices || adapterTo !in queue.value.indices) {
|
||||
return false
|
||||
}
|
||||
logD("Moving $adapterFrom to $adapterFrom in queue")
|
||||
playbackManager.moveQueueItem(adapterFrom, adapterTo)
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogPreAmpBinding
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp].
|
||||
|
@ -61,6 +62,7 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
|
|||
// settings. After this, the sliders save their own state, so we do not need to
|
||||
// do any restore behavior.
|
||||
val preAmp = playbackSettings.replayGainPreAmp
|
||||
logD("Initializing from $preAmp")
|
||||
binding.withTagsSlider.value = preAmp.with
|
||||
binding.withoutTagsSlider.value = preAmp.without
|
||||
}
|
||||
|
|
|
@ -125,14 +125,22 @@ constructor(
|
|||
when (playbackSettings.replayGainMode) {
|
||||
// User wants track gain to be preferred. Default to album gain only if
|
||||
// there is no track gain.
|
||||
ReplayGainMode.TRACK -> gain.track == 0f
|
||||
ReplayGainMode.TRACK -> {
|
||||
logD("Using track strategy")
|
||||
gain.track == 0f
|
||||
}
|
||||
// User wants album gain to be preferred. Default to track gain only if
|
||||
// here is no album gain.
|
||||
ReplayGainMode.ALBUM -> gain.album != 0f
|
||||
ReplayGainMode.ALBUM -> {
|
||||
logD("Using album strategy")
|
||||
gain.album != 0f
|
||||
}
|
||||
// User wants album gain to be used when in an album, track gain otherwise.
|
||||
ReplayGainMode.DYNAMIC ->
|
||||
ReplayGainMode.DYNAMIC -> {
|
||||
logD("Using dynamic strategy")
|
||||
playbackManager.parent is Album &&
|
||||
playbackManager.queue.currentSong?.album == playbackManager.parent
|
||||
}
|
||||
}
|
||||
|
||||
val resolvedGain =
|
||||
|
@ -184,6 +192,7 @@ constructor(
|
|||
textTags.vorbis[TAG_RG_TRACK_GAIN]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { albumGain = it }
|
||||
|
||||
// Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the
|
||||
// adjustment by 256 to get the gain. This is used alongside the base adjustment
|
||||
// intrinsic to the format to create the normalized adjustment. This is normally the only
|
||||
|
|
|
@ -24,7 +24,6 @@ import org.oxycblt.auxio.music.MusicParent
|
|||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.queue.EditableQueue
|
||||
import org.oxycblt.auxio.playback.queue.Queue
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
|
@ -308,8 +307,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
|
||||
override val queue = EditableQueue()
|
||||
@Volatile
|
||||
override var parent: MusicParent? =
|
||||
null // FIXME: Parent is interpreted wrong when nothing is playing.
|
||||
override var parent: MusicParent? = null
|
||||
private set
|
||||
@Volatile
|
||||
override var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
|
||||
|
@ -373,6 +371,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
@Synchronized
|
||||
override fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean) {
|
||||
val internalPlayer = internalPlayer ?: return
|
||||
logD("Playing $song from $parent in ${queue.size}-song queue [shuffled=$shuffled]")
|
||||
// Set up parent and queue
|
||||
this.parent = parent
|
||||
this.queue.start(song, queue, shuffled)
|
||||
|
@ -392,6 +391,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
if (!queue.goto(queue.index + 1)) {
|
||||
queue.goto(0)
|
||||
play = repeatMode == RepeatMode.ALL
|
||||
logD("At end of queue, wrapping around to position 0 [play=$play]")
|
||||
} else {
|
||||
logD("Moving to next song")
|
||||
}
|
||||
notifyIndexMoved()
|
||||
internalPlayer.loadSong(queue.currentSong, play)
|
||||
|
@ -400,12 +402,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
@Synchronized
|
||||
override fun prev() {
|
||||
val internalPlayer = internalPlayer ?: return
|
||||
|
||||
// If enabled, rewind before skipping back if the position is past 3 seconds [3000ms]
|
||||
if (internalPlayer.shouldRewindWithPrev) {
|
||||
logD("Rewinding current song")
|
||||
rewind()
|
||||
setPlaying(true)
|
||||
} else {
|
||||
logD("Moving to previous song")
|
||||
if (!queue.goto(queue.index - 1)) {
|
||||
queue.goto(0)
|
||||
}
|
||||
|
@ -418,16 +421,21 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
override fun goto(index: Int) {
|
||||
val internalPlayer = internalPlayer ?: return
|
||||
if (queue.goto(index)) {
|
||||
logD("Moving to $index")
|
||||
notifyIndexMoved()
|
||||
internalPlayer.loadSong(queue.currentSong, true)
|
||||
} else {
|
||||
logW("$index was not in bounds, could not move to it")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun playNext(songs: List<Song>) {
|
||||
if (queue.currentSong == null) {
|
||||
logD("Nothing playing, short-circuiting to new playback")
|
||||
play(songs[0], null, songs, false)
|
||||
} else {
|
||||
logD("Adding ${songs.size} songs to start of queue")
|
||||
notifyQueueChanged(queue.playNext(songs))
|
||||
}
|
||||
}
|
||||
|
@ -435,8 +443,10 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
@Synchronized
|
||||
override fun addToQueue(songs: List<Song>) {
|
||||
if (queue.currentSong == null) {
|
||||
logD("Nothing playing, short-circuiting to new playback")
|
||||
play(songs[0], null, songs, false)
|
||||
} else {
|
||||
logD("Adding ${songs.size} songs to end of queue")
|
||||
notifyQueueChanged(queue.addToQueue(songs))
|
||||
}
|
||||
}
|
||||
|
@ -460,6 +470,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
|
||||
@Synchronized
|
||||
override fun reorder(shuffled: Boolean) {
|
||||
logD("Reordering queue [shuffled=$shuffled]")
|
||||
queue.reorder(shuffled)
|
||||
notifyQueueReordered()
|
||||
}
|
||||
|
@ -504,11 +515,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
|
||||
@Synchronized
|
||||
override fun setPlaying(isPlaying: Boolean) {
|
||||
logD("Updating playing state to $isPlaying")
|
||||
internalPlayer?.setPlaying(isPlaying)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun seekTo(positionMs: Long) {
|
||||
logD("Seeking to ${positionMs}ms")
|
||||
internalPlayer?.seekTo(positionMs)
|
||||
}
|
||||
|
||||
|
@ -530,10 +543,11 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
destructive: Boolean
|
||||
) {
|
||||
if (isInitialized && !destructive) {
|
||||
logW("Already initialized, cannot apply saved state")
|
||||
return
|
||||
}
|
||||
val internalPlayer = internalPlayer ?: return
|
||||
logD("Restoring state $savedState")
|
||||
logD("Applying state $savedState")
|
||||
|
||||
val lastSong = queue.currentSong
|
||||
parent = savedState.parent
|
||||
|
@ -545,10 +559,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
// it be. Specifically done so we don't pause on music updates that don't really change
|
||||
// what's playing (ex. playlist editing)
|
||||
if (lastSong != queue.currentSong) {
|
||||
logD("Song changed, must reload player")
|
||||
// Continuing playback while also possibly doing drastic state updates is
|
||||
// a bad idea, so pause.
|
||||
internalPlayer.loadSong(queue.currentSong, false)
|
||||
if (queue.currentSong != null) {
|
||||
logD("Seeking to saved position ${savedState.positionMs}ms")
|
||||
// Internal player may have reloaded the media item, re-seek to the previous
|
||||
// position
|
||||
seekTo(savedState.positionMs)
|
||||
|
@ -560,36 +576,42 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
// --- CALLBACKS ---
|
||||
|
||||
private fun notifyIndexMoved() {
|
||||
logD("Dispatching index change")
|
||||
for (callback in listeners) {
|
||||
callback.onIndexMoved(queue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyQueueChanged(change: Queue.Change) {
|
||||
logD("Dispatching queue change $change")
|
||||
for (callback in listeners) {
|
||||
callback.onQueueChanged(queue, change)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyQueueReordered() {
|
||||
logD("Dispatching queue reordering")
|
||||
for (callback in listeners) {
|
||||
callback.onQueueReordered(queue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyNewPlayback() {
|
||||
logD("Dispatching new playback")
|
||||
for (callback in listeners) {
|
||||
callback.onNewPlayback(queue, parent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyStateChanged() {
|
||||
logD("Dispatching player state change")
|
||||
for (callback in listeners) {
|
||||
callback.onStateChanged(playerState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyRepeatModeChanged() {
|
||||
logD("Dispatching repeat mode change")
|
||||
for (callback in listeners) {
|
||||
callback.onRepeatChanged(repeatMode)
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService].
|
||||
|
@ -43,6 +44,7 @@ class MediaButtonReceiver : BroadcastReceiver() {
|
|||
// stupid this is with the state of foreground services on modern android. One
|
||||
// wrong action at the wrong time will result in the app crashing, and there is
|
||||
// nothing I can do about it.
|
||||
logD("Delivering media button intent $intent")
|
||||
intent.component = ComponentName(context, PlaybackService::class.java)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
|
|
@ -86,6 +86,7 @@ constructor(
|
|||
* @param intent The [Intent.ACTION_MEDIA_BUTTON] [Intent] to forward.
|
||||
*/
|
||||
fun handleMediaButtonIntent(intent: Intent) {
|
||||
logD("Forwarding $intent to MediaButtonReciever")
|
||||
MediaButtonReceiver.handleIntent(mediaSession, intent)
|
||||
}
|
||||
|
||||
|
@ -283,8 +284,10 @@ constructor(
|
|||
* playback is currently occuring from all songs.
|
||||
*/
|
||||
private fun updateMediaMetadata(song: Song?, parent: MusicParent?) {
|
||||
logD("Updating media metadata to $song with $parent")
|
||||
if (song == null) {
|
||||
// Nothing playing, reset the MediaSession and close the notification.
|
||||
logD("Nothing playing, resetting media session")
|
||||
mediaSession.setMetadata(emptyMetadata)
|
||||
return
|
||||
}
|
||||
|
@ -316,12 +319,17 @@ constructor(
|
|||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
|
||||
// These fields are nullable and so we must check first before adding them to the fields.
|
||||
song.track?.let {
|
||||
logD("Adding track information")
|
||||
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
|
||||
}
|
||||
song.disc?.let {
|
||||
logD("Adding disc information")
|
||||
builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.number.toLong())
|
||||
}
|
||||
song.date?.let { builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) }
|
||||
song.date?.let {
|
||||
logD("Adding date information")
|
||||
builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString())
|
||||
}
|
||||
|
||||
// We are normally supposed to use URIs for album art, but that removes some of the
|
||||
// nice things we can do like square cropping or high quality covers. Instead,
|
||||
|
@ -330,6 +338,8 @@ constructor(
|
|||
song,
|
||||
object : BitmapProvider.Target {
|
||||
override fun onCompleted(bitmap: Bitmap?) {
|
||||
this@MediaSessionComponent.logD(
|
||||
"Bitmap loaded, applying media " + "session and posting notification")
|
||||
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
val metadata = builder.build()
|
||||
|
@ -364,6 +374,7 @@ constructor(
|
|||
// playback state.
|
||||
MediaSessionCompat.QueueItem(description, i.toLong())
|
||||
}
|
||||
logD("Uploading ${queueItems.size} songs to MediaSession queue")
|
||||
mediaSession.setQueue(queueItems)
|
||||
}
|
||||
|
||||
|
@ -384,7 +395,8 @@ constructor(
|
|||
// Add the secondary action (either repeat/shuffle depending on the configuration)
|
||||
val secondaryAction =
|
||||
when (playbackSettings.notificationAction) {
|
||||
ActionMode.SHUFFLE ->
|
||||
ActionMode.SHUFFLE -> {
|
||||
logD("Using shuffle MediaSession action")
|
||||
PlaybackStateCompat.CustomAction.Builder(
|
||||
PlaybackService.ACTION_INVERT_SHUFFLE,
|
||||
context.getString(R.string.desc_shuffle),
|
||||
|
@ -393,11 +405,14 @@ constructor(
|
|||
} else {
|
||||
R.drawable.ic_shuffle_off_24
|
||||
})
|
||||
else ->
|
||||
}
|
||||
else -> {
|
||||
logD("Using repeat mode MediaSession action")
|
||||
PlaybackStateCompat.CustomAction.Builder(
|
||||
PlaybackService.ACTION_INC_REPEAT_MODE,
|
||||
context.getString(R.string.desc_change_repeat),
|
||||
playbackManager.repeatMode.icon)
|
||||
}
|
||||
}
|
||||
state.addCustomAction(secondaryAction.build())
|
||||
|
||||
|
@ -415,14 +430,22 @@ constructor(
|
|||
|
||||
/** Invalidate the "secondary" action (i.e shuffle/repeat mode). */
|
||||
private fun invalidateSecondaryAction() {
|
||||
logD("Invalidating secondary action")
|
||||
invalidateSessionState()
|
||||
|
||||
when (playbackSettings.notificationAction) {
|
||||
ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.queue.isShuffled)
|
||||
else -> notification.updateRepeatMode(playbackManager.repeatMode)
|
||||
ActionMode.SHUFFLE -> {
|
||||
logD("Using shuffle notification action")
|
||||
notification.updateShuffled(playbackManager.queue.isShuffled)
|
||||
}
|
||||
else -> {
|
||||
logD("Using repeat mode notification action")
|
||||
notification.updateRepeatMode(playbackManager.repeatMode)
|
||||
}
|
||||
}
|
||||
|
||||
if (!bitmapProvider.isBusy) {
|
||||
logD("Not loading a bitmap, post the notification")
|
||||
listener?.onPostNotification(notification)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ import org.oxycblt.auxio.IntegerTable
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.service.ForegroundServiceNotification
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newBroadcastPendingIntent
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
|
@ -73,6 +74,7 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
* @param metadata The [MediaMetadataCompat] to display in this notification.
|
||||
*/
|
||||
fun updateMetadata(metadata: MediaMetadataCompat) {
|
||||
logD("Updating shown metadata")
|
||||
setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART))
|
||||
setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE))
|
||||
setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST))
|
||||
|
@ -81,8 +83,10 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
// content text to being above the title. Use an appropriate field for both.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
// Display description -> Parent in which playback is occurring
|
||||
logD("API 24+, showing parent information")
|
||||
setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION))
|
||||
} else {
|
||||
logD("API 24 or lower, showing album information")
|
||||
setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ALBUM))
|
||||
}
|
||||
}
|
||||
|
@ -93,6 +97,7 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
* @param isPlaying Whether playback should be indicated as ongoing or paused.
|
||||
*/
|
||||
fun updatePlaying(isPlaying: Boolean) {
|
||||
logD("Updating playing state: $isPlaying")
|
||||
mActions[2] = buildPlayPauseAction(context, isPlaying)
|
||||
}
|
||||
|
||||
|
@ -102,6 +107,7 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
* @param repeatMode The current [RepeatMode].
|
||||
*/
|
||||
fun updateRepeatMode(repeatMode: RepeatMode) {
|
||||
logD("Applying repeat mode action: $repeatMode")
|
||||
mActions[0] = buildRepeatAction(context, repeatMode)
|
||||
}
|
||||
|
||||
|
@ -111,6 +117,7 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
|
|||
* @param isShuffled Whether the queue is currently shuffled or not.
|
||||
*/
|
||||
fun updateShuffled(isShuffled: Boolean) {
|
||||
logD("Applying shuffle action: $isShuffled")
|
||||
mActions[0] = buildShuffleAction(context, isShuffled)
|
||||
}
|
||||
|
||||
|
|
|
@ -56,6 +56,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
|||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.service.ForegroundManager
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.widgets.WidgetComponent
|
||||
import org.oxycblt.auxio.widgets.WidgetProvider
|
||||
|
||||
|
@ -243,6 +244,7 @@ class PlaybackService :
|
|||
}
|
||||
|
||||
override fun setPlaying(isPlaying: Boolean) {
|
||||
logD("Updating player state to $isPlaying")
|
||||
player.playWhenReady = isPlaying
|
||||
}
|
||||
|
||||
|
@ -254,14 +256,17 @@ class PlaybackService :
|
|||
if (player.playWhenReady) {
|
||||
// Mark that we have started playing so that the notification can now be posted.
|
||||
hasPlayed = true
|
||||
logD("Player has started playing")
|
||||
if (!openAudioEffectSession) {
|
||||
// Convention to start an audioeffect session on play/pause rather than
|
||||
// start/stop
|
||||
logD("Opening audio effect session")
|
||||
broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
openAudioEffectSession = true
|
||||
}
|
||||
} else if (openAudioEffectSession) {
|
||||
// Make sure to close the audio session when we stop playback.
|
||||
logD("Closing audio effect session")
|
||||
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
openAudioEffectSession = false
|
||||
}
|
||||
|
@ -273,6 +278,7 @@ class PlaybackService :
|
|||
Player.EVENT_PLAY_WHEN_READY_CHANGED,
|
||||
Player.EVENT_IS_PLAYING_CHANGED,
|
||||
Player.EVENT_POSITION_DISCONTINUITY)) {
|
||||
logD("Player state changed, must synchronize state")
|
||||
playbackManager.synchronizeState(this)
|
||||
}
|
||||
}
|
||||
|
@ -281,12 +287,15 @@ class PlaybackService :
|
|||
if (state == Player.STATE_ENDED) {
|
||||
// Player ended, repeat the current track if we are configured to.
|
||||
if (playbackManager.repeatMode == RepeatMode.TRACK) {
|
||||
logD("Looping current track")
|
||||
playbackManager.rewind()
|
||||
// May be configured to pause when we repeat a track.
|
||||
if (playbackSettings.pauseOnRepeat) {
|
||||
logD("Pausing track on loop")
|
||||
playbackManager.setPlaying(false)
|
||||
}
|
||||
} else {
|
||||
logD("Track ended, moving to next track")
|
||||
playbackManager.next()
|
||||
}
|
||||
}
|
||||
|
@ -295,12 +304,15 @@ class PlaybackService :
|
|||
override fun onPlayerError(error: PlaybackException) {
|
||||
// TODO: Replace with no skipping and a notification instead
|
||||
// If there's any issue, just go to the next song.
|
||||
logE("Player error occured")
|
||||
logE(error.stackTraceToString())
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (changes.deviceLibrary && musicRepository.deviceLibrary != null) {
|
||||
// We now have a library, see if we have anything we need to do.
|
||||
logD("Library obtained, requesting action")
|
||||
playbackManager.requestAction(this)
|
||||
}
|
||||
}
|
||||
|
@ -308,6 +320,7 @@ class PlaybackService :
|
|||
// --- OTHER FUNCTIONS ---
|
||||
|
||||
private fun broadcastAudioEffectAction(event: String) {
|
||||
logD("Broadcasting AudioEffect event: $event")
|
||||
sendBroadcast(
|
||||
Intent(event)
|
||||
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
|
||||
|
@ -333,11 +346,10 @@ class PlaybackService :
|
|||
// No library, cannot do anything.
|
||||
?: return false
|
||||
|
||||
logD("Performing action: $action")
|
||||
|
||||
when (action) {
|
||||
// Restore state -> Start a new restoreState job
|
||||
is InternalPlayer.Action.RestoreState -> {
|
||||
logD("Restoring playback state")
|
||||
restoreScope.launch {
|
||||
persistenceRepository.readState()?.let {
|
||||
playbackManager.applySavedState(it, false)
|
||||
|
@ -346,11 +358,13 @@ class PlaybackService :
|
|||
}
|
||||
// Shuffle all -> Start new playback from all songs
|
||||
is InternalPlayer.Action.ShuffleAll -> {
|
||||
logD("Shuffling all tracks")
|
||||
playbackManager.play(
|
||||
null, null, musicSettings.songSort.songs(deviceLibrary.songs), true)
|
||||
}
|
||||
// Open -> Try to find the Song for the given file and then play it from all songs
|
||||
is InternalPlayer.Action.Open -> {
|
||||
logD("Opening specified file")
|
||||
deviceLibrary.findSongForUri(application, action.uri)?.let { song ->
|
||||
playbackManager.play(
|
||||
song,
|
||||
|
@ -371,8 +385,9 @@ class PlaybackService :
|
|||
// where changing a setting would cause the notification to appear in an unfriendly
|
||||
// manner.
|
||||
if (hasPlayed) {
|
||||
logD("Updating notification")
|
||||
logD("Played before, starting foreground state")
|
||||
if (!foregroundManager.tryStartForeground(notification)) {
|
||||
logD("Notification changed, re-posting")
|
||||
notification.post()
|
||||
}
|
||||
}
|
||||
|
@ -397,6 +412,7 @@ class PlaybackService :
|
|||
// 3. Some internal framework thing that also handles bluetooth headsets
|
||||
// Just use ACTION_HEADSET_PLUG.
|
||||
AudioManager.ACTION_HEADSET_PLUG -> {
|
||||
logD("Received headset plug event")
|
||||
when (intent.getIntExtra("state", -1)) {
|
||||
0 -> pauseFromHeadsetPlug()
|
||||
1 -> playFromHeadsetPlug()
|
||||
|
@ -404,21 +420,41 @@ class PlaybackService :
|
|||
|
||||
initialHeadsetPlugEventHandled = true
|
||||
}
|
||||
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromHeadsetPlug()
|
||||
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> {
|
||||
logD("Received Headset noise event")
|
||||
pauseFromHeadsetPlug()
|
||||
}
|
||||
|
||||
// --- AUXIO EVENTS ---
|
||||
ACTION_PLAY_PAUSE ->
|
||||
ACTION_PLAY_PAUSE -> {
|
||||
logD("Received play event")
|
||||
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
|
||||
ACTION_INC_REPEAT_MODE ->
|
||||
}
|
||||
ACTION_INC_REPEAT_MODE -> {
|
||||
logD("Received repeat mode event")
|
||||
playbackManager.repeatMode = playbackManager.repeatMode.increment()
|
||||
ACTION_INVERT_SHUFFLE -> playbackManager.reorder(!playbackManager.queue.isShuffled)
|
||||
ACTION_SKIP_PREV -> playbackManager.prev()
|
||||
ACTION_SKIP_NEXT -> playbackManager.next()
|
||||
}
|
||||
ACTION_INVERT_SHUFFLE -> {
|
||||
logD("Received shuffle event")
|
||||
playbackManager.reorder(!playbackManager.queue.isShuffled)
|
||||
}
|
||||
ACTION_SKIP_PREV -> {
|
||||
logD("Received skip previous event")
|
||||
playbackManager.prev()
|
||||
}
|
||||
ACTION_SKIP_NEXT -> {
|
||||
logD("Received skip next event")
|
||||
playbackManager.next()
|
||||
}
|
||||
ACTION_EXIT -> {
|
||||
logD("Received exit event")
|
||||
playbackManager.setPlaying(false)
|
||||
stopAndSave()
|
||||
}
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update()
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> {
|
||||
logD("Received widget update event")
|
||||
widgetComponent.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import com.google.android.material.button.MaterialButton
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.RippleFixMaterialButton
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [MaterialButton] that automatically morphs from a circle to a squircle shape appearance when
|
||||
|
@ -46,10 +47,12 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
|||
val targetRadius = if (activated) 0.3f else 0.5f
|
||||
if (!isLaidOut) {
|
||||
// Not laid out, initialize it without animation before drawing.
|
||||
logD("Not laid out, immediately updating corner radius")
|
||||
updateCornerRadiusRatio(targetRadius)
|
||||
return
|
||||
}
|
||||
|
||||
logD("Starting corner radius animation")
|
||||
animator?.cancel()
|
||||
animator =
|
||||
ValueAnimator.ofFloat(currentCornerRadiusRatio, targetRadius).apply {
|
||||
|
|
|
@ -81,6 +81,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
|||
// zero, use 1 instead and disable the SeekBar.
|
||||
val to = max(value, 1)
|
||||
isEnabled = value > 0
|
||||
logD("Value sanitization finished [to=$to, enabled=$isEnabled]")
|
||||
// Sanity check 2: If the current value exceeds the new duration value, clamp it
|
||||
// down so that we don't crash and instead have an annoying visual flicker.
|
||||
if (positionDs > to) {
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.Music
|
|||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Name
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* Implements the fuzzy-ish searching algorithm used in the search view.
|
||||
|
@ -65,8 +66,9 @@ interface SearchEngine {
|
|||
|
||||
class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||
SearchEngine {
|
||||
override suspend fun search(items: SearchEngine.Items, query: String) =
|
||||
SearchEngine.Items(
|
||||
override suspend fun search(items: SearchEngine.Items, query: String): SearchEngine.Items {
|
||||
logD("Launching search for $query")
|
||||
return SearchEngine.Items(
|
||||
songs =
|
||||
items.songs?.searchListImpl(query) { q, song ->
|
||||
song.path.name.contains(q, ignoreCase = true)
|
||||
|
@ -75,6 +77,7 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte
|
|||
artists = items.artists?.searchListImpl(query),
|
||||
genres = items.genres?.searchListImpl(query),
|
||||
playlists = items.playlists?.searchListImpl(query))
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a given [Music] list.
|
||||
|
|
|
@ -115,6 +115,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
|||
|
||||
if (!launchedKeyboard) {
|
||||
// Auto-open the keyboard when this view is shown
|
||||
this@SearchFragment.logD("Keyboard is not shown yet")
|
||||
showKeyboard(this)
|
||||
launchedKeyboard = true
|
||||
}
|
||||
|
@ -155,6 +156,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
|||
if (item.itemId != R.id.submenu_filtering) {
|
||||
// Is a change in filter mode and not just a junk submenu click, update
|
||||
// the filtering within SearchViewModel.
|
||||
logD("Filter mode selected")
|
||||
item.isChecked = true
|
||||
searchModel.setFilterOptionId(item.itemId)
|
||||
return true
|
||||
|
@ -189,6 +191,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
|||
// I would make it so that the position is only scrolled back to the top when
|
||||
// the query actually changes instead of once every re-creation event, but sadly
|
||||
// that doesn't seem possible.
|
||||
logD("Update finished, scrolling to top")
|
||||
binding.searchRecycler.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
@ -233,6 +236,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
|||
* @param view The [View] to focus the keyboard on.
|
||||
*/
|
||||
private fun showKeyboard(view: View) {
|
||||
logD("Launching keyboard")
|
||||
view.apply {
|
||||
requestFocus()
|
||||
postDelayed(200) {
|
||||
|
@ -244,6 +248,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
|||
|
||||
/** Safely hide the keyboard from this view. */
|
||||
private fun hideKeyboard() {
|
||||
logD("Hiding keyboard")
|
||||
requireNotNull(imm) { "InputMethodManager was not available" }
|
||||
.hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||
}
|
||||
|
|
|
@ -78,6 +78,7 @@ constructor(
|
|||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (changes.deviceLibrary || changes.userLibrary) {
|
||||
logD("Music changed, re-searching library")
|
||||
search(lastQuery)
|
||||
}
|
||||
}
|
||||
|
@ -96,14 +97,13 @@ constructor(
|
|||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (query.isNullOrEmpty() || deviceLibrary == null || userLibrary == null) {
|
||||
logD("Search query is not applicable.")
|
||||
logD("Cannot search for the current query, aborting")
|
||||
_searchResults.value = listOf()
|
||||
return
|
||||
}
|
||||
|
||||
logD("Searching music library for $query")
|
||||
|
||||
// Searching is time-consuming, so do it in the background.
|
||||
logD("Searching music library for $query")
|
||||
currentSearchJob =
|
||||
viewModelScope.launch {
|
||||
_searchResults.value =
|
||||
|
@ -121,6 +121,7 @@ constructor(
|
|||
val items =
|
||||
if (filterMode == null) {
|
||||
// A nulled filter mode means to not filter anything.
|
||||
logD("No filter mode specified, using entire library")
|
||||
SearchEngine.Items(
|
||||
deviceLibrary.songs,
|
||||
deviceLibrary.albums,
|
||||
|
@ -128,6 +129,7 @@ constructor(
|
|||
deviceLibrary.genres,
|
||||
userLibrary.playlists)
|
||||
} else {
|
||||
logD("Filter mode specified, filtering library")
|
||||
SearchEngine.Items(
|
||||
songs = if (filterMode == MusicMode.SONGS) deviceLibrary.songs else null,
|
||||
albums = if (filterMode == MusicMode.ALBUMS) deviceLibrary.albums else null,
|
||||
|
@ -141,11 +143,13 @@ constructor(
|
|||
|
||||
return buildList {
|
||||
results.artists?.let {
|
||||
logD("Adding ${it.size} artists to search results")
|
||||
val header = BasicHeader(R.string.lbl_artists)
|
||||
add(header)
|
||||
addAll(SORT.artists(it))
|
||||
}
|
||||
results.albums?.let {
|
||||
logD("Adding ${it.size} albums to search results")
|
||||
val header = BasicHeader(R.string.lbl_albums)
|
||||
if (isNotEmpty()) {
|
||||
add(Divider(header))
|
||||
|
@ -155,6 +159,7 @@ constructor(
|
|||
addAll(SORT.albums(it))
|
||||
}
|
||||
results.playlists?.let {
|
||||
logD("Adding ${it.size} playlists to search results")
|
||||
val header = BasicHeader(R.string.lbl_playlists)
|
||||
if (isNotEmpty()) {
|
||||
add(Divider(header))
|
||||
|
@ -164,6 +169,7 @@ constructor(
|
|||
addAll(SORT.playlists(it))
|
||||
}
|
||||
results.genres?.let {
|
||||
logD("Adding ${it.size} genres to search results")
|
||||
val header = BasicHeader(R.string.lbl_genres)
|
||||
if (isNotEmpty()) {
|
||||
add(Divider(header))
|
||||
|
@ -173,6 +179,7 @@ constructor(
|
|||
addAll(SORT.genres(it))
|
||||
}
|
||||
results.songs?.let {
|
||||
logD("Adding ${it.size} songs to search results")
|
||||
val header = BasicHeader(R.string.lbl_songs)
|
||||
if (isNotEmpty()) {
|
||||
add(Divider(header))
|
||||
|
|
|
@ -108,6 +108,7 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
|||
// Android 11 seems to now handle the app chooser situations on its own now
|
||||
// [along with adding a new permission that breaks the old manual code], so
|
||||
// we just do a typical activity launch.
|
||||
logD("Using API 30+ chooser")
|
||||
try {
|
||||
context.startActivity(browserIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
|
@ -119,6 +120,7 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
|||
// not work in all cases, especially when no default app was set. If that is the
|
||||
// case, we will try to manually handle these cases before we try to launch the
|
||||
// browser.
|
||||
logD("Resolving browser activity for chooser")
|
||||
@Suppress("DEPRECATION")
|
||||
val pkgName =
|
||||
context.packageManager
|
||||
|
@ -128,16 +130,17 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
|||
if (pkgName != null) {
|
||||
if (pkgName == "android") {
|
||||
// No default browser [Must open app chooser, may not be supported]
|
||||
logD("No default browser found")
|
||||
openAppChooser(browserIntent)
|
||||
} else
|
||||
try {
|
||||
browserIntent.setPackage(pkgName)
|
||||
startActivity(browserIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// Not a browser but an app chooser
|
||||
browserIntent.setPackage(null)
|
||||
openAppChooser(browserIntent)
|
||||
}
|
||||
} else logD("Opening browser intent")
|
||||
try {
|
||||
browserIntent.setPackage(pkgName)
|
||||
startActivity(browserIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// Not a browser but an app chooser
|
||||
browserIntent.setPackage(null)
|
||||
openAppChooser(browserIntent)
|
||||
}
|
||||
} else {
|
||||
// No app installed to open the link
|
||||
context.showToast(R.string.err_no_app)
|
||||
|
@ -151,6 +154,7 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
|||
* @param intent The [Intent] to show an app chooser for.
|
||||
*/
|
||||
private fun openAppChooser(intent: Intent) {
|
||||
logD("Opening app chooser for ${intent.action}")
|
||||
val chooserIntent =
|
||||
Intent(Intent.ACTION_CHOOSER)
|
||||
.putExtra(Intent.EXTRA_INTENT, intent)
|
||||
|
|
|
@ -107,9 +107,10 @@ abstract class BasePreferenceFragment(@XmlRes private val screen: Int) :
|
|||
when (preference) {
|
||||
is IntListPreference -> {
|
||||
// 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. The deprecated code
|
||||
// is largely unavoidable.
|
||||
val dialog = IntListPreferenceDialog.from(preference)
|
||||
dialog.setTargetFragment(this, 0)
|
||||
@Suppress("DEPRECATION") dialog.setTargetFragment(this, 0)
|
||||
dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG)
|
||||
}
|
||||
is WrappedDialogPreference -> {
|
||||
|
@ -128,6 +129,7 @@ abstract class BasePreferenceFragment(@XmlRes private val screen: Int) :
|
|||
}
|
||||
|
||||
if (preference is PreferenceCategory) {
|
||||
// Recurse into preference children to make sure they are set up as well
|
||||
preference.children.forEach(::setupPreference)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.settings.ui.WrappedDialogPreference
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
|
@ -64,18 +65,22 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) {
|
|||
// do one.
|
||||
when (preference.key) {
|
||||
getString(R.string.set_key_ui) -> {
|
||||
logD("Navigating to UI preferences")
|
||||
findNavController()
|
||||
.navigateSafe(RootPreferenceFragmentDirections.goToUiPreferences())
|
||||
}
|
||||
getString(R.string.set_key_personalize) -> {
|
||||
logD("Navigating to personalization preferences")
|
||||
findNavController()
|
||||
.navigateSafe(RootPreferenceFragmentDirections.goToPersonalizePreferences())
|
||||
}
|
||||
getString(R.string.set_key_music) -> {
|
||||
logD("Navigating to music preferences")
|
||||
findNavController()
|
||||
.navigateSafe(RootPreferenceFragmentDirections.goToMusicPreferences())
|
||||
}
|
||||
getString(R.string.set_key_audio) -> {
|
||||
logD("Navigating to audio preferences")
|
||||
findNavController()
|
||||
.navigateSafe(RootPreferenceFragmentDirections.goToAudioPreferences())
|
||||
}
|
||||
|
@ -85,6 +90,7 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) {
|
|||
playbackModel.savePlaybackState { saved ->
|
||||
// Use the nullable context, as we could try to show a toast when this
|
||||
// fragment is no longer attached.
|
||||
logD("Showing saving confirmation")
|
||||
if (saved) {
|
||||
context?.showToast(R.string.lbl_state_saved)
|
||||
} else {
|
||||
|
@ -94,6 +100,7 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) {
|
|||
}
|
||||
getString(R.string.set_key_wipe_state) -> {
|
||||
playbackModel.wipePlaybackState { wiped ->
|
||||
logD("Showing wipe confirmation")
|
||||
if (wiped) {
|
||||
// Use the nullable context, as we could try to show a toast when this
|
||||
// fragment is no longer attached.
|
||||
|
@ -105,6 +112,7 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) {
|
|||
}
|
||||
getString(R.string.set_key_restore_state) ->
|
||||
playbackModel.tryRestorePlaybackState { restored ->
|
||||
logD("Showing restore confirmation")
|
||||
if (restored) {
|
||||
// Use the nullable context, as we could try to show a toast when this
|
||||
// fragment is no longer attached.
|
||||
|
|
|
@ -22,6 +22,7 @@ import androidx.navigation.fragment.findNavController
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.settings.BasePreferenceFragment
|
||||
import org.oxycblt.auxio.settings.ui.WrappedDialogPreference
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
|
||||
/**
|
||||
|
@ -33,6 +34,7 @@ class AudioPreferenceFragment : BasePreferenceFragment(R.xml.preferences_audio)
|
|||
|
||||
override fun onOpenDialogPreference(preference: WrappedDialogPreference) {
|
||||
if (preference.key == getString(R.string.set_key_pre_amp)) {
|
||||
logD("Navigating to pre-amp dialog")
|
||||
findNavController().navigateSafe(AudioPreferenceFragmentDirections.goToPreAmpDialog())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import javax.inject.Inject
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.settings.BasePreferenceFragment
|
||||
import org.oxycblt.auxio.settings.ui.WrappedDialogPreference
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
|
||||
/**
|
||||
|
@ -39,6 +40,7 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music)
|
|||
|
||||
override fun onOpenDialogPreference(preference: WrappedDialogPreference) {
|
||||
if (preference.key == getString(R.string.set_key_separators)) {
|
||||
logD("Navigating to separator dialog")
|
||||
findNavController()
|
||||
.navigateSafe(MusicPreferenceFragmentDirections.goToSeparatorsDialog())
|
||||
}
|
||||
|
@ -46,8 +48,10 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music)
|
|||
|
||||
override fun onSetupPreference(preference: Preference) {
|
||||
if (preference.key == getString(R.string.set_key_cover_mode)) {
|
||||
logD("Configuring cover mode setting")
|
||||
preference.onPreferenceChangeListener =
|
||||
Preference.OnPreferenceChangeListener { _, _ ->
|
||||
logD("Cover mode changed, resetting image memory cache")
|
||||
imageLoader.memoryCache?.clear()
|
||||
true
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import androidx.navigation.fragment.findNavController
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.settings.BasePreferenceFragment
|
||||
import org.oxycblt.auxio.settings.ui.WrappedDialogPreference
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
|
||||
/**
|
||||
|
@ -32,6 +33,7 @@ import org.oxycblt.auxio.util.navigateSafe
|
|||
class PersonalizePreferenceFragment : BasePreferenceFragment(R.xml.preferences_personalize) {
|
||||
override fun onOpenDialogPreference(preference: WrappedDialogPreference) {
|
||||
if (preference.key == getString(R.string.set_key_home_tabs)) {
|
||||
logD("Navigating to home tab dialog")
|
||||
findNavController()
|
||||
.navigateSafe(PersonalizePreferenceFragmentDirections.goToTabDialog())
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ import org.oxycblt.auxio.settings.BasePreferenceFragment
|
|||
import org.oxycblt.auxio.settings.ui.WrappedDialogPreference
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.isNight
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
|
||||
/**
|
||||
|
@ -41,6 +42,7 @@ class UIPreferenceFragment : BasePreferenceFragment(R.xml.preferences_ui) {
|
|||
|
||||
override fun onOpenDialogPreference(preference: WrappedDialogPreference) {
|
||||
if (preference.key == getString(R.string.set_key_accent)) {
|
||||
logD("Navigating to accent dialog")
|
||||
findNavController().navigateSafe(UIPreferenceFragmentDirections.goToAccentDialog())
|
||||
}
|
||||
}
|
||||
|
@ -48,20 +50,25 @@ class UIPreferenceFragment : BasePreferenceFragment(R.xml.preferences_ui) {
|
|||
override fun onSetupPreference(preference: Preference) {
|
||||
when (preference.key) {
|
||||
getString(R.string.set_key_theme) -> {
|
||||
logD("Configuring theme setting")
|
||||
preference.onPreferenceChangeListener =
|
||||
Preference.OnPreferenceChangeListener { _, value ->
|
||||
logD("Theme changed, recreating")
|
||||
AppCompatDelegate.setDefaultNightMode(value as Int)
|
||||
true
|
||||
}
|
||||
}
|
||||
getString(R.string.set_key_accent) -> {
|
||||
logD("Configuring accent setting")
|
||||
preference.summary = getString(uiSettings.accent.name)
|
||||
}
|
||||
getString(R.string.set_key_black_theme) -> {
|
||||
logD("Configuring black theme setting")
|
||||
preference.onPreferenceChangeListener =
|
||||
Preference.OnPreferenceChangeListener { _, _ ->
|
||||
val activity = requireActivity()
|
||||
if (activity.isNight) {
|
||||
logD("Black theme changed in night mode, recreating")
|
||||
activity.recreate()
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|||
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.systemGestureInsetsCompat
|
||||
|
||||
/**
|
||||
|
@ -82,6 +83,7 @@ abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet:
|
|||
val layout = super.onLayoutChild(parent, child, layoutDirection)
|
||||
// Don't repeat redundant initialization.
|
||||
if (!initalized) {
|
||||
logD("Not initialized, setting up child")
|
||||
child.apply {
|
||||
// Set up compat elevation attributes. These are only shown below API 28.
|
||||
translationZ = context.getDimen(R.dimen.elevation_normal)
|
||||
|
|
|
@ -26,6 +26,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|||
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
|
||||
import kotlin.math.abs
|
||||
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
|
@ -60,10 +61,12 @@ class BottomSheetContentBehavior<V : View>(context: Context, attributeSet: Attri
|
|||
val behavior = dependency.coordinatorLayoutBehavior as BackportBottomSheetBehavior
|
||||
val consumed = behavior.calculateConsumedByBar()
|
||||
if (consumed == Int.MIN_VALUE) {
|
||||
logD("Not laid out yet, cannot update dependent view")
|
||||
return false
|
||||
}
|
||||
|
||||
if (consumed != lastConsumed) {
|
||||
logD("Consumed amount changed, re-applying insets")
|
||||
lastConsumed = consumed
|
||||
|
||||
val insets = lastInsets
|
||||
|
|
|
@ -30,6 +30,7 @@ import androidx.core.content.res.ResourcesCompat
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* An [AppBarLayout] that resolves two issues with the default implementation:
|
||||
|
@ -75,6 +76,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
fun expandWithScrollingRecycler() {
|
||||
setExpanded(true)
|
||||
(findScrollingChild() as? RecyclerView)?.let {
|
||||
logD("Found RecyclerView, expanding with it")
|
||||
addOnOffsetChangedListener(ExpansionHackListener(it))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
fun setVisible(@IdRes viewId: Int): Boolean {
|
||||
val index = children.indexOfFirst { it.id == viewId }
|
||||
if (index == currentlyVisible) return false
|
||||
logD("Switching toolbar visibility from $currentlyVisible -> $index")
|
||||
return animateToolbarsVisibility(currentlyVisible, index).also { currentlyVisible = index }
|
||||
}
|
||||
|
||||
|
@ -61,14 +62,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
val targetFromAlpha = 0f
|
||||
val targetToAlpha = 1f
|
||||
val targetDuration =
|
||||
// Since this view starts with the lowest toolbar index,
|
||||
if (from < to) {
|
||||
logD("Moving higher, use an entrance animation")
|
||||
context.getInteger(R.integer.anim_fade_enter_duration).toLong()
|
||||
} else {
|
||||
logD("Moving lower, use an exit animation")
|
||||
context.getInteger(R.integer.anim_fade_exit_duration).toLong()
|
||||
}
|
||||
|
||||
logD(targetDuration)
|
||||
|
||||
val fromView = getChildAt(from) as Toolbar
|
||||
val toView = getChildAt(to) as Toolbar
|
||||
|
||||
|
@ -80,15 +82,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
if (!isLaidOut) {
|
||||
// Not laid out, just change it immediately while are not shown to the user.
|
||||
// This is an initialization, so we return false despite changing.
|
||||
logD("Not laid out, immediately updating visibility")
|
||||
setToolbarsAlpha(fromView, toView, targetFromAlpha)
|
||||
return false
|
||||
}
|
||||
|
||||
if (fadeThroughAnimator != null) {
|
||||
fadeThroughAnimator?.cancel()
|
||||
fadeThroughAnimator = null
|
||||
}
|
||||
|
||||
logD("Changing toolbar visibility $from -> 0f, $to -> 1f")
|
||||
fadeThroughAnimator?.cancel()
|
||||
fadeThroughAnimator =
|
||||
ValueAnimator.ofFloat(fromView.alpha, targetFromAlpha).apply {
|
||||
duration = targetDuration
|
||||
|
@ -100,7 +100,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private fun setToolbarsAlpha(from: Toolbar, to: Toolbar, innerAlpha: Float) {
|
||||
logD("${to.id == R.id.detail_edit_toolbar} ${1 - innerAlpha}")
|
||||
from.apply {
|
||||
alpha = innerAlpha
|
||||
isInvisible = innerAlpha == 0f
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue