Merge pull request #659 from OxygenCobalt/dev

Version 3.3.0
This commit is contained in:
Alexander Capehart 2024-01-03 14:30:35 -07:00 committed by GitHub
commit 4e2e6f66b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
186 changed files with 4106 additions and 1486 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
github: [OxygenCobalt]

View file

@ -34,6 +34,7 @@ body:
attributes: attributes:
label: What android version do you use? label: What android version do you use?
options: options:
- Android 14
- Android 13 - Android 13
- Android 12L - Android 12L
- Android 12 - Android 12

View file

@ -2,8 +2,33 @@
## dev ## dev
## 3.3.0
#### What's New #### What's New
- Added ability to rewind/skip tracks by swiping back/forward - Added ability to rewind/skip tracks by swiping back/forward
- Added support for demo release type
- Added playlist importing/export from M3U files
#### What's Improved
- Music loading will now fail when it hangs
#### What's Changed
- Albums linked to an artist only as a collaborator are no longer included
in an artist's album count
- File name and parent path have been combined into "Path" in the Song Properties
view
#### What's Fixed
- Fixed music loading failing on all huawei devices
- Fixed prior music loads not cancelling when reloading music in settings
- Fixed certain FLAC files failing to play on some devices
- Fixed music loading failing when duplicate tags with different casing was present
#### Dev/Meta
- Revamped path management
## 3.2.1
#### What's Improved #### What's Improved
- Added support for native M4A multi-value tags based on duplicate atoms - Added support for native M4A multi-value tags based on duplicate atoms

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.2.0"> <a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.3.0">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.2.0&color=64B5F6&style=flat"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.3.0&color=64B5F6&style=flat">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat"> <img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">

View file

@ -16,13 +16,13 @@ android {
// it here so that binary stripping will work. // it here so that binary stripping will work.
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
// NDK use is unified // NDK use is unified
ndkVersion = "23.2.8568313" ndkVersion = "25.2.9519653"
namespace "org.oxycblt.auxio" namespace "org.oxycblt.auxio"
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "3.2.1" versionName "3.3.0"
versionCode 36 versionCode 37
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
@ -31,6 +31,7 @@ android {
} }
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
@ -87,8 +88,8 @@ dependencies {
// General // General
implementation "androidx.core:core-ktx:1.12.0" implementation "androidx.core:core-ktx:1.12.0"
implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.activity:activity-ktx:1.8.0" implementation "androidx.activity:activity-ktx:1.8.2"
implementation "androidx.fragment:fragment-ktx:1.6.1" implementation "androidx.fragment:fragment-ktx:1.6.2"
// Components // Components
// Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on // Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on
@ -111,13 +112,13 @@ dependencies {
implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version" implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version"
// Media // Media
implementation "androidx.media:media:1.6.0" implementation "androidx.media:media:1.7.0"
// Preferences // Preferences
implementation "androidx.preference:preference-ktx:1.2.1" implementation "androidx.preference:preference-ktx:1.2.1"
// Database // Database
def room_version = '2.6.0-rc01' def room_version = '2.6.1'
implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-runtime:$room_version"
ksp "androidx.room:room-compiler:$room_version" ksp "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version" implementation "androidx.room:room-ktx:$room_version"
@ -127,6 +128,7 @@ dependencies {
// Exoplayer (Vendored) // Exoplayer (Vendored)
implementation project(":media-lib-exoplayer") implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg") implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
// Image loading // Image loading
implementation 'io.coil-kt:coil-base:2.4.0' implementation 'io.coil-kt:coil-base:2.4.0'
@ -145,6 +147,9 @@ dependencies {
// Logging // Logging
implementation 'com.jakewharton.timber:timber:5.0.1' implementation 'com.jakewharton.timber:timber:5.0.1'
// Speed dial
implementation "com.leinardi.android:speed-dial:3.3.0"
// Testing // Testing
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2" testImplementation "junit:junit:4.13.2"

View file

@ -76,6 +76,7 @@ class MainFragment :
private var sheetBackCallback: SheetBackPressedCallback? = null private var sheetBackCallback: SheetBackPressedCallback? = null
private var detailBackCallback: DetailBackPressedCallback? = null private var detailBackCallback: DetailBackPressedCallback? = null
private var selectionBackCallback: SelectionBackPressedCallback? = null private var selectionBackCallback: SelectionBackPressedCallback? = null
private var speedDialBackCallback: SpeedDialBackPressedCallback? = null
private var selectionNavigationListener: DialogAwareNavigationListener? = null private var selectionNavigationListener: DialogAwareNavigationListener? = null
private var lastInsets: WindowInsets? = null private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f private var elevationNormal = 0f
@ -109,6 +110,8 @@ class MainFragment :
DetailBackPressedCallback(detailModel).also { detailBackCallback = it } DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
val selectionBackCallback = val selectionBackCallback =
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it } SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
val speedDialBackCallback =
SpeedDialBackPressedCallback(homeModel).also { speedDialBackCallback = it }
selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection) selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection)
@ -158,6 +161,7 @@ class MainFragment :
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
collectImmediately(homeModel.speedDialOpen, speedDialBackCallback::invalidateEnabled)
collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled) collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.openPanel.flow, ::handlePanel) collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
@ -181,6 +185,7 @@ class MainFragment :
// navigation, navigation out of detail views, etc. We have to do this here in // navigation, navigation out of detail views, etc. We have to do this here in
// onResume or otherwise the FragmentManager will have precedence. // onResume or otherwise the FragmentManager will have precedence.
requireActivity().onBackPressedDispatcher.apply { requireActivity().onBackPressedDispatcher.apply {
addCallback(viewLifecycleOwner, requireNotNull(speedDialBackCallback))
addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback))
addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback))
addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback))
@ -197,6 +202,7 @@ class MainFragment :
override fun onDestroyBinding(binding: FragmentMainBinding) { override fun onDestroyBinding(binding: FragmentMainBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
speedDialBackCallback = null
sheetBackCallback = null sheetBackCallback = null
detailBackCallback = null detailBackCallback = null
selectionBackCallback = null selectionBackCallback = null
@ -218,6 +224,13 @@ class MainFragment :
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f) val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
if (playbackRatio > 0f && homeModel.speedDialOpen.value) {
// Stupid hack to prevent you from sliding the sheet up without closing the speed
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the speed
// dial, which is super finicky behavior.
homeModel.setSpeedDialOpen(false)
}
val outPlaybackRatio = 1 - playbackRatio val outPlaybackRatio = 1 - playbackRatio
val halfOutRatio = min(playbackRatio * 2, 1f) val halfOutRatio = min(playbackRatio * 2, 1f)
val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2 val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2
@ -493,4 +506,17 @@ class MainFragment :
isEnabled = selection.isNotEmpty() isEnabled = selection.isNotEmpty()
} }
} }
private inner class SpeedDialBackPressedCallback(private val homeModel: HomeViewModel) :
OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (homeModel.speedDialOpen.value) {
homeModel.setSpeedDialOpen(false)
}
}
fun invalidateEnabled(open: Boolean) {
isEnabled = open
}
}
} }

View file

@ -44,6 +44,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
@ -55,6 +56,7 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -126,6 +128,7 @@ class AlbumDetailFragment :
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision) collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
@ -273,12 +276,20 @@ class AlbumDetailFragment :
decision.songs.map { it.uid }.toTypedArray()) decision.songs.map { it.uid }.toTypedArray())
} }
is PlaylistDecision.New, is PlaylistDecision.New,
is PlaylistDecision.Import,
is PlaylistDecision.Rename, is PlaylistDecision.Rename,
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") is PlaylistDecision.Delete,
is PlaylistDecision.Export -> error("Unexpected playlist decision $decision")
} }
findNavController().navigateSafe(directions) findNavController().navigateSafe(directions)
} }
private fun handlePlaylistMessage(message: PlaylistMessage?) {
if (message == null) return
requireContext().showToast(message.stringRes)
musicModel.playlistMessage.consume()
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
albumListAdapter.setPlaying( albumListAdapter.setPlaying(
song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying) song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying)

View file

@ -45,6 +45,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
@ -54,6 +55,7 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -128,6 +130,7 @@ class ArtistDetailFragment :
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision) collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
@ -276,12 +279,20 @@ class ArtistDetailFragment :
decision.songs.map { it.uid }.toTypedArray()) decision.songs.map { it.uid }.toTypedArray())
} }
is PlaylistDecision.New, is PlaylistDecision.New,
is PlaylistDecision.Import,
is PlaylistDecision.Rename, is PlaylistDecision.Rename,
is PlaylistDecision.Export,
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
} }
findNavController().navigateSafe(directions) findNavController().navigateSafe(directions)
} }
private fun handlePlaylistMessage(message: PlaylistMessage?) {
if (message == null) return
requireContext().showToast(message.stringRes)
musicModel.playlistMessage.consume()
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value) val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
val playingItem = val playingItem =

View file

@ -607,6 +607,7 @@ constructor(
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
is ReleaseType.Mix -> AlbumGrouping.DJMIXES is ReleaseType.Mix -> AlbumGrouping.DJMIXES
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
is ReleaseType.Demo -> AlbumGrouping.DEMOS
} }
} }
} }
@ -709,6 +710,7 @@ constructor(
SOUNDTRACKS(R.string.lbl_soundtracks), SOUNDTRACKS(R.string.lbl_soundtracks),
DJMIXES(R.string.lbl_mixes), DJMIXES(R.string.lbl_mixes),
MIXTAPES(R.string.lbl_mixtapes), MIXTAPES(R.string.lbl_mixtapes),
DEMOS(R.string.lbl_demos),
APPEARANCES(R.string.lbl_appears_on), APPEARANCES(R.string.lbl_appears_on),
LIVE(R.string.lbl_live_group), LIVE(R.string.lbl_live_group),
REMIXES(R.string.lbl_remix_group), REMIXES(R.string.lbl_remix_group),

View file

@ -45,6 +45,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
@ -54,6 +55,7 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -125,7 +127,8 @@ class GenreDetailFragment :
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collect(musicModel.playlistDecision.flow, ::handleDecision) collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
@ -259,7 +262,7 @@ class GenreDetailFragment :
} }
} }
private fun handleDecision(decision: PlaylistDecision?) { private fun handlePlaylistDecision(decision: PlaylistDecision?) {
if (decision == null) return if (decision == null) return
val directions = val directions =
when (decision) { when (decision) {
@ -269,12 +272,20 @@ class GenreDetailFragment :
decision.songs.map { it.uid }.toTypedArray()) decision.songs.map { it.uid }.toTypedArray())
} }
is PlaylistDecision.New, is PlaylistDecision.New,
is PlaylistDecision.Import,
is PlaylistDecision.Rename, is PlaylistDecision.Rename,
is PlaylistDecision.Export,
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
} }
findNavController().navigateSafe(directions) findNavController().navigateSafe(directions)
} }
private fun handlePlaylistMessage(message: PlaylistMessage?) {
if (message == null) return
requireContext().showToast(message.stringRes)
musicModel.playlistMessage.consume()
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value) val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
val playingItem = val playingItem =

View file

@ -21,6 +21,8 @@ package org.oxycblt.auxio.detail
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
@ -47,16 +49,20 @@ import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -80,6 +86,8 @@ class PlaylistDetailFragment :
private val playlistListAdapter = PlaylistDetailListAdapter(this) private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null private var touchHelper: ItemTouchHelper? = null
private var editNavigationListener: DialogAwareNavigationListener? = null private var editNavigationListener: DialogAwareNavigationListener? = null
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var pendingImportTarget: Playlist? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -99,6 +107,17 @@ class PlaylistDetailFragment :
editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit) editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit)
getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
logW("No URI returned from file picker")
return@registerForActivityResult
}
logD("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget)
}
// --- UI SETUP --- // --- UI SETUP ---
binding.detailNormalToolbar.apply { binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
@ -142,7 +161,8 @@ class PlaylistDetailFragment :
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collect(musicModel.playlistDecision.flow, ::handleDecision) collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
@ -316,13 +336,31 @@ class PlaylistDetailFragment :
updateMultiToolbar() updateMultiToolbar()
} }
private fun handleDecision(decision: PlaylistDecision?) { private fun handlePlaylistDecision(decision: PlaylistDecision?) {
if (decision == null) return if (decision == null) return
val directions = val directions =
when (decision) { when (decision) {
is PlaylistDecision.Import -> {
logD("Importing playlist")
pendingImportTarget = decision.target
requireNotNull(getContentLauncher) {
"Content picker launcher was not available"
}
.launch(M3U.MIME_TYPE)
musicModel.playlistDecision.consume()
return
}
is PlaylistDecision.Rename -> { is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}") logD("Renaming ${decision.playlist}")
PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid) PlaylistDetailFragmentDirections.renamePlaylist(
decision.playlist.uid,
decision.template,
decision.applySongs.map { it.uid }.toTypedArray(),
decision.reason)
}
is PlaylistDecision.Export -> {
logD("Exporting ${decision.playlist}")
PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid)
} }
is PlaylistDecision.Delete -> { is PlaylistDecision.Delete -> {
logD("Deleting ${decision.playlist}") logD("Deleting ${decision.playlist}")
@ -338,6 +376,12 @@ class PlaylistDetailFragment :
findNavController().navigateSafe(directions) findNavController().navigateSafe(directions)
} }
private fun handlePlaylistMessage(message: PlaylistMessage?) {
if (message == null) return
requireContext().showToast(message.stringRes)
musicModel.playlistMessage.consume()
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
// Prefer songs that are playing from this playlist. // Prefer songs that are playing from this playlist.
playlistListAdapter.setPlaying( playlistListAdapter.setPlaying(

View file

@ -102,10 +102,7 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
} }
add(SongProperty(R.string.lbl_disc, zipped)) add(SongProperty(R.string.lbl_disc, zipped))
} }
add(SongProperty(R.string.lbl_file_name, song.path.name)) add(SongProperty(R.string.lbl_path, song.path.resolve(context)))
add(
SongProperty(
R.string.lbl_relative_path, song.path.parent.resolveName(context)))
info.resolvedMimeType.resolveName(context)?.let { info.resolvedMimeType.resolveName(context)?.let {
add(SongProperty(R.string.lbl_format, it)) add(SongProperty(R.string.lbl_format, it))
} }

View file

@ -71,7 +71,11 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
binding.detailInfo.text = binding.detailInfo.text =
binding.context.getString( binding.context.getString(
R.string.fmt_two, R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size), if (artist.explicitAlbums.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size)
} else {
binding.context.getString(R.string.def_album_count)
},
if (artist.songs.isNotEmpty()) { if (artist.songs.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size) binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
} else { } else {

View file

@ -1,117 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* FlipFloatingActionButton.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.google.android.material.R
import com.google.android.material.floatingactionbutton.FloatingActionButton
import org.oxycblt.auxio.util.logD
/**
* An extension of [FloatingActionButton] that enables the ability to fade in and out between
* several states, as in the Material Design 3 specification.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class FlipFloatingActionButton
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.floatingActionButtonStyle
) : FloatingActionButton(context, attrs, defStyleAttr) {
private var pendingConfig: PendingConfig? = null
private var flipping = false
override fun show() {
// Will already show eventually, need to do nothing.
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 {
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()
}
/**
* Flip to a new FAB state.
*
* @param iconRes The resource of the new FAB icon.
* @param contentDescriptionRes The resource of the new FAB content description.
*/
fun flipTo(
@DrawableRes iconRes: Int,
@StringRes contentDescriptionRes: Int,
clickListener: OnClickListener
) {
// Avoid doing a flip if the given config is already being applied.
if (tag == iconRes) return
tag = iconRes
pendingConfig = PendingConfig(iconRes, contentDescriptionRes, clickListener)
// 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")
}
}
private data class PendingConfig(
@DrawableRes val iconRes: Int,
@StringRes val contentDescriptionRes: Int,
val clickListener: OnClickListener
)
private inner class FlipVisibilityListener : OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
if (!flipping) return
logD("Starting show for flip")
flipping = false
show()
}
}
}

View file

@ -21,6 +21,7 @@ package org.oxycblt.auxio.home
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.MotionEvent
import android.view.View import android.view.View
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -36,10 +37,14 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field import java.lang.reflect.Field
import java.lang.reflect.Method
import kotlin.math.abs import kotlin.math.abs
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -64,16 +69,22 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.NoAudioPermissionException import org.oxycblt.auxio.music.NoAudioPermissionException
import org.oxycblt.auxio.music.NoMusicException import org.oxycblt.auxio.music.NoMusicException
import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast
/** /**
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation * The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
@ -83,13 +94,17 @@ import org.oxycblt.auxio.util.navigateSafe
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class HomeFragment : class HomeFragment :
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener { SelectionFragment<FragmentHomeBinding>(),
AppBarLayout.OnOffsetChangedListener,
SpeedDialView.OnActionSelectedListener {
override val listModel: ListViewModel by activityViewModels() override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var pendingImportTarget: Playlist? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -118,7 +133,39 @@ class HomeFragment :
musicModel.refresh() musicModel.refresh()
} }
getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
logW("No URI returned from file picker")
return@registerForActivityResult
}
logD("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget)
}
// --- UI SETUP --- // --- UI SETUP ---
binding.root.rootView.apply {
// Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
// it ourselves.
findViewById<View>(R.id.main_scrim).setOnClickListener {
homeModel.setSpeedDialOpen(false)
}
findViewById<View>(R.id.main_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event)
}
findViewById<View>(R.id.sheet_scrim).setOnClickListener {
homeModel.setSpeedDialOpen(false)
}
findViewById<View>(R.id.sheet_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event)
}
}
binding.homeAppbar.addOnOffsetChangedListener(this) binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeNormalToolbar.apply { binding.homeNormalToolbar.apply {
setOnMenuItemClickListener(this@HomeFragment) setOnMenuItemClickListener(this@HomeFragment)
@ -166,14 +213,30 @@ class HomeFragment :
// re-creating the ViewPager. // re-creating the ViewPager.
setupPager(binding) setupPager(binding)
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
binding.homeNewPlaylistFab.apply {
inflate(R.menu.new_playlist_actions)
setOnActionSelectedListener(this@HomeFragment)
setChangeListener(homeModel::setSpeedDialOpen)
}
hideAllFabs()
updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate) collect(homeModel.recreateTabs.flow, ::handleRecreate)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab) collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab) collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
collect(homeModel.speedDialOpen, ::updateSpeedDial)
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(musicModel.indexingState, ::updateIndexerState) collectImmediately(musicModel.indexingState, ::updateIndexerState)
collect(musicModel.playlistDecision.flow, ::handleDecision) collect(musicModel.playlistDecision.flow, ::handleDecision)
collectImmediately(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
} }
@ -191,6 +254,8 @@ class HomeFragment :
storagePermissionLauncher = null storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this) binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeNormalToolbar.setOnMenuItemClickListener(null) binding.homeNormalToolbar.setOnMenuItemClickListener(null)
binding.homeNewPlaylistFab.setChangeListener(null)
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
} }
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
@ -249,6 +314,24 @@ class HomeFragment :
} }
} }
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
when (actionItem.id) {
R.id.action_new_playlist -> {
logD("Creating playlist")
musicModel.createPlaylist()
}
R.id.action_import_playlist -> {
logD("Importing playlist")
musicModel.importPlaylist()
}
else -> {}
}
// Returning false to close th speed dial results in no animation, manually close instead.
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
requireBinding().homeNewPlaylistFab.close()
return true
}
private fun setupPager(binding: FragmentHomeBinding) { private fun setupPager(binding: FragmentHomeBinding) {
binding.homePager.adapter = binding.homePager.adapter =
HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner) HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner)
@ -291,17 +374,7 @@ class HomeFragment :
MusicType.PLAYLISTS -> R.id.home_playlist_recycler MusicType.PLAYLISTS -> R.id.home_playlist_recycler
} }
if (tabType != MusicType.PLAYLISTS) { updateFabVisibility(homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
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()
}
}
} }
private fun handleRecreate(recreate: Unit?) { private fun handleRecreate(recreate: Unit?) {
@ -333,7 +406,10 @@ class HomeFragment :
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) { private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
if (error == null) { if (error == null) {
logD("Received ok response") logD("Received ok response")
binding.homeFab.show() updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
binding.homeIndexingContainer.visibility = View.INVISIBLE binding.homeIndexingContainer.visibility = View.INVISIBLE
return return
} }
@ -420,11 +496,32 @@ class HomeFragment :
when (decision) { when (decision) {
is PlaylistDecision.New -> { is PlaylistDecision.New -> {
logD("Creating new playlist") logD("Creating new playlist")
HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray()) HomeFragmentDirections.newPlaylist(
decision.songs.map { it.uid }.toTypedArray(),
decision.template,
decision.reason)
}
is PlaylistDecision.Import -> {
logD("Importing playlist")
pendingImportTarget = decision.target
requireNotNull(getContentLauncher) {
"Content picker launcher was not available"
}
.launch(M3U.MIME_TYPE)
musicModel.playlistDecision.consume()
return
} }
is PlaylistDecision.Rename -> { is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}") logD("Renaming ${decision.playlist}")
HomeFragmentDirections.renamePlaylist(decision.playlist.uid) HomeFragmentDirections.renamePlaylist(
decision.playlist.uid,
decision.template,
decision.applySongs.map { it.uid }.toTypedArray(),
decision.reason)
}
is PlaylistDecision.Export -> {
logD("Exporting ${decision.playlist}")
HomeFragmentDirections.exportPlaylist(decision.playlist.uid)
} }
is PlaylistDecision.Delete -> { is PlaylistDecision.Delete -> {
logD("Deleting ${decision.playlist}") logD("Deleting ${decision.playlist}")
@ -439,20 +536,130 @@ class HomeFragment :
findNavController().navigateSafe(directions) findNavController().navigateSafe(directions)
} }
private fun handlePlaylistMessage(message: PlaylistMessage?) {
if (message == null) return
requireContext().showToast(message.stringRes)
musicModel.playlistMessage.consume()
}
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) { private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value)
}
private fun updateFabVisibility(
songs: List<Song>,
isFastScrolling: Boolean,
tabType: MusicType
) {
val binding = requireBinding() val binding = requireBinding()
// If there are no songs, it's likely that the library has not been loaded, so // If there are no songs, it's likely that the library has not been loaded, so
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll // 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. // popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
if (songs.isEmpty() || isFastScrolling) { if (songs.isEmpty() || isFastScrolling) {
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]") logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
binding.homeFab.hide() hideAllFabs()
} else { } else {
logD("Showing fab") if (tabType != MusicType.PLAYLISTS) {
binding.homeFab.show() logD("Showing shuffle button")
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Nothing to do")
return
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeNewPlaylistFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
binding.homeShuffleFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeShuffleFab.show()
}
} else {
logD("Showing playlist button")
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Nothing to do")
return
}
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeShuffleFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
binding.homeNewPlaylistFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeNewPlaylistFab.show()
}
}
} }
} }
private fun hideAllFabs() {
val binding = requireBinding()
if (binding.homeShuffleFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
}
}
private fun updateSpeedDial(open: Boolean) {
val binding = requireBinding()
binding.root.rootView.apply {
// Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
// it ourselves.
findViewById<View>(R.id.main_scrim).isClickable = open
findViewById<View>(R.id.sheet_scrim).isClickable = open
}
if (open) {
binding.homeNewPlaylistFab.open(true)
} else {
binding.homeNewPlaylistFab.close(true)
}
}
private fun handleSpeedDialBoundaryTouch(event: MotionEvent): Boolean {
val binding = binding ?: return false
if (homeModel.speedDialOpen.value && binding.homeNewPlaylistFab.isUnder(event.x, event.y)) {
// Convert absolute coordinates to relative coordinates
val offsetX = event.x - binding.homeNewPlaylistFab.x
val offsetY = event.y - binding.homeNewPlaylistFab.y
// Create a new MotionEvent with relative coordinates
val relativeEvent =
MotionEvent.obtain(
event.downTime,
event.eventTime,
event.action,
offsetX,
offsetY,
event.metaState)
// Dispatch the relative MotionEvent to the target child view
val handled = binding.homeNewPlaylistFab.dispatchTouchEvent(relativeEvent)
// Recycle the relative MotionEvent
relativeEvent.recycle()
return handled
}
return false
}
private fun handleShow(show: Show?) { private fun handleShow(show: Show?) {
when (show) { when (show) {
is Show.SongDetails -> { is Show.SongDetails -> {
@ -568,6 +775,12 @@ class HomeFragment :
private companion object { private companion object {
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop") val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
val FAB_HIDE_FROM_USER_FIELD: Method by
lazyReflectedMethod(
FloatingActionButton::class,
"hide",
FloatingActionButton.OnVisibilityChangedListener::class,
Boolean::class)
const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS" const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
} }
} }

View file

@ -156,6 +156,10 @@ constructor(
/** A marker for whether the user is fast-scrolling in the home view or not. */ /** A marker for whether the user is fast-scrolling in the home view or not. */
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
private val _speedDialOpen = MutableStateFlow(false)
/** A marker for whether the speed dial is open or not. */
val speedDialOpen: StateFlow<Boolean> = _speedDialOpen
private val _showOuter = MutableEvent<Outer>() private val _showOuter = MutableEvent<Outer>()
val showOuter: Event<Outer> val showOuter: Event<Outer>
get() = _showOuter get() = _showOuter
@ -293,6 +297,16 @@ constructor(
_isFastScrolling.value = isFastScrolling _isFastScrolling.value = isFastScrolling
} }
/**
* Update whether the speed dial is open or not.
*
* @param speedDialOpen true if the speed dial is open, false otherwise.
*/
fun setSpeedDialOpen(speedDialOpen: Boolean) {
logD("Updating speed dial state: $speedDialOpen")
_speedDialOpen.value = speedDialOpen
}
fun showSettings() { fun showSettings() {
_showOuter.put(Outer.Settings) _showOuter.put(Outer.Settings)
} }

View file

@ -0,0 +1,274 @@
/*
* Copyright (c) 2018 Auxio Project
* ThemedSpeedDialView.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.drawable.Drawable
import android.graphics.drawable.RotateDrawable
import android.os.Bundle
import android.os.Parcelable
import android.util.AttributeSet
import android.util.Property
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.FloatRange
import androidx.core.os.BundleCompat
import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.shape.MaterialShapeDrawable
import com.leinardi.android.speeddial.FabWithLabelView
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import kotlin.math.roundToInt
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels
/**
* Customized Speed Dial view with some bug fixes and Material 3 theming.
*
* Adapted from Material Files:
* https://github.com/zhanghai/MaterialFiles/tree/79f1727cec72a6a089eb495f79193f87459fc5e3
*
* MODIFICATIONS:
* - Removed dynamic theme changes based on the MaterialFile's Material 3 setting
* - Adapted code to the extensions in this project
*
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*/
class ThemedSpeedDialView : SpeedDialView {
private var mainFabAnimator: Animator? = null
private val spacingSmall = context.getDimenPixels(R.dimen.spacing_small)
private var innerChangeListener: ((Boolean) -> Unit)? = null
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(
context: Context,
attrs: AttributeSet?,
@AttrRes defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
init {
// Work around ripple bug on Android 12 when useCompatPadding = true.
// @see https://github.com/material-components/material-components-android/issues/2617
mainFab.apply {
updateLayoutParams<MarginLayoutParams> {
setMargins(context.getDimenPixels(R.dimen.spacing_medium))
}
useCompatPadding = false
}
val context = context
mainFabClosedBackgroundColor =
context
.getAttrColorCompat(com.google.android.material.R.attr.colorPrimaryContainer)
.defaultColor
mainFabClosedIconColor =
context
.getAttrColorCompat(com.google.android.material.R.attr.colorOnPrimaryContainer)
.defaultColor
mainFabOpenedBackgroundColor =
context.getAttrColorCompat(androidx.appcompat.R.attr.colorPrimary).defaultColor
mainFabOpenedIconColor =
context
.getAttrColorCompat(com.google.android.material.R.attr.colorOnPrimary)
.defaultColor
// Always use our own animation to fix the library issue that ripple is rotated as well.
val mainFabDrawable =
RotateDrawable().apply {
drawable = mainFab.drawable
toDegrees = mainFabAnimationRotateAngle
}
mainFabAnimationRotateAngle = 0f
setMainFabClosedDrawable(mainFabDrawable)
setOnChangeListener(
object : OnChangeListener {
override fun onMainActionSelected(): Boolean = false
override fun onToggleChanged(isOpen: Boolean) {
mainFabAnimator?.cancel()
mainFabAnimator =
createMainFabAnimator(isOpen).apply {
addListener(
object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
mainFabAnimator = null
}
})
start()
}
innerChangeListener?.invoke(isOpen)
}
})
}
private fun createMainFabAnimator(isOpen: Boolean): Animator =
AnimatorSet().apply {
playTogether(
ObjectAnimator.ofArgb(
mainFab,
VIEW_PROPERTY_BACKGROUND_TINT,
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor),
ObjectAnimator.ofArgb(
mainFab,
IMAGE_VIEW_PROPERTY_IMAGE_TINT,
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor),
ObjectAnimator.ofInt(
mainFab.drawable, DRAWABLE_PROPERTY_LEVEL, if (isOpen) 10000 else 0))
duration = 200
interpolator = FastOutSlowInInterpolator()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
val overlayLayout = overlayLayout
if (overlayLayout != null) {
val surfaceColor =
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
overlayLayout.setBackgroundColor(overlayColor)
}
}
private fun Int.withModulatedAlpha(
@FloatRange(from = 0.0, to = 1.0) alphaModulation: Float
): Int {
val alpha = (alpha * alphaModulation).roundToInt()
return ((alpha shl 24) or (this and 0x00FFFFFF))
}
override fun addActionItem(
actionItem: SpeedDialActionItem,
position: Int,
animate: Boolean
): FabWithLabelView? {
val context = context
val fabImageTintColor = context.getAttrColorCompat(androidx.appcompat.R.attr.colorPrimary)
val fabBackgroundColor =
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
val labelColor = context.getAttrColorCompat(android.R.attr.textColorSecondary)
val labelBackgroundColor =
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
val labelElevation =
context.getDimen(com.google.android.material.R.dimen.m3_card_elevated_elevation)
val cornerRadius = context.getDimenPixels(R.dimen.spacing_medium)
val actionItem =
SpeedDialActionItem.Builder(
actionItem.id,
// Should not be a resource, pass null to fail fast.
actionItem.getFabImageDrawable(null))
.setLabel(actionItem.getLabel(context))
.setFabImageTintColor(fabImageTintColor.defaultColor)
.setFabBackgroundColor(fabBackgroundColor.defaultColor)
.setLabelColor(labelColor.defaultColor)
.setLabelBackgroundColor(labelBackgroundColor.defaultColor)
.setLabelClickable(actionItem.isLabelClickable)
.setTheme(actionItem.theme)
.create()
return super.addActionItem(actionItem, position, animate)?.apply {
fab.apply {
updateLayoutParams<MarginLayoutParams> {
val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
setMargins(horizontalMargin, 0, horizontalMargin, 0)
}
useCompatPadding = false
}
labelBackground.apply {
useCompatPadding = false
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
background =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = labelBackgroundColor
elevation = labelElevation
setCornerSize(cornerRadius.toFloat())
}
foreground = null
(getChildAt(0) as TextView).apply {
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_LabelLarge)
}
}
}
}
override fun onSaveInstanceState(): Parcelable {
val superState =
BundleCompat.getParcelable(
super.onSaveInstanceState() as Bundle, "superState", Parcelable::class.java)
return State(superState, isOpen)
}
override fun onRestoreInstanceState(state: Parcelable) {
state as State
super.onRestoreInstanceState(state.superState)
if (state.isOpen) {
toggle(false)
}
}
fun setChangeListener(listener: ((Boolean) -> Unit)?) {
innerChangeListener = listener
}
companion object {
private val VIEW_PROPERTY_BACKGROUND_TINT =
object : Property<View, Int>(Int::class.java, "backgroundTint") {
override fun get(view: View): Int? = view.backgroundTintList!!.defaultColor
override fun set(view: View, value: Int?) {
view.backgroundTintList = ColorStateList.valueOf(value!!)
}
}
private val IMAGE_VIEW_PROPERTY_IMAGE_TINT =
object : Property<ImageView, Int>(Int::class.java, "imageTint") {
override fun get(view: ImageView): Int? = view.imageTintList!!.defaultColor
override fun set(view: ImageView, value: Int?) {
view.imageTintList = ColorStateList.valueOf(value!!)
}
}
private val DRAWABLE_PROPERTY_LEVEL =
object : Property<Drawable, Int>(Int::class.java, "level") {
override fun get(drawable: Drawable): Int? = drawable.level
override fun set(drawable: Drawable, value: Int?) {
drawable.level = value!!
}
}
}
@Parcelize private class State(val superState: Parcelable?, val isOpen: Boolean) : Parcelable
}

View file

@ -73,7 +73,7 @@ import org.oxycblt.auxio.util.getInteger
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class CoverView open class CoverView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) { FrameLayout(context, attrs, defStyleAttr) {

View file

@ -178,7 +178,11 @@ class ArtistMenuDialogFragment : MenuDialogFragment<Menu.ForArtist>() {
binding.menuInfo.text = binding.menuInfo.text =
getString( getString(
R.string.fmt_two, R.string.fmt_two,
context.getPlural(R.plurals.fmt_album_count, menu.artist.albums.size), if (menu.artist.explicitAlbums.isNotEmpty()) {
context.getPlural(R.plurals.fmt_album_count, menu.artist.explicitAlbums.size)
} else {
context.getString(R.string.def_album_count)
},
if (menu.artist.songs.isNotEmpty()) { if (menu.artist.songs.isNotEmpty()) {
context.getPlural(R.plurals.fmt_song_count, menu.artist.songs.size) context.getPlural(R.plurals.fmt_song_count, menu.artist.songs.size)
} else { } else {
@ -284,6 +288,7 @@ class PlaylistMenuDialogFragment : MenuDialogFragment<Menu.ForPlaylist>() {
R.id.action_play_next, R.id.action_play_next,
R.id.action_queue_add, R.id.action_queue_add,
R.id.action_playlist_add, R.id.action_playlist_add,
R.id.action_export,
R.id.action_share) R.id.action_share)
} else { } else {
setOf() setOf()
@ -316,6 +321,8 @@ class PlaylistMenuDialogFragment : MenuDialogFragment<Menu.ForPlaylist>() {
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
} }
R.id.action_rename -> musicModel.renamePlaylist(menu.playlist) R.id.action_rename -> musicModel.renamePlaylist(menu.playlist)
R.id.action_import -> musicModel.importPlaylist(target = menu.playlist)
R.id.action_export -> musicModel.exportPlaylist(menu.playlist)
R.id.action_delete -> musicModel.deletePlaylist(menu.playlist) R.id.action_delete -> musicModel.deletePlaylist(menu.playlist)
R.id.action_share -> requireContext().share(menu.playlist) R.id.action_share -> requireContext().share(menu.playlist)
else -> error("Unexpected menu item $item") else -> error("Unexpected menu item $item")

View file

@ -164,7 +164,11 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
binding.parentInfo.text = binding.parentInfo.text =
binding.context.getString( binding.context.getString(
R.string.fmt_two, R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size), if (artist.explicitAlbums.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size)
} else {
binding.context.getString(R.string.def_album_count)
},
if (artist.songs.isNotEmpty()) { if (artist.songs.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size) binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
} else { } else {
@ -199,7 +203,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
object : SimpleDiffCallback<Artist>() { object : SimpleDiffCallback<Artist>() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.name == newItem.name && oldItem.name == newItem.name &&
oldItem.albums.size == newItem.albums.size && oldItem.explicitAlbums.size == newItem.explicitAlbums.size &&
oldItem.songs.size == newItem.songs.size oldItem.songs.size == newItem.songs.size
} }
} }

View file

@ -317,12 +317,6 @@ interface Album : MusicParent {
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Artist : MusicParent { interface Artist : MusicParent {
/**
* All of the [Album]s this artist is credited to from [explicitAlbums] and [implicitAlbums].
* Note that any [Song] credited to this artist will have it's [Album] considered to be
* "indirectly" linked to this [Artist], and thus included in this list.
*/
val albums: Collection<Album>
/** Albums directly credited to this [Artist] via a "Album Artist" tag. */ /** Albums directly credited to this [Artist] via a "Album Artist" tag. */
val explicitAlbums: Collection<Album> val explicitAlbums: Collection<Album>
/** Albums indirectly credited to this [Artist] via an "Artist" tag. */ /** Albums indirectly credited to this [Artist] via an "Artist" tag. */
@ -354,6 +348,7 @@ interface Genre : MusicParent {
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Playlist : MusicParent { interface Playlist : MusicParent {
override val name: Name.Known
override val songs: List<Song> override val songs: List<Song>
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long

View file

@ -31,14 +31,18 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.cache.CacheRepository
import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.fs.MediaStoreExtractor import org.oxycblt.auxio.music.fs.MediaStoreExtractor
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.metadata.TagExtractor import org.oxycblt.auxio.music.metadata.TagExtractor
import org.oxycblt.auxio.music.user.MutableUserLibrary import org.oxycblt.auxio.music.user.MutableUserLibrary
import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.music.user.UserLibrary
import org.oxycblt.auxio.util.forEachWithTimeout
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -223,7 +227,8 @@ constructor(
private val mediaStoreExtractor: MediaStoreExtractor, private val mediaStoreExtractor: MediaStoreExtractor,
private val tagExtractor: TagExtractor, private val tagExtractor: TagExtractor,
private val deviceLibraryFactory: DeviceLibrary.Factory, private val deviceLibraryFactory: DeviceLibrary.Factory,
private val userLibraryFactory: UserLibrary.Factory private val userLibraryFactory: UserLibrary.Factory,
private val musicSettings: MusicSettings
) : MusicRepository { ) : MusicRepository {
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>() private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>() private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
@ -337,11 +342,11 @@ constructor(
} }
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) = override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
worker.scope.launch { indexWrapper(worker, withCache) } worker.scope.launch { indexWrapper(worker.context, this, withCache) }
private suspend fun indexWrapper(worker: MusicRepository.IndexingWorker, withCache: Boolean) { private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
try { try {
indexImpl(worker, withCache) indexImpl(context, scope, withCache)
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Got cancelled, propagate upwards to top-level co-routine. // Got cancelled, propagate upwards to top-level co-routine.
logD("Loading routine was cancelled") logD("Loading routine was cancelled")
@ -355,16 +360,29 @@ constructor(
} }
} }
private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) { private suspend fun indexImpl(context: Context, scope: CoroutineScope, withCache: Boolean) {
// TODO: Find a way to break up this monster of a method, preferably as another class.
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
// Make sure we have permissions before going forward. Theoretically this would be better // Make sure we have permissions before going forward. Theoretically this would be better
// done at the UI level, but that intertwines logic and display too much. // done at the UI level, but that intertwines logic and display too much.
if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) == if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) { PackageManager.PERMISSION_DENIED) {
logE("Permissions were not granted") logE("Permissions were not granted")
throw NoAudioPermissionException() throw NoAudioPermissionException()
} }
// Obtain configuration information
val constraints =
MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs)
val separators = Separators.from(musicSettings.separators)
val nameFactory =
if (musicSettings.intelligentSorting) {
Name.Known.IntelligentFactory
} else {
Name.Known.SimpleFactory
}
// Begin with querying MediaStore and the music cache. The former is needed for Auxio // Begin with querying MediaStore and the music cache. The former is needed for Auxio
// to figure out what songs are (probably) on the device, and the latter will be needed // to figure out what songs are (probably) on the device, and the latter will be needed
// for discovery (described later). These have no shared state, so they are done in // for discovery (described later). These have no shared state, so they are done in
@ -373,10 +391,10 @@ constructor(
emitIndexingProgress(IndexingProgress.Indeterminate) emitIndexingProgress(IndexingProgress.Indeterminate)
val mediaStoreQueryJob = val mediaStoreQueryJob =
worker.scope.async { scope.async {
val query = val query =
try { try {
mediaStoreExtractor.query() mediaStoreExtractor.query(constraints)
} catch (e: Exception) { } catch (e: Exception) {
// Normally, errors in an async call immediately bubble up to the Looper // Normally, errors in an async call immediately bubble up to the Looper
// and crash the app. Thus, we have to wrap any error into a Result // and crash the app. Thus, we have to wrap any error into a Result
@ -411,14 +429,15 @@ constructor(
// does not exist. In the latter situation, it also applies it's own (inferior) metadata. // does not exist. In the latter situation, it also applies it's own (inferior) metadata.
logD("Starting MediaStore discovery") logD("Starting MediaStore discovery")
val mediaStoreJob = val mediaStoreJob =
worker.scope.async { scope.async {
try { try {
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
} catch (e: Exception) { } catch (e: Exception) {
// To prevent a deadlock, we want to close the channel with an exception // To prevent a deadlock, we want to close the channel with an exception
// to cascade to and cancel all other routines before finally bubbling up // to cascade to and cancel all other routines before finally bubbling up
// to the main extractor loop. // to the main extractor loop.
incompleteSongs.close(e) logE("MediaStore extraction failed: $e")
incompleteSongs.close(Exception("MediaStore extraction failed: $e"))
return@async return@async
} }
incompleteSongs.close() incompleteSongs.close()
@ -428,11 +447,12 @@ constructor(
// metadata for them, and then forwards it to DeviceLibrary. // metadata for them, and then forwards it to DeviceLibrary.
logD("Starting tag extraction") logD("Starting tag extraction")
val tagJob = val tagJob =
worker.scope.async { scope.async {
try { try {
tagExtractor.consume(incompleteSongs, completeSongs) tagExtractor.consume(incompleteSongs, completeSongs)
} catch (e: Exception) { } catch (e: Exception) {
completeSongs.close(e) logE("Tag extraction failed: $e")
completeSongs.close(Exception("Tag extraction failed: $e"))
return@async return@async
} }
completeSongs.close() completeSongs.close()
@ -442,12 +462,14 @@ constructor(
// and then forwards them to the primary loading loop. // and then forwards them to the primary loading loop.
logD("Starting DeviceLibrary creation") logD("Starting DeviceLibrary creation")
val deviceLibraryJob = val deviceLibraryJob =
worker.scope.async(Dispatchers.Default) { scope.async(Dispatchers.Default) {
val deviceLibrary = val deviceLibrary =
try { try {
deviceLibraryFactory.create(completeSongs, processedSongs) deviceLibraryFactory.create(
completeSongs, processedSongs, separators, nameFactory)
} catch (e: Exception) { } catch (e: Exception) {
processedSongs.close(e) logE("DeviceLibrary creation failed: $e")
processedSongs.close(Exception("DeviceLibrary creation failed: $e"))
return@async Result.failure(e) return@async Result.failure(e)
} }
processedSongs.close() processedSongs.close()
@ -457,19 +479,20 @@ constructor(
// We could keep track of a total here, but we also need to collate this RawSong information // We could keep track of a total here, but we also need to collate this RawSong information
// for when we write the cache later on in the finalization step. // for when we write the cache later on in the finalization step.
val rawSongs = LinkedList<RawSong>() val rawSongs = LinkedList<RawSong>()
for (rawSong in processedSongs) { // Use a longer timeout so that dependent components can timeout and throw errors that
rawSongs.add(rawSong) // provide more context than if we timed out here.
processedSongs.forEachWithTimeout(20000) {
rawSongs.add(it)
// Since discovery takes up the bulk of the music loading process, we switch to // Since discovery takes up the bulk of the music loading process, we switch to
// indicating a defined amount of loaded songs in comparison to the projected amount // indicating a defined amount of loaded songs in comparison to the projected amount
// of songs that were queried. // of songs that were queried.
emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
} }
// This shouldn't occur, but keep them around just in case there's a regression. withTimeout(10000) {
// Note that DeviceLibrary might still actually be doing work (specifically parent mediaStoreJob.await()
// processing), so we don't check if it's deadlocked. tagJob.await()
check(!mediaStoreJob.isActive) { "MediaStore discovery is deadlocked" } }
check(!tagJob.isActive) { "Tag extraction is deadlocked" }
// Deliberately done after the involved initialization step to make it less likely // Deliberately done after the involved initialization step to make it less likely
// that the short-circuit occurs so quickly as to break the UI. // that the short-circuit occurs so quickly as to break the UI.
@ -493,7 +516,7 @@ constructor(
// working on parent information. // working on parent information.
logD("Starting UserLibrary query") logD("Starting UserLibrary query")
val userLibraryQueryJob = val userLibraryQueryJob =
worker.scope.async { scope.async {
val rawPlaylists = val rawPlaylists =
try { try {
userLibraryFactory.query() userLibraryFactory.query()
@ -518,7 +541,7 @@ constructor(
logD("Awaiting DeviceLibrary creation") logD("Awaiting DeviceLibrary creation")
val deviceLibrary = deviceLibraryJob.await().getOrThrow() val deviceLibrary = deviceLibraryJob.await().getOrThrow()
logD("Starting UserLibrary creation") logD("Starting UserLibrary creation")
val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary) val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory)
// Loading process is functionally done, indicate such // Loading process is functionally done, indicate such
logD( logD(

View file

@ -24,8 +24,8 @@ import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.fs.Directory import org.oxycblt.auxio.music.dirs.MusicDirectories
import org.oxycblt.auxio.music.fs.MusicDirectories import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -55,7 +55,9 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
} }
} }
class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context) : class MusicSettingsImpl
@Inject
constructor(@ApplicationContext context: Context, val documentPathFactory: DocumentPathFactory) :
Settings.Impl<MusicSettings.Listener>(context), MusicSettings { Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
private val storageManager = context.getSystemServiceCompat(StorageManager::class) private val storageManager = context.getSystemServiceCompat(StorageManager::class)
@ -64,7 +66,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
val dirs = val dirs =
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null) (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
?: emptySet()) ?: emptySet())
.mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) } .mapNotNull(documentPathFactory::fromDocumentId)
return MusicDirectories( return MusicDirectories(
dirs, dirs,
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false)) sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
@ -73,7 +75,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
sharedPreferences.edit { sharedPreferences.edit {
putStringSet( putStringSet(
getString(R.string.set_key_music_dirs), getString(R.string.set_key_music_dirs),
value.dirs.map(Directory::toDocumentTreeUri).toSet()) value.dirs.map(documentPathFactory::toDocumentId).toSet())
putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude) putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
apply() apply()
} }

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -26,10 +27,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.music.external.ExportConfig
import org.oxycblt.auxio.music.external.ExternalPlaylistManager
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
/** /**
* A [ViewModel] providing data specific to the music loading process. * A [ViewModel] providing data specific to the music loading process.
@ -42,18 +47,22 @@ class MusicViewModel
constructor( constructor(
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
private val externalPlaylistManager: ExternalPlaylistManager
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { ) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
private val _indexingState = MutableStateFlow<IndexingState?>(null) private val _indexingState = MutableStateFlow<IndexingState?>(null)
/** The current music loading state, or null if no loading is going on. */ /** The current music loading state, or null if no loading is going on. */
val indexingState: StateFlow<IndexingState?> = _indexingState val indexingState: StateFlow<IndexingState?> = _indexingState
private val _statistics = MutableStateFlow<Statistics?>(null) private val _statistics = MutableStateFlow<Statistics?>(null)
/** [Statistics] about the last completed music load. */ /** [Statistics] about the last completed music load. */
val statistics: StateFlow<Statistics?> val statistics: StateFlow<Statistics?>
get() = _statistics get() = _statistics
private val _playlistDecision = MutableEvent<PlaylistDecision>() private val _playlistDecision = MutableEvent<PlaylistDecision>()
/** /**
* A [PlaylistDecision] command that is awaiting a view capable of responding to it. Null if * A [PlaylistDecision] command that is awaiting a view capable of responding to it. Null if
* none currently. * none currently.
@ -61,6 +70,10 @@ constructor(
val playlistDecision: Event<PlaylistDecision> val playlistDecision: Event<PlaylistDecision>
get() = _playlistDecision get() = _playlistDecision
private val _playlistMessage = MutableEvent<PlaylistMessage>()
val playlistMessage: Event<PlaylistMessage>
get() = _playlistMessage
init { init {
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this) musicRepository.addIndexingListener(this)
@ -105,14 +118,103 @@ constructor(
* *
* @param name The name of the new [Playlist]. If null, the user will be prompted for one. * @param name The name of the new [Playlist]. If null, the user will be prompted for one.
* @param songs The [Song]s to be contained in the new playlist. * @param songs The [Song]s to be contained in the new playlist.
* @param reason The reason why a new playlist is being created. For all intensive purposes, you
* do not need to specify this.
*/ */
fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) { fun createPlaylist(
name: String? = null,
songs: List<Song> = listOf(),
reason: PlaylistDecision.New.Reason = PlaylistDecision.New.Reason.NEW
) {
if (name != null) { if (name != null) {
logD("Creating $name with ${songs.size} songs]") logD("Creating $name with ${songs.size} songs]")
viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) } viewModelScope.launch(Dispatchers.IO) {
musicRepository.createPlaylist(name, songs)
val message =
when (reason) {
PlaylistDecision.New.Reason.NEW -> PlaylistMessage.NewPlaylistSuccess
PlaylistDecision.New.Reason.ADD -> PlaylistMessage.AddSuccess
PlaylistDecision.New.Reason.IMPORT -> PlaylistMessage.ImportSuccess
}
_playlistMessage.put(message)
}
} else { } else {
logD("Launching creation dialog for ${songs.size} songs") logD("Launching creation dialog for ${songs.size} songs")
_playlistDecision.put(PlaylistDecision.New(songs)) _playlistDecision.put(PlaylistDecision.New(songs, null, reason))
}
}
/**
* Import a playlist from a file [Uri]. Errors pushed to [playlistMessage].
*
* @param uri The [Uri] of the file to import. If null, the user will be prompted with a file
* picker.
* @param target The [Playlist] to import to. If null, a new playlist will be created. Note the
* [Playlist] will not be renamed to the name of the imported playlist.
* @see ExternalPlaylistManager
*/
fun importPlaylist(uri: Uri? = null, target: Playlist? = null) {
if (uri != null) {
viewModelScope.launch(Dispatchers.IO) {
val importedPlaylist = externalPlaylistManager.import(uri)
if (importedPlaylist == null) {
logE("Could not import playlist")
_playlistMessage.put(PlaylistMessage.ImportFailed)
return@launch
}
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath)
if (songs.isEmpty()) {
logE("No songs found")
_playlistMessage.put(PlaylistMessage.ImportFailed)
return@launch
}
if (target !== null) {
if (importedPlaylist.name != null && importedPlaylist.name != target.name.raw) {
_playlistDecision.put(
PlaylistDecision.Rename(
target,
importedPlaylist.name,
songs,
PlaylistDecision.Rename.Reason.IMPORT))
} else {
musicRepository.rewritePlaylist(target, songs)
_playlistMessage.put(PlaylistMessage.ImportSuccess)
}
} else {
_playlistDecision.put(
PlaylistDecision.New(
songs, importedPlaylist.name, PlaylistDecision.New.Reason.IMPORT))
}
}
} else {
logD("Launching import picker")
_playlistDecision.put(PlaylistDecision.Import(target))
}
}
/**
* Export a [Playlist] to a file [Uri]. Errors pushed to [playlistMessage].
*
* @param playlist The [Playlist] to export.
* @param uri The [Uri] to export to. If null, the user will be prompted for one.
*/
fun exportPlaylist(playlist: Playlist, uri: Uri? = null, config: ExportConfig? = null) {
if (uri != null && config != null) {
logD("Exporting playlist to $uri")
viewModelScope.launch(Dispatchers.IO) {
if (externalPlaylistManager.export(playlist, uri, config)) {
_playlistMessage.put(PlaylistMessage.ExportSuccess)
} else {
_playlistMessage.put(PlaylistMessage.ExportFailed)
}
}
} else {
logD("Launching export dialog")
_playlistDecision.put(PlaylistDecision.Export(playlist))
} }
} }
@ -121,14 +223,34 @@ constructor(
* *
* @param playlist The [Playlist] to rename, * @param playlist The [Playlist] to rename,
* @param name The new name of the [Playlist]. If null, the user will be prompted for a name. * @param name The new name of the [Playlist]. If null, the user will be prompted for a name.
* @param applySongs The songs to apply to the playlist after renaming. If empty, no songs will
* be applied. This argument is internal and does not need to be specified in normal use.
* @param reason The reason why the playlist is being renamed. This argument is internal and
* does not need to be specified in normal use.
*/ */
fun renamePlaylist(playlist: Playlist, name: String? = null) { fun renamePlaylist(
playlist: Playlist,
name: String? = null,
applySongs: List<Song> = listOf(),
reason: PlaylistDecision.Rename.Reason = PlaylistDecision.Rename.Reason.ACTION
) {
if (name != null) { if (name != null) {
logD("Renaming $playlist to $name") logD("Renaming $playlist to $name")
viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) } viewModelScope.launch(Dispatchers.IO) {
musicRepository.renamePlaylist(playlist, name)
if (applySongs.isNotEmpty()) {
musicRepository.rewritePlaylist(playlist, applySongs)
}
val message =
when (reason) {
PlaylistDecision.Rename.Reason.ACTION -> PlaylistMessage.RenameSuccess
PlaylistDecision.Rename.Reason.IMPORT -> PlaylistMessage.ImportSuccess
}
_playlistMessage.put(message)
}
} else { } else {
logD("Launching rename dialog for $playlist") logD("Launching rename dialog for $playlist")
_playlistDecision.put(PlaylistDecision.Rename(playlist)) _playlistDecision.put(PlaylistDecision.Rename(playlist, null, applySongs, reason))
} }
} }
@ -137,12 +259,16 @@ constructor(
* *
* @param playlist The playlist to delete. * @param playlist The playlist to delete.
* @param rude Whether to immediately delete the playlist or prompt the user first. This should * @param rude Whether to immediately delete the playlist or prompt the user first. This should
* be false at almost all times. * be false at almost all times. This argument is internal and does not need to be specified
* in normal use.
*/ */
fun deletePlaylist(playlist: Playlist, rude: Boolean = false) { fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
if (rude) { if (rude) {
logD("Deleting $playlist") logD("Deleting $playlist")
viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) } viewModelScope.launch(Dispatchers.IO) {
musicRepository.deletePlaylist(playlist)
_playlistMessage.put(PlaylistMessage.DeleteSuccess)
}
} else { } else {
logD("Launching deletion dialog for $playlist") logD("Launching deletion dialog for $playlist")
_playlistDecision.put(PlaylistDecision.Delete(playlist)) _playlistDecision.put(PlaylistDecision.Delete(playlist))
@ -202,7 +328,10 @@ constructor(
fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) { fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) {
if (playlist != null) { if (playlist != null) {
logD("Adding ${songs.size} songs to $playlist") logD("Adding ${songs.size} songs to $playlist")
viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) } viewModelScope.launch(Dispatchers.IO) {
musicRepository.addToPlaylist(songs, playlist)
_playlistMessage.put(PlaylistMessage.AddSuccess)
}
} else { } else {
logD("Launching addition dialog for songs=${songs.size}") logD("Launching addition dialog for songs=${songs.size}")
_playlistDecision.put(PlaylistDecision.Add(songs)) _playlistDecision.put(PlaylistDecision.Add(songs))
@ -237,15 +366,50 @@ sealed interface PlaylistDecision {
* Navigate to a dialog that allows a user to pick a name for a new [Playlist]. * Navigate to a dialog that allows a user to pick a name for a new [Playlist].
* *
* @param songs The [Song]s to contain in the new [Playlist]. * @param songs The [Song]s to contain in the new [Playlist].
* @param template An existing playlist name that should be editable in the opened dialog. If
* null, a placeholder should be created and shown as a hint instead.
* @param context The context in which this decision is being fulfilled.
*/ */
data class New(val songs: List<Song>) : PlaylistDecision data class New(val songs: List<Song>, val template: String?, val reason: Reason) :
PlaylistDecision {
enum class Reason {
NEW,
ADD,
IMPORT
}
}
/**
* Navigate to a file picker to import a playlist from.
*
* @param target The [Playlist] to import to. If null, then the file imported will create a new
* playlist.
*/
data class Import(val target: Playlist?) : PlaylistDecision
/** /**
* Navigate to a dialog that allows a user to rename an existing [Playlist]. * Navigate to a dialog that allows a user to rename an existing [Playlist].
* *
* @param playlist The playlist to act on. * @param playlist The playlist to act on.
*/ */
data class Rename(val playlist: Playlist) : PlaylistDecision data class Rename(
val playlist: Playlist,
val template: String?,
val applySongs: List<Song>,
val reason: Reason
) : PlaylistDecision {
enum class Reason {
ACTION,
IMPORT
}
}
/**
* Navigate to a dialog that allows the user to export a [Playlist].
*
* @param playlist The [Playlist] to export.
*/
data class Export(val playlist: Playlist) : PlaylistDecision
/** /**
* Navigate to a dialog that confirms the deletion of an existing [Playlist]. * Navigate to a dialog that confirms the deletion of an existing [Playlist].
@ -261,3 +425,47 @@ sealed interface PlaylistDecision {
*/ */
data class Add(val songs: List<Song>) : PlaylistDecision data class Add(val songs: List<Song>) : PlaylistDecision
} }
sealed interface PlaylistMessage {
val stringRes: Int
data object NewPlaylistSuccess : PlaylistMessage {
override val stringRes: Int
get() = R.string.lng_playlist_created
}
data object ImportSuccess : PlaylistMessage {
override val stringRes: Int
get() = R.string.lng_playlist_imported
}
data object ImportFailed : PlaylistMessage {
override val stringRes: Int
get() = R.string.err_import_failed
}
data object RenameSuccess : PlaylistMessage {
override val stringRes: Int
get() = R.string.lng_playlist_renamed
}
data object DeleteSuccess : PlaylistMessage {
override val stringRes: Int
get() = R.string.lng_playlist_deleted
}
data object AddSuccess : PlaylistMessage {
override val stringRes: Int
get() = R.string.lng_playlist_added
}
data object ExportSuccess : PlaylistMessage {
override val stringRes: Int
get() = R.string.lng_playlist_exported
}
data object ExportFailed : PlaylistMessage {
override val stringRes: Int
get() = R.string.err_export_failed
}
}

View file

@ -32,12 +32,12 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast
/** /**
* A dialog that allows the user to pick a specific playlist to add song(s) to. * A dialog that allows the user to pick a specific playlist to add song(s) to.
@ -86,7 +86,6 @@ class AddToPlaylistDialog :
override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) { override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) {
musicModel.addToPlaylist(pickerModel.currentSongsToAdd.value ?: return, item.playlist) musicModel.addToPlaylist(pickerModel.currentSongsToAdd.value ?: return, item.playlist)
requireContext().showToast(R.string.lng_playlist_added)
findNavController().navigateUp() findNavController().navigateUp()
} }
@ -100,7 +99,8 @@ class AddToPlaylistDialog :
val songs = pickerModel.currentSongsToAdd.value ?: return val songs = pickerModel.currentSongsToAdd.value ?: return
findNavController() findNavController()
.navigateSafe( .navigateSafe(
AddToPlaylistDialogDirections.newPlaylist(songs.map { it.uid }.toTypedArray())) AddToPlaylistDialogDirections.newPlaylist(
songs.map { it.uid }.toTypedArray(), null, PlaylistDecision.New.Reason.ADD))
} }
private fun updatePendingSongs(songs: List<Song>?) { private fun updatePendingSongs(songs: List<Song>?) {

View file

@ -33,7 +33,6 @@ import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -56,7 +55,6 @@ class DeletePlaylistDialog : ViewBindingMaterialDialogFragment<DialogDeletePlayl
// Now we can delete the playlist for-real this time. // Now we can delete the playlist for-real this time.
musicModel.deletePlaylist( musicModel.deletePlaylist(
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true) unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)
requireContext().showToast(R.string.lng_playlist_deleted)
} }
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
} }

View file

@ -0,0 +1,153 @@
/*
* Copyright (c) 2023 Auxio Project
* ExportPlaylistDialog.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.decision
import android.os.Bundle
import android.view.LayoutInflater
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.external.ExportConfig
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A dialog that allows the user to configure how a playlist will be exported to a file.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class ExportPlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistExportBinding>() {
private val musicModel: MusicViewModel by activityViewModels()
private val pickerModel: PlaylistPickerViewModel by viewModels()
private var createDocumentLauncher: ActivityResultLauncher<String>? = null
// Information about what playlist to name for is initially within the navigation arguments
// as UIDs, as that is the only safe way to parcel playlist information.
private val args: ExportPlaylistDialogArgs by navArgs()
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
.setTitle(R.string.lbl_export_playlist)
.setPositiveButton(R.string.lbl_export, null)
.setNegativeButton(R.string.lbl_cancel, null)
}
override fun onCreateBinding(inflater: LayoutInflater) =
DialogPlaylistExportBinding.inflate(inflater)
override fun onBindingCreated(
binding: DialogPlaylistExportBinding,
savedInstanceState: Bundle?
) {
// --- UI SETUP ---
createDocumentLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument(M3U.MIME_TYPE)) { uri
->
if (uri == null) {
logW("No URI returned from file picker")
return@registerForActivityResult
}
val playlist = pickerModel.currentPlaylistToExport.value
if (playlist == null) {
logW("No playlist to export")
findNavController().navigateUp()
return@registerForActivityResult
}
logD("Received playlist URI $uri")
musicModel.exportPlaylist(playlist, uri, pickerModel.currentExportConfig.value)
findNavController().navigateUp()
}
binding.exportPathsGroup.addOnButtonCheckedListener { group, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val current = pickerModel.currentExportConfig.value
pickerModel.setExportConfig(
current.copy(absolute = checkedId == R.id.export_absolute_paths))
}
binding.exportWindowsPaths.setOnClickListener { _ ->
val current = pickerModel.currentExportConfig.value
pickerModel.setExportConfig(current.copy(windowsPaths = !current.windowsPaths))
}
// --- VIEWMODEL SETUP ---
musicModel.playlistDecision.consume()
pickerModel.setPlaylistToExport(args.playlistUid)
collectImmediately(pickerModel.currentPlaylistToExport, ::updatePlaylistToExport)
collectImmediately(pickerModel.currentExportConfig, ::updateExportConfig)
}
override fun onStart() {
super.onStart()
(requireDialog() as AlertDialog)
.getButton(AlertDialog.BUTTON_POSITIVE)
.setOnClickListener { _ ->
val pendingPlaylist = unlikelyToBeNull(pickerModel.currentPlaylistToExport.value)
val fileName =
pendingPlaylist.name
.resolve(requireContext())
.replace(SAFE_FILE_NAME_REGEX, "_") + ".m3u"
requireNotNull(createDocumentLauncher) {
"Create document launcher was not available"
}
.launch(fileName)
}
}
private fun updatePlaylistToExport(playlist: Playlist?) {
if (playlist == null) {
logD("No playlist to export, leaving")
findNavController().navigateUp()
return
}
}
private fun updateExportConfig(config: ExportConfig) {
val binding = requireBinding()
binding.exportPathsGroup.check(
if (config.absolute) {
R.id.export_absolute_paths
} else {
R.id.export_relative_paths
})
logD(config.windowsPaths)
binding.exportWindowsPaths.isChecked = config.windowsPaths
}
private companion object {
val SAFE_FILE_NAME_REGEX = Regex("[^a-zA-Z0-9.-]")
}
}

View file

@ -19,6 +19,7 @@
package org.oxycblt.auxio.music.decision package org.oxycblt.auxio.music.decision
import android.os.Bundle import android.os.Bundle
import android.text.Editable
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
@ -30,10 +31,10 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -48,12 +49,18 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNameBi
// Information about what playlist to name for is initially within the navigation arguments // Information about what playlist to name for is initially within the navigation arguments
// as UIDs, as that is the only safe way to parcel playlist information. // as UIDs, as that is the only safe way to parcel playlist information.
private val args: NewPlaylistDialogArgs by navArgs() private val args: NewPlaylistDialogArgs by navArgs()
private var initializedField = false
override fun onConfigDialog(builder: AlertDialog.Builder) { override fun onConfigDialog(builder: AlertDialog.Builder) {
builder builder
.setTitle(R.string.lbl_new_playlist) .setTitle(
when (args.reason) {
PlaylistDecision.New.Reason.NEW,
PlaylistDecision.New.Reason.ADD -> R.string.lbl_new_playlist
PlaylistDecision.New.Reason.IMPORT -> R.string.lbl_import_playlist
})
.setPositiveButton(R.string.lbl_ok) { _, _ -> .setPositiveButton(R.string.lbl_ok) { _, _ ->
val pendingPlaylist = unlikelyToBeNull(pickerModel.currentPendingPlaylist.value) val pendingPlaylist = unlikelyToBeNull(pickerModel.currentPendingNewPlaylist.value)
val name = val name =
when (val chosenName = pickerModel.chosenName.value) { when (val chosenName = pickerModel.chosenName.value) {
is ChosenName.Valid -> chosenName.value is ChosenName.Valid -> chosenName.value
@ -61,8 +68,7 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNameBi
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
// TODO: Navigate to playlist if there are songs in it // TODO: Navigate to playlist if there are songs in it
musicModel.createPlaylist(name, pendingPlaylist.songs) musicModel.createPlaylist(name, pendingPlaylist.songs, pendingPlaylist.reason)
requireContext().showToast(R.string.lng_playlist_created)
findNavController().apply { findNavController().apply {
navigateUp() navigateUp()
// Do an additional navigation away from the playlist addition dialog, if // Do an additional navigation away from the playlist addition dialog, if
@ -84,23 +90,37 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNameBi
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
musicModel.playlistDecision.consume() musicModel.playlistDecision.consume()
pickerModel.setPendingPlaylist(requireContext(), args.songUids) pickerModel.setPendingPlaylist(requireContext(), args.songUids, args.template, args.reason)
collectImmediately(pickerModel.currentPendingPlaylist, ::updatePendingPlaylist)
collectImmediately(pickerModel.currentPendingNewPlaylist, ::updatePendingPlaylist)
collectImmediately(pickerModel.chosenName, ::updateChosenName) collectImmediately(pickerModel.chosenName, ::updateChosenName)
} }
private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) { private fun updatePendingPlaylist(pendingNewPlaylist: PendingNewPlaylist?) {
if (pendingPlaylist == null) { if (pendingNewPlaylist == null) {
logD("No playlist to create, leaving") logD("No playlist to create, leaving")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
val binding = requireBinding()
requireBinding().playlistName.hint = pendingPlaylist.preferredName if (pendingNewPlaylist.template != null) {
if (initializedField) return
initializedField = true
// Need to convert args.existingName to an Editable
if (args.template != null) {
binding.playlistName.text = EDITABLE_FACTORY.newEditable(args.template)
}
} else {
binding.playlistName.hint = pendingNewPlaylist.preferredName
}
} }
private fun updateChosenName(chosenName: ChosenName) { private fun updateChosenName(chosenName: ChosenName) {
(dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled =
chosenName is ChosenName.Valid || chosenName is ChosenName.Empty chosenName is ChosenName.Valid || chosenName is ChosenName.Empty
} }
private companion object {
val EDITABLE_FACTORY: Editable.Factory = Editable.Factory.getInstance()
}
} }

View file

@ -30,7 +30,9 @@ import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.ExportConfig
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -43,15 +45,25 @@ import org.oxycblt.auxio.util.logW
@HiltViewModel @HiltViewModel
class PlaylistPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : class PlaylistPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
ViewModel(), MusicRepository.UpdateListener { ViewModel(), MusicRepository.UpdateListener {
private val _currentPendingPlaylist = MutableStateFlow<PendingPlaylist?>(null) private val _currentPendingNewPlaylist = MutableStateFlow<PendingNewPlaylist?>(null)
/** A new [Playlist] having it's name chosen by the user. Null if none yet. */ /** A new [Playlist] having it's name chosen by the user. Null if none yet. */
val currentPendingPlaylist: StateFlow<PendingPlaylist?> val currentPendingNewPlaylist: StateFlow<PendingNewPlaylist?>
get() = _currentPendingPlaylist get() = _currentPendingNewPlaylist
private val _currentPlaylistToRename = MutableStateFlow<Playlist?>(null) private val _currentPendingRenamePlaylist = MutableStateFlow<PendingRenamePlaylist?>(null)
/** An existing [Playlist] that is being renamed. Null if none yet. */ /** An existing [Playlist] that is being renamed. Null if none yet. */
val currentPlaylistToRename: StateFlow<Playlist?> val currentPendingRenamePlaylist: StateFlow<PendingRenamePlaylist?>
get() = _currentPlaylistToRename get() = _currentPendingRenamePlaylist
private val _currentPlaylistToExport = MutableStateFlow<Playlist?>(null)
/** An existing [Playlist] that is being exported. Null if none yet. */
val currentPlaylistToExport: StateFlow<Playlist?>
get() = _currentPlaylistToExport
private val _currentExportConfig = MutableStateFlow(DEFAULT_EXPORT_CONFIG)
/** The current [ExportConfig] to use when exporting a playlist. */
val currentExportConfig: StateFlow<ExportConfig>
get() = _currentExportConfig
private val _currentPlaylistToDelete = MutableStateFlow<Playlist?>(null) private val _currentPlaylistToDelete = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] that needs it's deletion confirmed. Null if none yet. */ /** The current [Playlist] that needs it's deletion confirmed. Null if none yet. */
@ -59,7 +71,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
get() = _currentPlaylistToDelete get() = _currentPlaylistToDelete
private val _chosenName = MutableStateFlow<ChosenName>(ChosenName.Empty) private val _chosenName = MutableStateFlow<ChosenName>(ChosenName.Empty)
/** The users chosen name for [currentPendingPlaylist] or [currentPlaylistToRename]. */ /** The users chosen name for [currentPendingNewPlaylist] or [currentPendingRenamePlaylist]. */
val chosenName: StateFlow<ChosenName> val chosenName: StateFlow<ChosenName>
get() = _chosenName get() = _chosenName
@ -81,13 +93,15 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
var refreshChoicesWith: List<Song>? = null var refreshChoicesWith: List<Song>? = null
val deviceLibrary = musicRepository.deviceLibrary val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) { if (changes.deviceLibrary && deviceLibrary != null) {
_currentPendingPlaylist.value = _currentPendingNewPlaylist.value =
_currentPendingPlaylist.value?.let { pendingPlaylist -> _currentPendingNewPlaylist.value?.let { pendingPlaylist ->
PendingPlaylist( PendingNewPlaylist(
pendingPlaylist.preferredName, pendingPlaylist.preferredName,
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) },
pendingPlaylist.template,
pendingPlaylist.reason)
} }
logD("Updated pending playlist: ${_currentPendingPlaylist.value?.preferredName}") logD("Updated pending playlist: ${_currentPendingNewPlaylist.value?.preferredName}")
_currentSongsToAdd.value = _currentSongsToAdd.value =
_currentSongsToAdd.value?.let { pendingSongs -> _currentSongsToAdd.value?.let { pendingSongs ->
@ -110,6 +124,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
logD("Updated chosen name to $chosenName") logD("Updated chosen name to $chosenName")
refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value
// TODO: Add music syncing for other playlist states here
_currentPlaylistToExport.value =
_currentPlaylistToExport.value?.let { playlist ->
musicRepository.userLibrary?.findPlaylist(playlist.uid)
}
logD("Updated playlist to export to ${_currentPlaylistToExport.value}")
} }
refreshChoicesWith?.let(::refreshPlaylistChoices) refreshChoicesWith?.let(::refreshPlaylistChoices)
@ -120,12 +142,18 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
/** /**
* Set a new [currentPendingPlaylist] from a new batch of pending [Song] [Music.UID]s. * Set a new [currentPendingNewPlaylist] from a new batch of pending [Song] [Music.UID]s.
* *
* @param context [Context] required to generate a playlist name. * @param context [Context] required to generate a playlist name.
* @param songUids The [Music.UID]s of songs to be present in the playlist. * @param songUids The [Music.UID]s of songs to be present in the playlist.
* @param reason The reason the playlist is being created.
*/ */
fun setPendingPlaylist(context: Context, songUids: Array<Music.UID>) { fun setPendingPlaylist(
context: Context,
songUids: Array<Music.UID>,
template: String?,
reason: PlaylistDecision.New.Reason
) {
logD("Opening ${songUids.size} songs to create a playlist from") logD("Opening ${songUids.size} songs to create a playlist from")
val userLibrary = musicRepository.userLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return
val songs = val songs =
@ -147,9 +175,9 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
possibleName possibleName
} }
_currentPendingPlaylist.value = _currentPendingNewPlaylist.value =
if (possibleName != null && songs != null) { if (possibleName != null && songs != null) {
PendingPlaylist(possibleName, songs) PendingNewPlaylist(possibleName, songs, template, reason)
} else { } else {
logW("Given song UIDs to create were invalid") logW("Given song UIDs to create were invalid")
null null
@ -157,20 +185,59 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
/** /**
* Set a new [currentPlaylistToRename] from a [Playlist] [Music.UID]. * Set a new [currentPendingRenamePlaylist] from a [Playlist] [Music.UID].
* *
* @param playlistUid The [Music.UID]s of the [Playlist] to rename. * @param playlistUid The [Music.UID]s of the [Playlist] to rename.
*/ */
fun setPlaylistToRename(playlistUid: Music.UID) { fun setPlaylistToRename(
playlistUid: Music.UID,
applySongUids: Array<Music.UID>,
template: String?,
reason: PlaylistDecision.Rename.Reason
) {
logD("Opening playlist $playlistUid to rename") logD("Opening playlist $playlistUid to rename")
_currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid) val playlist = musicRepository.userLibrary?.findPlaylist(playlistUid)
if (_currentPlaylistToDelete.value == null) { val applySongs =
logW("Given playlist UID to rename was invalid") musicRepository.deviceLibrary?.let { applySongUids.mapNotNull(it::findSong) }
_currentPendingRenamePlaylist.value =
if (playlist != null && applySongs != null) {
PendingRenamePlaylist(playlist, applySongs, template, reason)
} else {
logW("Given playlist UID to rename was invalid")
null
}
}
/**
* Set a new [currentPlaylisttoExport] from a [Playlist] [Music.UID].
*
* @param playlistUid The [Music.UID] of the [Playlist] to export.
*/
fun setPlaylistToExport(playlistUid: Music.UID) {
logD("Opening playlist $playlistUid to export")
// TODO: Add this guard to the rest of the methods here
if (_currentPlaylistToExport.value?.uid == playlistUid) return
_currentPlaylistToExport.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
if (_currentPlaylistToExport.value == null) {
logW("Given playlist UID to export was invalid")
} else {
_currentExportConfig.value = DEFAULT_EXPORT_CONFIG
} }
} }
/** /**
* Set a new [currentPendingPlaylist] from a new [Playlist] [Music.UID]. * Update [currentExportConfig] based on new user input.
*
* @param exportConfig The new [ExportConfig] to use.
*/
fun setExportConfig(exportConfig: ExportConfig) {
logD("Setting export config to $exportConfig")
_currentExportConfig.value = exportConfig
}
/**
* Set a new [currentPendingNewPlaylist] from a new [Playlist] [Music.UID].
* *
* @param playlistUid The [Music.UID] of the [Playlist] to delete. * @param playlistUid The [Music.UID] of the [Playlist] to delete.
*/ */
@ -238,16 +305,33 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
PlaylistChoice(it, songs.all(songSet::contains)) PlaylistChoice(it, songs.all(songSet::contains))
} }
} }
private companion object {
private val DEFAULT_EXPORT_CONFIG = ExportConfig(absolute = false, windowsPaths = false)
}
} }
/** /**
* Represents a playlist that will be created as soon as a name is chosen. * Represents a playlist that will be created as soon as a name is chosen.
* *
* @param preferredName The name to be used by default if no other name is chosen. * @param preferredName The name to be used by default if no other name is chosen.
* @param songs The [Song]s to be contained in the [PendingPlaylist] * @param songs The [Song]s to be contained in the [PendingNewPlaylist]
* @param reason The reason the playlist is being created.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class PendingPlaylist(val preferredName: String, val songs: List<Song>) data class PendingNewPlaylist(
val preferredName: String,
val songs: List<Song>,
val template: String?,
val reason: PlaylistDecision.New.Reason
)
data class PendingRenamePlaylist(
val playlist: Playlist,
val applySongs: List<Song>,
val template: String?,
val reason: PlaylistDecision.Rename.Reason
)
/** /**
* Represents the (processed) user input from the playlist naming dialogs. * Represents the (processed) user input from the playlist naming dialogs.

View file

@ -30,11 +30,9 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -55,10 +53,14 @@ class RenamePlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNam
builder builder
.setTitle(R.string.lbl_rename_playlist) .setTitle(R.string.lbl_rename_playlist)
.setPositiveButton(R.string.lbl_ok) { _, _ -> .setPositiveButton(R.string.lbl_ok) { _, _ ->
val playlist = unlikelyToBeNull(pickerModel.currentPlaylistToRename.value) val pendingRenamePlaylist =
unlikelyToBeNull(pickerModel.currentPendingRenamePlaylist.value)
val chosenName = pickerModel.chosenName.value as ChosenName.Valid val chosenName = pickerModel.chosenName.value as ChosenName.Valid
musicModel.renamePlaylist(playlist, chosenName.value) musicModel.renamePlaylist(
requireContext().showToast(R.string.lng_playlist_renamed) pendingRenamePlaylist.playlist,
chosenName.value,
pendingRenamePlaylist.applySongs,
pendingRenamePlaylist.reason)
findNavController().navigateUp() findNavController().navigateUp()
} }
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
@ -75,20 +77,23 @@ class RenamePlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNam
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
musicModel.playlistDecision.consume() musicModel.playlistDecision.consume()
pickerModel.setPlaylistToRename(args.playlistUid) pickerModel.setPlaylistToRename(
collectImmediately(pickerModel.currentPlaylistToRename, ::updatePlaylistToRename) args.playlistUid, args.applySongUids, args.template, args.reason)
collectImmediately(pickerModel.currentPendingRenamePlaylist, ::updatePlaylistToRename)
collectImmediately(pickerModel.chosenName, ::updateChosenName) collectImmediately(pickerModel.chosenName, ::updateChosenName)
} }
private fun updatePlaylistToRename(playlist: Playlist?) { private fun updatePlaylistToRename(pendingRenamePlaylist: PendingRenamePlaylist?) {
if (playlist == null) { if (pendingRenamePlaylist == null) {
// Nothing to rename anymore. // Nothing to rename anymore.
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
if (!initializedField) { if (!initializedField) {
val default = playlist.name.resolve(requireContext()) val default =
pendingRenamePlaylist.template
?: pendingRenamePlaylist.playlist.name.resolve(requireContext())
logD("Name input is not initialized, setting to $default") logD("Name input is not initialized, setting to $default")
requireBinding().playlistName.setText(default) requireBinding().playlistName.setText(default)
initializedField = true initializedField = true

View file

@ -28,13 +28,15 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.music.fs.useQuery import org.oxycblt.auxio.music.fs.useQuery
import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.util.forEachWithTimeout
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.sendWithTimeout
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -73,6 +75,14 @@ interface DeviceLibrary {
*/ */
fun findSongForUri(context: Context, uri: Uri): Song? fun findSongForUri(context: Context, uri: Uri): Song?
/**
* Find a [Song] instance corresponding to the given [Path].
*
* @param path [Path] to search for.
* @return A [Song] corresponding to the given [Path], or null if one could not be found.
*/
fun findSongByPath(path: Path): Song?
/** /**
* Find a [Album] instance corresponding to the given [Music.UID]. * Find a [Album] instance corresponding to the given [Music.UID].
* *
@ -110,19 +120,19 @@ interface DeviceLibrary {
suspend fun create( suspend fun create(
rawSongs: Channel<RawSong>, rawSongs: Channel<RawSong>,
processedSongs: Channel<RawSong>, processedSongs: Channel<RawSong>,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibraryImpl ): DeviceLibraryImpl
} }
} }
class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) : class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
DeviceLibrary.Factory {
override suspend fun create( override suspend fun create(
rawSongs: Channel<RawSong>, rawSongs: Channel<RawSong>,
processedSongs: Channel<RawSong> processedSongs: Channel<RawSong>,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibraryImpl { ): DeviceLibraryImpl {
val nameFactory = Name.Known.Factory.from(musicSettings)
val separators = Separators.from(musicSettings)
val songGrouping = mutableMapOf<Music.UID, SongImpl>() val songGrouping = mutableMapOf<Music.UID, SongImpl>()
val albumGrouping = mutableMapOf<RawAlbum.Key, Grouping<RawAlbum, SongImpl>>() val albumGrouping = mutableMapOf<RawAlbum.Key, Grouping<RawAlbum, SongImpl>>()
val artistGrouping = mutableMapOf<RawArtist.Key, Grouping<RawArtist, Music>>() val artistGrouping = mutableMapOf<RawArtist.Key, Grouping<RawArtist, Music>>()
@ -131,7 +141,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
// TODO: Use comparators here // TODO: Use comparators here
// All music information is grouped as it is indexed by other components. // All music information is grouped as it is indexed by other components.
for (rawSong in rawSongs) { rawSongs.forEachWithTimeout { rawSong ->
val song = SongImpl(rawSong, nameFactory, separators) val song = SongImpl(rawSong, nameFactory, separators)
// At times the indexer produces duplicate songs, try to filter these. Comparing by // At times the indexer produces duplicate songs, try to filter these. Comparing by
// UID is sufficient for something like this, and also prevents collisions from // UID is sufficient for something like this, and also prevents collisions from
@ -143,8 +153,8 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
// We still want to say that we "processed" the song so that the user doesn't // We still want to say that we "processed" the song so that the user doesn't
// get confused at why the bar was only partly filled by the end of the loading // get confused at why the bar was only partly filled by the end of the loading
// process. // process.
processedSongs.send(rawSong) processedSongs.sendWithTimeout(rawSong)
continue return@forEachWithTimeout
} }
songGrouping[song.uid] = song songGrouping[song.uid] = song
@ -207,7 +217,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
} }
} }
processedSongs.send(rawSong) processedSongs.sendWithTimeout(rawSong)
} }
// Now that all songs are processed, also process albums and group them into their // Now that all songs are processed, also process albums and group them into their
@ -265,6 +275,7 @@ class DeviceLibraryImpl(
) : DeviceLibrary { ) : DeviceLibrary {
// Use a mapping to make finding information based on it's UID much faster. // Use a mapping to make finding information based on it's UID much faster.
private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } } private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } }
private val songPathMap = buildMap { songs.forEach { put(it.path, it) } }
private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } } private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } }
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } } private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } } private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
@ -286,6 +297,8 @@ class DeviceLibraryImpl(
override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid] override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid]
override fun findSongByPath(path: Path) = songPathMap[path]
override fun findSongForUri(context: Context, uri: Uri) = override fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery( context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor -> uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->

View file

@ -28,7 +28,6 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.fs.toCoverUri import org.oxycblt.auxio.music.fs.toCoverUri
import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Date
@ -75,41 +74,31 @@ class SongImpl(
} }
override val name = override val name =
nameFactory.parse( nameFactory.parse(
requireNotNull(rawSong.name) { "Invalid raw ${rawSong.fileName}: No title" }, requireNotNull(rawSong.name) { "Invalid raw ${rawSong.path}: No title" },
rawSong.sortName) rawSong.sortName)
override val track = rawSong.track override val track = rawSong.track
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) } override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
override val date = rawSong.date override val date = rawSong.date
override val uri = override val uri =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.fileName}: No id" } requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }.toAudioUri()
.toAudioUri() override val path = requireNotNull(rawSong.path) { "Invalid raw ${rawSong.path}: No path" }
override val path =
Path(
name =
requireNotNull(rawSong.fileName) {
"Invalid raw ${rawSong.fileName}: No display name"
},
parent =
requireNotNull(rawSong.directory) {
"Invalid raw ${rawSong.fileName}: No parent directory"
})
override val mimeType = override val mimeType =
MimeType( MimeType(
fromExtension = fromExtension =
requireNotNull(rawSong.extensionMimeType) { requireNotNull(rawSong.extensionMimeType) {
"Invalid raw ${rawSong.fileName}: No mime type" "Invalid raw ${rawSong.path}: No mime type"
}, },
fromFormat = null) fromFormat = null)
override val size = requireNotNull(rawSong.size) { "Invalid raw ${rawSong.fileName}: No size" } override val size = requireNotNull(rawSong.size) { "Invalid raw ${rawSong.path}: No size" }
override val durationMs = override val durationMs =
requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.fileName}: No duration" } requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.path}: No duration" }
override val replayGainAdjustment = override val replayGainAdjustment =
ReplayGainAdjustment( ReplayGainAdjustment(
track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment) track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment)
override val dateAdded = override val dateAdded =
requireNotNull(rawSong.dateAdded) { "Invalid raw ${rawSong.fileName}: No date added" } requireNotNull(rawSong.dateAdded) { "Invalid raw ${rawSong.path}: No date added" }
private var _album: AlbumImpl? = null private var _album: AlbumImpl? = null
override val album: Album override val album: Album
@ -150,37 +139,37 @@ class SongImpl(
val artistSortNames = separators.split(rawSong.artistSortNames) val artistSortNames = separators.split(rawSong.artistSortNames)
val rawIndividualArtists = val rawIndividualArtists =
artistNames artistNames
.mapIndexedTo(mutableSetOf()) { i, name -> .mapIndexed { i, name ->
RawArtist( RawArtist(
artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name, name,
artistSortNames.getOrNull(i)) artistSortNames.getOrNull(i))
} }
.toList() .distinctBy { it.key }
val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds) val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds)
val albumArtistNames = separators.split(rawSong.albumArtistNames) val albumArtistNames = separators.split(rawSong.albumArtistNames)
val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames) val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames)
val rawAlbumArtists = val rawAlbumArtists =
albumArtistNames albumArtistNames
.mapIndexedTo(mutableSetOf()) { i, name -> .mapIndexed { i, name ->
RawArtist( RawArtist(
albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name, name,
albumArtistSortNames.getOrNull(i)) albumArtistSortNames.getOrNull(i))
} }
.toList() .distinctBy { it.key }
rawAlbum = rawAlbum =
RawAlbum( RawAlbum(
mediaStoreId = mediaStoreId =
requireNotNull(rawSong.albumMediaStoreId) { requireNotNull(rawSong.albumMediaStoreId) {
"Invalid raw ${rawSong.fileName}: No album id" "Invalid raw ${rawSong.path}: No album id"
}, },
musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(), musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
name = name =
requireNotNull(rawSong.albumName) { requireNotNull(rawSong.albumName) {
"Invalid raw ${rawSong.fileName}: No album name" "Invalid raw ${rawSong.path}: No album name"
}, },
sortName = rawSong.albumSortName, sortName = rawSong.albumSortName,
releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)), releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)),
@ -195,10 +184,7 @@ class SongImpl(
val genreNames = val genreNames =
(rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames)) (rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames))
rawGenres = rawGenres =
genreNames genreNames.map { RawGenre(it) }.distinctBy { it.key }.ifEmpty { listOf(RawGenre()) }
.mapTo(mutableSetOf()) { RawGenre(it) }
.toList()
.ifEmpty { listOf(RawGenre()) }
hashCode = 31 * hashCode + rawSong.hashCode() hashCode = 31 * hashCode + rawSong.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode()
@ -250,11 +236,11 @@ class SongImpl(
* @return This instance upcasted to [Song]. * @return This instance upcasted to [Song].
*/ */
fun finalize(): Song { fun finalize(): Song {
checkNotNull(_album) { "Malformed song ${path.name}: No album" } checkNotNull(_album) { "Malformed song ${path}: No album" }
check(_artists.isNotEmpty()) { "Malformed song ${path.name}: No artists" } check(_artists.isNotEmpty()) { "Malformed song ${path}: No artists" }
check(_artists.size == rawArtists.size) { check(_artists.size == rawArtists.size) {
"Malformed song ${path.name}: Artist grouping mismatch" "Malformed song ${path}: Artist grouping mismatch"
} }
for (i in _artists.indices) { for (i in _artists.indices) {
// Non-destructively reorder the linked artists so that they align with // Non-destructively reorder the linked artists so that they align with
@ -265,10 +251,8 @@ class SongImpl(
_artists[i] = other _artists[i] = other
} }
check(_genres.isNotEmpty()) { "Malformed song ${path.name}: No genres" } check(_genres.isNotEmpty()) { "Malformed song ${path}: No genres" }
check(_genres.size == rawGenres.size) { check(_genres.size == rawGenres.size) { "Malformed song ${path}: Genre grouping mismatch" }
"Malformed song ${path.name}: Genre grouping mismatch"
}
for (i in _genres.indices) { for (i in _genres.indices) {
// Non-destructively reorder the linked genres so that they align with // Non-destructively reorder the linked genres so that they align with
// the genre ordering within the song metadata. // the genre ordering within the song metadata.
@ -432,7 +416,6 @@ class ArtistImpl(
?: Name.Unknown(R.string.def_artist) ?: Name.Unknown(R.string.def_artist)
override val songs: Set<Song> override val songs: Set<Song>
override val albums: Set<Album>
override val explicitAlbums: Set<Album> override val explicitAlbums: Set<Album>
override val implicitAlbums: Set<Album> override val implicitAlbums: Set<Album>
override val durationMs: Long? override val durationMs: Long?
@ -463,7 +446,7 @@ class ArtistImpl(
} }
songs = distinctSongs songs = distinctSongs
albums = albumMap.keys val albums = albumMap.keys
explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true } explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true }
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true } implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
durationMs = songs.sumOf { it.durationMs }.positiveOrNull() durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
@ -506,7 +489,16 @@ class ArtistImpl(
* @return This instance upcasted to [Artist]. * @return This instance upcasted to [Artist].
*/ */
fun finalize(): Artist { fun finalize(): Artist {
check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist $name: Empty" } // There are valid artist configurations:
// 1. No songs, no implicit albums, some explicit albums
// 2. Some songs, no implicit albums, some explicit albums
// 3. Some songs, some implicit albums, no implicit albums
// 4. Some songs, some implicit albums, some explicit albums
// I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty,
// but I can't be 100% certain.
check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) {
"Malformed artist $name: Empty"
}
genres = genres =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.genres(songs.flatMapTo(mutableSetOf()) { it.genres }) .genres(songs.flatMapTo(mutableSetOf()) { it.genres })
@ -514,6 +506,7 @@ class ArtistImpl(
return this return this
} }
} }
/** /**
* Library-backed implementation of [Genre]. * Library-backed implementation of [Genre].
* *

View file

@ -22,7 +22,7 @@ import java.util.UUID
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.Directory import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.info.ReleaseType
@ -42,9 +42,7 @@ data class RawSong(
/** The latest date the [SongImpl]'s audio file was modified, as a unix epoch timestamp. */ /** The latest date the [SongImpl]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long? = null, var dateModified: Long? = null,
/** @see Song.path */ /** @see Song.path */
var fileName: String? = null, var path: Path? = null,
/** @see Song.path */
var directory: Directory? = null,
/** @see Song.size */ /** @see Song.size */
var size: Long? = null, var size: Long? = null,
/** @see Song.durationMs */ /** @see Song.durationMs */

View file

@ -16,13 +16,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.fs package org.oxycblt.auxio.music.dirs
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemMusicDirBinding import org.oxycblt.auxio.databinding.ItemMusicDirBinding
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -35,11 +36,11 @@ import org.oxycblt.auxio.util.logD
*/ */
class DirectoryAdapter(private val listener: Listener) : class DirectoryAdapter(private val listener: Listener) :
RecyclerView.Adapter<MusicDirViewHolder>() { RecyclerView.Adapter<MusicDirViewHolder>() {
private val _dirs = mutableListOf<Directory>() private val _dirs = mutableListOf<Path>()
/** /**
* The current list of [Directory]s, may not line up with [MusicDirectories] due to removals. * The current list of [SystemPath]s, may not line up with [MusicDirectories] due to removals.
*/ */
val dirs: List<Directory> = _dirs val dirs: List<Path> = _dirs
override fun getItemCount() = dirs.size override fun getItemCount() = dirs.size
@ -50,37 +51,37 @@ class DirectoryAdapter(private val listener: Listener) :
holder.bind(dirs[position], listener) holder.bind(dirs[position], listener)
/** /**
* Add a [Directory] to the end of the list. * Add a [Path] to the end of the list.
* *
* @param dir The [Directory] to add. * @param path The [Path] to add.
*/ */
fun add(dir: Directory) { fun add(path: Path) {
if (_dirs.contains(dir)) return if (_dirs.contains(path)) return
logD("Adding $dir") logD("Adding $path")
_dirs.add(dir) _dirs.add(path)
notifyItemInserted(_dirs.lastIndex) notifyItemInserted(_dirs.lastIndex)
} }
/** /**
* Add a list of [Directory] instances to the end of the list. * Add a list of [Path] instances to the end of the list.
* *
* @param dirs The [Directory] instances to add. * @param path The [Path] instances to add.
*/ */
fun addAll(dirs: List<Directory>) { fun addAll(path: List<Path>) {
logD("Adding ${dirs.size} directories") logD("Adding ${path.size} directories")
val oldLastIndex = dirs.lastIndex val oldLastIndex = path.lastIndex
_dirs.addAll(dirs) _dirs.addAll(path)
notifyItemRangeInserted(oldLastIndex, dirs.size) notifyItemRangeInserted(oldLastIndex, path.size)
} }
/** /**
* Remove a [Directory] from the list. * Remove a [Path] from the list.
* *
* @param dir The [Directory] to remove. Must exist in the list. * @param path The [Path] to remove. Must exist in the list.
*/ */
fun remove(dir: Directory) { fun remove(path: Path) {
logD("Removing $dir") logD("Removing $path")
val idx = _dirs.indexOf(dir) val idx = _dirs.indexOf(path)
_dirs.removeAt(idx) _dirs.removeAt(idx)
notifyItemRemoved(idx) notifyItemRemoved(idx)
} }
@ -88,7 +89,7 @@ class DirectoryAdapter(private val listener: Listener) :
/** A Listener for [DirectoryAdapter] interactions. */ /** A Listener for [DirectoryAdapter] interactions. */
interface Listener { interface Listener {
/** Called when the delete button on a directory item is clicked. */ /** Called when the delete button on a directory item is clicked. */
fun onRemoveDirectory(dir: Directory) fun onRemoveDirectory(dir: Path)
} }
} }
@ -102,12 +103,12 @@ class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBi
/** /**
* Bind new data to this instance. * Bind new data to this instance.
* *
* @param dir The new [Directory] to bind. * @param path The new [Path] to bind.
* @param listener A [DirectoryAdapter.Listener] to bind interactions to. * @param listener A [DirectoryAdapter.Listener] to bind interactions to.
*/ */
fun bind(dir: Directory, listener: DirectoryAdapter.Listener) { fun bind(path: Path, listener: DirectoryAdapter.Listener) {
binding.dirPath.text = dir.resolveName(binding.context) binding.dirPath.text = path.resolve(binding.context)
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(dir) } binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(path) }
} }
companion object { companion object {

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 Auxio Project
* DirectoryModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.dirs
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module @InstallIn(SingletonComponent::class) interface DirectoryModule {}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 Auxio Project
* MusicDirectories.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.dirs
import org.oxycblt.auxio.music.fs.Path
/**
* Represents the configuration for specific directories to filter to/from when loading music.
*
* @param dirs A list of [Directory] instances. How these are interpreted depends on [shouldInclude]
* @param shouldInclude True if the library should only load from the [Directory] instances, false
* if the library should not load from the [Directory] instances.
* @author Alexander Capehart (OxygenCobalt)
*/
data class MusicDirectories(val dirs: List<Path>, val shouldInclude: Boolean)

View file

@ -16,13 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.fs package org.oxycblt.auxio.music.dirs
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -35,8 +33,9 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
@ -50,7 +49,7 @@ class MusicDirsDialog :
ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener { ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
private val dirAdapter = DirectoryAdapter(this) private val dirAdapter = DirectoryAdapter(this)
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
private var storageManager: StorageManager? = null @Inject lateinit var documentPathFactory: DocumentPathFactory
@Inject lateinit var musicSettings: MusicSettings @Inject lateinit var musicSettings: MusicSettings
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
@ -70,10 +69,6 @@ class MusicDirsDialog :
} }
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
val context = requireContext()
val storageManager =
context.getSystemServiceCompat(StorageManager::class).also { storageManager = it }
openDocumentTreeLauncher = openDocumentTreeLauncher =
registerForActivityResult( registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs) ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
@ -107,9 +102,7 @@ class MusicDirsDialog :
if (pendingDirs != null) { if (pendingDirs != null) {
dirs = dirs =
MusicDirectories( MusicDirectories(
pendingDirs.mapNotNull { pendingDirs.mapNotNull(documentPathFactory::fromDocumentId),
Directory.fromDocumentTreeUri(storageManager, it)
},
savedInstanceState.getBoolean(KEY_PENDING_MODE)) savedInstanceState.getBoolean(KEY_PENDING_MODE))
} }
} }
@ -133,18 +126,17 @@ class MusicDirsDialog :
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putStringArrayList( outState.putStringArrayList(
KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map { it.toString() })) KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map(documentPathFactory::toDocumentId)))
outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding())) outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding()))
} }
override fun onDestroyBinding(binding: DialogMusicDirsBinding) { override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
storageManager = null
openDocumentTreeLauncher = null openDocumentTreeLauncher = null
binding.dirsRecycler.adapter = null binding.dirsRecycler.adapter = null
} }
override fun onRemoveDirectory(dir: Directory) { override fun onRemoveDirectory(dir: Path) {
dirAdapter.remove(dir) dirAdapter.remove(dir)
requireBinding().dirsEmpty.isVisible = dirAdapter.dirs.isEmpty() requireBinding().dirsEmpty.isVisible = dirAdapter.dirs.isEmpty()
} }
@ -162,15 +154,7 @@ class MusicDirsDialog :
return return
} }
// Convert the document tree URI into it's relative path form, which can then be val dir = documentPathFactory.unpackDocumentTreeUri(uri)
// parsed into a Directory instance.
val docUri =
DocumentsContract.buildDocumentUriUsingTree(
uri, DocumentsContract.getTreeDocumentId(uri))
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
val dir =
Directory.fromDocumentTreeUri(
requireNotNull(storageManager) { "StorageManager was not available" }, treeUri)
if (dir != null) { if (dir != null) {
dirAdapter.add(dir) dirAdapter.add(dir)

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 Auxio Project
* ExternalModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.external
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface ExternalModule {
@Binds
fun externalPlaylistManager(
externalPlaylistManager: ExternalPlaylistManagerImpl
): ExternalPlaylistManager
@Binds fun m3u(m3u: M3UImpl): M3U
}

View file

@ -0,0 +1,124 @@
/*
* Copyright (c) 2023 Auxio Project
* ExternalPlaylistManager.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.external
import android.content.Context
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.fs.Components
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.util.logE
/**
* Generic playlist file importing abstraction.
*
* @see ImportedPlaylist
* @see M3U
* @author Alexander Capehart (OxygenCobalt)
*/
interface ExternalPlaylistManager {
/**
* Import the playlist file at the given [uri].
*
* @param uri The [Uri] of the playlist file to import.
* @return An [ImportedPlaylist] containing the paths to the files listed in the playlist file,
* or null if the playlist could not be imported.
*/
suspend fun import(uri: Uri): ImportedPlaylist?
/**
* Export the given [playlist] to the given [uri].
*
* @param playlist The playlist to export.
* @param uri The [Uri] to export the playlist to.
* @param config The configuration to use when exporting the playlist.
* @return True if the playlist was successfully exported, false otherwise.
*/
suspend fun export(playlist: Playlist, uri: Uri, config: ExportConfig): Boolean
}
/**
* Configuration to use when exporting playlists.
*
* @property absolute Whether or not to use absolute paths when exporting. If not, relative paths
* will be used.
* @property windowsPaths Whether or not to use Windows-style paths when exporting (i.e prefixed
* with C:\\ and using \). If not, Unix-style paths will be used (i.e prefixed with /).
* @see ExternalPlaylistManager.export
*/
data class ExportConfig(val absolute: Boolean, val windowsPaths: Boolean)
/**
* A playlist that has been imported.
*
* @property name The name of the playlist. May be null if not provided.
* @property paths The paths of the files in the playlist.
* @see ExternalPlaylistManager
* @see M3U
*/
data class ImportedPlaylist(val name: String?, val paths: List<Path>)
class ExternalPlaylistManagerImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val documentPathFactory: DocumentPathFactory,
private val m3u: M3U
) : ExternalPlaylistManager {
override suspend fun import(uri: Uri): ImportedPlaylist? {
val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return null
return try {
context.contentResolverSafe.openInputStream(uri)?.use {
return m3u.read(it, filePath.directory)
}
} catch (e: Exception) {
logE("Failed to import playlist: $e")
null
}
}
override suspend fun export(playlist: Playlist, uri: Uri, config: ExportConfig): Boolean {
val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return false
val workingDirectory =
if (config.absolute) {
Path(filePath.volume, Components.parseUnix("/"))
} else {
filePath.directory
}
return try {
val outputStream = context.contentResolverSafe.openOutputStream(uri)
if (outputStream == null) {
logE("Failed to export playlist: Could not open output stream")
return false
}
outputStream.use {
m3u.write(playlist, it, workingDirectory, config)
true
}
} catch (e: Exception) {
logE("Failed to export playlist: $e")
false
}
}
}

View file

@ -0,0 +1,267 @@
/*
* Copyright (c) 2023 Auxio Project
* M3U.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.external
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStream
import java.io.InputStreamReader
import java.io.OutputStream
import javax.inject.Inject
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.fs.Components
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.logE
/**
* Minimal M3U file format implementation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface M3U {
/**
* Reads an M3U file from the given [stream] and returns a [ImportedPlaylist] containing the
* paths to the files listed in the M3U file.
*
* @param stream The stream to read the M3U file from.
* @param workingDirectory The directory that the M3U file is contained in. This is used to
* resolve relative paths.
* @return An [ImportedPlaylist] containing the paths to the files listed in the M3U file,
*/
fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist?
/**
* Writes the given [playlist] to the given [outputStream] in the M3U format,.
*
* @param playlist The playlist to write.
* @param outputStream The stream to write the M3U file to.
* @param workingDirectory The directory that the M3U file is contained in. This is used to
* create relative paths to where the M3U file is assumed to be stored.
* @param config The configuration to use when exporting the playlist.
*/
fun write(
playlist: Playlist,
outputStream: OutputStream,
workingDirectory: Path,
config: ExportConfig
)
companion object {
/** The mime type used for M3U files by the android system. */
const val MIME_TYPE = "audio/x-mpegurl"
}
}
class M3UImpl @Inject constructor(@ApplicationContext private val context: Context) : M3U {
override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
val reader = BufferedReader(InputStreamReader(stream))
val paths = mutableListOf<Path>()
var name: String? = null
consumeFile@ while (true) {
var path: String?
collectMetadata@ while (true) {
// The M3U format consists of "entries" that begin with a bunch of metadata
// prefixed with "#", and then a relative/absolute path or url to the file.
// We don't really care about the metadata except for the playlist name, so
// we discard everything but that.
val currentLine =
(reader.readLine() ?: break@consumeFile).correctWhitespace()
?: continue@collectMetadata
if (currentLine.startsWith("#")) {
// Metadata entries are roughly structured
val split = currentLine.split(":", limit = 2)
when (split[0]) {
// Playlist name
"#PLAYLIST" -> name = split.getOrNull(1)?.correctWhitespace()
// Add more metadata handling here if needed.
else -> {}
}
} else {
// Something that isn't a metadata entry, assume it's a path. It could be
// a URL, but it'll just get mangled really badly and not match with anything,
// so it's okay.
path = currentLine
break@collectMetadata
}
}
if (path == null) {
logE("Expected a path, instead got an EOF")
break@consumeFile
}
// There is basically no formal specification of file paths in M3U, and it differs
// based on the US that generated it. These are the paths though that I assume most
// programs will generate.
val components =
when {
path.startsWith('/') -> {
// Unix absolute path. Note that we still assume this absolute path is in
// the same volume as the M3U file. There's no sane way to map the volume
// to the phone's volumes, so this is the only thing we can do.
Components.parseUnix(path)
}
path.startsWith("./") -> {
// Unix relative path, resolve it
Components.parseUnix(path).absoluteTo(workingDirectory.components)
}
path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> {
// Windows absolute path, we should get rid of the volume prefix, but
// otherwise
// the rest should be fine. Again, we have to disregard what the volume
// actually
// is since there's no sane way to map it to the phone's volumes.
Components.parseWindows(path.substring(2))
}
path.startsWith(".\\") -> {
// Windows relative path, we need to remove the .\\ prefix
Components.parseWindows(path).absoluteTo(workingDirectory.components)
}
else -> {
// No clue, parse by all separators and assume it's relative.
Components.parseAny(path).absoluteTo(workingDirectory.components)
}
}
paths.add(Path(workingDirectory.volume, components))
}
return if (paths.isNotEmpty()) {
ImportedPlaylist(name, paths)
} else {
// Couldn't get anything useful out of this file.
null
}
}
override fun write(
playlist: Playlist,
outputStream: OutputStream,
workingDirectory: Path,
config: ExportConfig
) {
val writer = outputStream.bufferedWriter()
// Try to be as compliant to the spec as possible while also cramming it full of extensions
// I imagine other players will use.
writer.writeLine("#EXTM3U")
writer.writeLine("#EXTENC:UTF-8")
writer.writeLine("#PLAYLIST:${playlist.name.resolve(context)}")
for (song in playlist.songs) {
writer.writeLine("#EXTINF:${song.durationMs},${song.name.resolve(context)}")
writer.writeLine("#EXTALB:${song.album.name.resolve(context)}")
writer.writeLine("#EXTART:${song.artists.resolveNames(context)}")
writer.writeLine("#EXTGEN:${song.genres.resolveNames(context)}")
val formattedPath =
if (config.absolute) {
// The path is already absolute in this case, but we need to prefix and separate
// it differently depending on the setting.
if (config.windowsPaths) {
// Assume the plain windows C volume, since that's probably where most music
// libraries are on a windows PC.
"C:\\\\${song.path.components.windowsString}"
} else {
"/${song.path.components.unixString}"
}
} else {
// First need to make this path relative to the working directory of the M3U
// file, and then format it with the correct separators.
val relativePath = song.path.components.relativeTo(workingDirectory.components)
if (config.windowsPaths) {
relativePath.windowsString
} else {
relativePath.unixString
}
}
writer.writeLine(formattedPath)
}
writer.flush()
}
private fun BufferedWriter.writeLine(line: String) {
write(line)
newLine()
}
private fun Components.absoluteTo(workingDirectory: Components): Components {
var absoluteComponents = workingDirectory
for (component in components) {
when (component) {
// Parent specifier, go "back" one directory (in practice cleave off the last
// component)
".." -> absoluteComponents = absoluteComponents.parent()
// Current directory, the components are already there.
"." -> {}
// New directory, add it
else -> absoluteComponents = absoluteComponents.child(component)
}
}
return absoluteComponents
}
private fun Components.relativeTo(workingDirectory: Components): Components {
// We want to find the common prefix of the working directory and path, and then
// and them combine them with the correct relative elements to make sure they
// resolve the same.
var commonIndex = 0
while (commonIndex < components.size &&
commonIndex < workingDirectory.components.size &&
components[commonIndex] == workingDirectory.components[commonIndex]) {
++commonIndex
}
var relativeComponents = Components.parseUnix(".")
// TODO: Simplify this logic
when {
commonIndex == components.size && commonIndex == workingDirectory.components.size -> {
// The paths are the same. This shouldn't occur.
}
commonIndex == components.size -> {
// The working directory is deeper in the path, backtrack.
for (i in 0..workingDirectory.components.size - commonIndex - 1) {
relativeComponents = relativeComponents.child("..")
}
}
commonIndex == workingDirectory.components.size -> {
// Working directory is shallower than the path, can just append the
// non-common remainder of the path
relativeComponents = relativeComponents.child(depth(commonIndex))
}
else -> {
// The paths are siblings. Backtrack and append as needed.
for (i in 0..workingDirectory.components.size - commonIndex - 1) {
relativeComponents = relativeComponents.child("..")
}
relativeComponents = relativeComponents.child(depth(commonIndex))
}
}
return relativeComponents
}
private companion object {
val WINDOWS_VOLUME_PREFIX_REGEX = Regex("^[A-Za-z]:\\\\.*")
}
}

View file

@ -0,0 +1,147 @@
/*
* Copyright (c) 2023 Auxio Project
* DocumentPathFactory.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.fs
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
/**
* A factory for parsing the reverse-engineered format of the URIs obtained from document picker.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface DocumentPathFactory {
/**
* Unpacks a document URI into a [Path] instance, using [fromDocumentId].
*
* @param uri The document URI to unpack.
* @return The [Path] instance, or null if the URI could not be unpacked.
*/
fun unpackDocumentUri(uri: Uri): Path?
/**
* Unpacks a document tree URI into a [Path] instance, using [fromDocumentId].
*
* @param uri The document tree URI to unpack.
* @return The [Path] instance, or null if the URI could not be unpacked.
*/
fun unpackDocumentTreeUri(uri: Uri): Path?
/**
* Serializes a [Path] instance into a document tree URI format path.
*
* @param path The [Path] instance to serialize.
* @return The serialized path.
*/
fun toDocumentId(path: Path): String
/**
* Deserializes a document tree URI format path into a [Path] instance.
*
* @param path The path to deserialize.
* @return The [Path] instance, or null if the path could not be deserialized.
*/
fun fromDocumentId(path: String): Path?
}
class DocumentPathFactoryImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val volumeManager: VolumeManager,
private val mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory
) : DocumentPathFactory {
override fun unpackDocumentUri(uri: Uri): Path? {
val id = DocumentsContract.getDocumentId(uri)
val numericId = id.toLongOrNull()
return if (numericId != null) {
// The document URI is special and points to an entry only accessible via
// ContentResolver. In this case, we have to manually query MediaStore.
for (prefix in POSSIBLE_CONTENT_URI_PREFIXES) {
val contentUri = ContentUris.withAppendedId(prefix, numericId)
val path =
context.contentResolverSafe.useQuery(
contentUri, mediaStorePathInterpreterFactory.projection) {
it.moveToFirst()
mediaStorePathInterpreterFactory.wrap(it).extract()
}
if (path != null) {
return path
}
}
null
} else {
fromDocumentId(id)
}
}
override fun unpackDocumentTreeUri(uri: Uri): Path? {
// Convert the document tree URI into it's relative path form, which can then be
// parsed into a Directory instance.
val docUri =
DocumentsContract.buildDocumentUriUsingTree(
uri, DocumentsContract.getTreeDocumentId(uri))
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
return fromDocumentId(treeUri)
}
override fun toDocumentId(path: Path): String =
when (val volume = path.volume) {
// The primary storage has a volume prefix of "primary", regardless
// of if it's internal or not.
is Volume.Internal -> "$DOCUMENT_URI_PRIMARY_NAME:${path.components}"
// Document tree URIs consist of a prefixed volume name followed by a relative path.
is Volume.External -> "${volume.id}:${path.components}"
}
override fun fromDocumentId(path: String): Path? {
// Document tree URIs consist of a prefixed volume name followed by a relative path,
// delimited with a colon.
val split = path.split(File.pathSeparator, limit = 2)
val volume =
when (split[0]) {
// The primary storage has a volume prefix of "primary", regardless
// of if it's internal or not.
DOCUMENT_URI_PRIMARY_NAME -> volumeManager.getInternalVolume()
// Removable storage has a volume prefix of it's UUID, try to find it
// within StorageManager's volume list.
else ->
volumeManager.getVolumes().find { it is Volume.External && it.id == split[0] }
}
val relativePath = split.getOrNull(1) ?: return null
return Path(volume ?: return null, Components.parseUnix(relativePath))
}
private companion object {
const val DOCUMENT_URI_PRIMARY_NAME = "primary"
private val POSSIBLE_CONTENT_URI_PREFIXES =
arrayOf(
Uri.parse("content://downloads/public_downloads"),
Uri.parse("content://downloads/my_downloads"))
}
}

View file

@ -24,119 +24,229 @@ import android.os.storage.StorageManager
import android.os.storage.StorageVolume import android.os.storage.StorageVolume
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import java.io.File import java.io.File
import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
/** /**
* A full absolute path to a file. Only intended for display purposes. For accessing files, URIs are * An abstraction of an android file system path, including the volume and relative path.
* preferred in all cases due to scoped storage limitations.
* *
* @param name The name of the file. * @param volume The volume that the path is on.
* @param parent The parent [Directory] of the file. * @param components The components of the path of the file, relative to the root of the volume.
* @author Alexander Capehart (OxygenCobalt)
*/ */
data class Path(val name: String, val parent: Directory) data class Path(
val volume: Volume,
val components: Components,
) {
/** The name of the file/directory. */
val name: String?
get() = components.name
/** /** The parent directory of the path, or itself if it's the root path. */
* A volume-aware relative path to a directory. val directory: Path
* get() = Path(volume, components.parent())
* @param volume The [StorageVolume] that the [Directory] is contained in.
* @param relativePath The relative path from within the [StorageVolume] to the [Directory].
* @author Alexander Capehart (OxygenCobalt)
*/
class Directory private constructor(val volume: StorageVolume, val relativePath: String) {
/**
* Resolve the [Directory] instance into a human-readable path name.
*
* @param context [Context] required to obtain volume descriptions.
* @return A human-readable path.
* @see StorageVolume.getDescription
*/
fun resolveName(context: Context) =
context.getString(R.string.fmt_path, volume.getDescriptionCompat(context), relativePath)
/** /**
* Converts this [Directory] instance into an opaque document tree path. This is a huge * Transforms this [Path] into a "file" of the given name that's within the "directory"
* violation of the document tree URI contract, but it's also the only one can sensibly work * represented by the current path. Ex. "/storage/emulated/0/Music" ->
* with these uris in the UI, and it doesn't exactly matter since we never write or read to * "/storage/emulated/0/Music/file.mp3"
* directory.
* *
* @return A URI [String] abiding by the document tree specification, or null if the [Directory] * @param fileName The name of the file to append to the path.
* is not valid. * @return The new [Path] instance.
*/ */
fun toDocumentTreeUri() = fun file(fileName: String) = Path(volume, components.child(fileName))
// Document tree URIs consist of a prefixed volume name followed by a relative path.
if (volume.isInternalCompat) {
// The primary storage has a volume prefix of "primary", regardless
// of if it's internal or not.
"$DOCUMENT_URI_PRIMARY_NAME:$relativePath"
} else {
// Removable storage has a volume prefix of it's UUID.
volume.uuidCompat?.let { uuid -> "$uuid:$relativePath" }
}
override fun hashCode(): Int { /**
var result = volume.hashCode() * Resolves the [Path] in a human-readable format.
result = 31 * result + relativePath.hashCode() *
return result * @param context [Context] required to obtain human-readable strings.
} */
fun resolve(context: Context) = "${volume.resolveName(context)}/$components"
}
override fun equals(other: Any?) = sealed interface Volume {
other is Directory && other.volume == volume && other.relativePath == relativePath /** The name of the volume as it appears in MediaStore. */
val mediaStoreName: String?
companion object { /**
/** The name given to the internal volume when in a document tree URI. */ * The components of the path to the volume, relative from the system root. Should not be used
private const val DOCUMENT_URI_PRIMARY_NAME = "primary" * except for compatibility purposes.
*/
val components: Components?
/** /** Resolves the name of the volume in a human-readable format. */
* Create a new directory instance from the given components. fun resolveName(context: Context): String
*
* @param volume The [StorageVolume] that the [Directory] is contained in.
* @param relativePath The relative path from within the [StorageVolume] to the [Directory].
* Will be stripped of any trailing separators for a consistent internal representation.
* @return A new [Directory] created from the components.
*/
fun from(volume: StorageVolume, relativePath: String) =
Directory(
volume, relativePath.removePrefix(File.separator).removeSuffix(File.separator))
/** /** A volume representing the device's internal storage. */
* Create a new directory from a document tree URI. This is a huge violation of the document interface Internal : Volume
* tree URI contract, but it's also the only one can sensibly work with these uris in the
* UI, and it doesn't exactly matter since we never write or read directory. /** A volume representing an external storage device, identified by a UUID. */
* interface External : Volume {
* @param storageManager [StorageManager] in order to obtain the [StorageVolume] specified /** The UUID of the volume. */
* in the given URI. val id: String?
* @param uri The URI string to parse into a [Directory].
* @return A new [Directory] parsed from the URI, or null if the URI is not valid.
*/
fun fromDocumentTreeUri(storageManager: StorageManager, uri: String): Directory? {
// Document tree URIs consist of a prefixed volume name followed by a relative path,
// delimited with a colon.
val split = uri.split(File.pathSeparator, limit = 2)
val volume =
when (split[0]) {
// The primary storage has a volume prefix of "primary", regardless
// of if it's internal or not.
DOCUMENT_URI_PRIMARY_NAME -> storageManager.primaryStorageVolumeCompat
// Removable storage has a volume prefix of it's UUID, try to find it
// within StorageManager's volume list.
else -> storageManager.storageVolumesCompat.find { it.uuidCompat == split[0] }
}
val relativePath = split.getOrNull(1)
return from(volume ?: return null, relativePath ?: return null)
}
} }
} }
/** /**
* Represents the configuration for specific directories to filter to/from when loading music. * The components of a path. This allows the path to be manipulated without having tp handle
* separator parsing.
* *
* @param dirs A list of [Directory] instances. How these are interpreted depends on [shouldInclude] * @param components The components of the path.
* @param shouldInclude True if the library should only load from the [Directory] instances, false
* if the library should not load from the [Directory] instances.
* @author Alexander Capehart (OxygenCobalt)
*/ */
data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolean) @JvmInline
value class Components private constructor(val components: List<String>) {
/** The name of the file/directory. */
val name: String?
get() = components.lastOrNull()
override fun toString() = unixString
/** Formats these components using the unix file separator (/) */
val unixString: String
get() = components.joinToString(File.separator)
/** Formats these components using the windows file separator (\). */
val windowsString: String
get() = components.joinToString("\\")
/**
* Returns a new [Components] instance with the last element of the path removed as a "parent"
* element of the original instance.
*
* @return The new [Components] instance, or the original instance if it's the root path.
*/
fun parent() = Components(components.dropLast(1))
/**
* Returns a new [Components] instance with the given name appended to the end of the path as a
* "child" element of the original instance.
*
* @param name The name of the file/directory to append to the path.
*/
fun child(name: String) =
if (name.isNotEmpty()) {
Components(components + name.trimSlashes())
} else {
this
}
/**
* Removes the first [n] elements of the path, effectively resulting in a path that is n levels
* deep.
*
* @param n The number of elements to remove.
* @return The new [Components] instance.
*/
fun depth(n: Int) = Components(components.drop(n))
/**
* Concatenates this [Components] instance with another.
*
* @param other The [Components] instance to concatenate with.
* @return The new [Components] instance.
*/
fun child(other: Components) = Components(components + other.components)
/**
* Returns the given [Components] has a prefix equal to this [Components] instance. Effectively,
* as if the given [Components] instance was a child of this [Components] instance.
*/
fun contains(other: Components): Boolean {
if (other.components.size < components.size) {
return false
}
return components == other.components.take(components.size)
}
companion object {
/**
* Parses a path string into a [Components] instance by the unix path separator (/).
*
* @param path The path string to parse.
* @return The [Components] instance.
*/
fun parseUnix(path: String) =
Components(path.trimSlashes().split(File.separatorChar).filter { it.isNotEmpty() })
/**
* Parses a path string into a [Components] instance by the windows path separator.
*
* @param path The path string to parse.
* @return The [Components] instance.
*/
fun parseWindows(path: String) =
Components(path.trimSlashes().split('\\').filter { it.isNotEmpty() })
/**
* Parses a path string into a [Components] instance by any path separator, either unix or
* windows. This is useful for parsing paths when you can't determine the separators any
* other way, however also risks mangling the paths if they use unix-style escapes.
*
* @param path The path string to parse.
* @return The [Components] instance.
*/
fun parseAny(path: String) =
Components(
path.trimSlashes().split(File.separatorChar, '\\').filter { it.isNotEmpty() })
private fun String.trimSlashes() = trimStart(File.separatorChar).trimEnd(File.separatorChar)
}
}
/** A wrapper around [StorageManager] that provides instances of the [Volume] interface. */
interface VolumeManager {
/**
* The internal storage volume of the device.
*
* @see StorageManager.getPrimaryStorageVolume
*/
fun getInternalVolume(): Volume.Internal
/**
* The list of [Volume]s currently recognized by [StorageManager].
*
* @see StorageManager.getStorageVolumes
*/
fun getVolumes(): List<Volume>
}
class VolumeManagerImpl @Inject constructor(private val storageManager: StorageManager) :
VolumeManager {
override fun getInternalVolume(): Volume.Internal =
InternalVolumeImpl(storageManager.primaryStorageVolume)
override fun getVolumes() =
storageManager.storageVolumesCompat.map {
if (it.isInternalCompat) {
InternalVolumeImpl(it)
} else {
ExternalVolumeImpl(it)
}
}
private data class InternalVolumeImpl(val storageVolume: StorageVolume) : Volume.Internal {
override val mediaStoreName
get() = storageVolume.mediaStoreVolumeNameCompat
override val components
get() = storageVolume.directoryCompat?.let(Components::parseUnix)
override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context)
}
private data class ExternalVolumeImpl(val storageVolume: StorageVolume) : Volume.External {
override val id
get() = storageVolume.uuidCompat
override val mediaStoreName
get() = storageVolume.mediaStoreVolumeNameCompat
override val components
get() = storageVolume.directoryCompat?.let(Components::parseUnix)
override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context)
}
}
/** /**
* A mime type of a file. Only intended for display. * A mime type of a file. Only intended for display.

View file

@ -18,18 +18,43 @@
package org.oxycblt.auxio.music.fs package org.oxycblt.auxio.music.fs
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.storage.StorageManager
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.util.getSystemServiceCompat
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class FsModule { class FsModule {
@Provides @Provides
fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) = fun volumeManager(@ApplicationContext context: Context): VolumeManager =
MediaStoreExtractor.from(context, musicSettings) VolumeManagerImpl(context.getSystemServiceCompat(StorageManager::class))
@Provides
fun mediaStoreExtractor(
@ApplicationContext context: Context,
mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory
) = MediaStoreExtractor.from(context, mediaStorePathInterpreterFactory)
@Provides
fun mediaStorePathInterpreterFactory(
volumeManager: VolumeManager
): MediaStorePathInterpreter.Factory = MediaStorePathInterpreter.Factory.from(volumeManager)
@Provides
fun contentResolver(@ApplicationContext context: Context): ContentResolver =
context.contentResolverSafe
}
@Module
@InstallIn(SingletonComponent::class)
interface FsBindsModule {
@Binds
fun documentPathFactory(documentTreePathFactory: DocumentPathFactoryImpl): DocumentPathFactory
} }

View file

@ -21,22 +21,19 @@ package org.oxycblt.auxio.music.fs
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.os.Build import android.os.Build
import android.os.storage.StorageManager
import android.provider.MediaStore import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import java.io.File
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.cache.Cache import org.oxycblt.auxio.music.cache.Cache
import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.dirs.MusicDirectories
import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.parseId3v2PositionField import org.oxycblt.auxio.music.metadata.parseId3v2PositionField
import org.oxycblt.auxio.music.metadata.transformPositionField import org.oxycblt.auxio.music.metadata.transformPositionField
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.sendWithTimeout
/** /**
* The layer that loads music from the [MediaStore] database. This is an intermediate step in the * The layer that loads music from the [MediaStore] database. This is an intermediate step in the
@ -50,9 +47,11 @@ interface MediaStoreExtractor {
/** /**
* Query the media database. * Query the media database.
* *
* @param constraints Configuration parameter to restrict what music should be ignored when
* querying.
* @return A new [Query] returned from the media database. * @return A new [Query] returned from the media database.
*/ */
suspend fun query(): Query suspend fun query(constraints: Constraints): Query
/** /**
* Consume the [Cursor] loaded after [query]. * Consume the [Cursor] loaded after [query].
@ -79,83 +78,87 @@ interface MediaStoreExtractor {
fun close() fun close()
fun populateFileInfo(rawSong: RawSong) fun populateFileInfo(rawSong: RawSong): Boolean
fun populateTags(rawSong: RawSong) fun populateTags(rawSong: RawSong)
} }
data class Constraints(val excludeNonMusic: Boolean, val musicDirs: MusicDirectories)
companion object { companion object {
/** /**
* Create a framework-backed instance. * Create a framework-backed instance.
* *
* @param context [Context] required. * @param context [Context] required.
* @param musicSettings [MusicSettings] required. * @param volumeManager [VolumeManager] required.
* @return A new [MediaStoreExtractor] that will work best on the device's API level. * @return A new [MediaStoreExtractor] that will work best on the device's API level.
*/ */
fun from(context: Context, musicSettings: MusicSettings): MediaStoreExtractor = fun from(
when { context: Context,
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> pathInterpreterFactory: MediaStorePathInterpreter.Factory
Api30MediaStoreExtractor(context, musicSettings) ): MediaStoreExtractor {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> val tagInterpreterFactory =
Api29MediaStoreExtractor(context, musicSettings) when {
else -> Api21MediaStoreExtractor(context, musicSettings) Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30TagInterpreter.Factory()
} else -> Api21TagInterpreter.Factory()
}
return MediaStoreExtractorImpl(context, pathInterpreterFactory, tagInterpreterFactory)
}
} }
} }
private abstract class BaseMediaStoreExtractor( private class MediaStoreExtractorImpl(
protected val context: Context, private val context: Context,
private val musicSettings: MusicSettings private val mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory,
private val tagInterpreterFactory: TagInterpreter.Factory
) : MediaStoreExtractor { ) : MediaStoreExtractor {
final override suspend fun query(): MediaStoreExtractor.Query { override suspend fun query(
constraints: MediaStoreExtractor.Constraints
): MediaStoreExtractor.Query {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val args = mutableListOf<String>() val projection =
var selector = BASE_SELECTOR BASE_PROJECTION +
mediaStorePathInterpreterFactory.projection +
tagInterpreterFactory.projection
var uniSelector = BASE_SELECTOR
var uniArgs = listOf<String>()
// Filter out audio that is not music, if enabled. // Filter out audio that is not music, if enabled.
if (musicSettings.excludeNonMusic) { if (constraints.excludeNonMusic) {
logD("Excluding non-music") logD("Excluding non-music")
selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1" uniSelector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
} }
// Set up the projection to follow the music directory configuration. // Set up the projection to follow the music directory configuration.
val dirs = musicSettings.musicDirs if (constraints.musicDirs.dirs.isNotEmpty()) {
if (dirs.dirs.isNotEmpty()) { val pathSelector =
selector += " AND " mediaStorePathInterpreterFactory.createSelector(constraints.musicDirs.dirs)
if (!dirs.shouldInclude) { if (pathSelector != null) {
logD("Excluding directories in selector") logD("Must select for directories")
// Without a NOT, the query will be restricted to the specified paths, resulting uniSelector += " AND "
// in the "Include" mode. With a NOT, the specified paths will not be included, if (!constraints.musicDirs.shouldInclude) {
// resulting in the "Exclude" mode. logD("Excluding directories in selector")
selector += "NOT " // 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,
selector += " (" // resulting in the "Exclude" mode.
uniSelector += "NOT "
// Specifying the paths to filter is version-specific, delegate to the concrete
// implementations.
for (i in dirs.dirs.indices) {
if (addDirToSelector(dirs.dirs[i], args)) {
selector +=
if (i < dirs.dirs.lastIndex) {
"$dirSelectorTemplate OR "
} else {
dirSelectorTemplate
}
} }
uniSelector += " (${pathSelector.template})"
uniArgs = pathSelector.args
} }
selector += ')'
} }
// Now we can actually query MediaStore. // 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=$uniSelector, args=$uniArgs]")
val cursor = val cursor =
context.contentResolverSafe.safeQuery( context.contentResolverSafe.safeQuery(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection, projection,
selector, uniSelector,
args.toTypedArray()) uniArgs.toTypedArray())
logD("Successfully queried for ${cursor.count} songs") logD("Successfully queried for ${cursor.count} songs")
val genreNamesMap = mutableMapOf<Long, String>() val genreNamesMap = mutableMapOf<Long, String>()
@ -193,10 +196,14 @@ private abstract class BaseMediaStoreExtractor(
logD("Read ${genreNamesMap.values.distinct().size} genres from MediaStore") logD("Read ${genreNamesMap.values.distinct().size} genres from MediaStore")
logD("Finished initialization in ${System.currentTimeMillis() - start}ms") logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
return wrapQuery(cursor, genreNamesMap) return QueryImpl(
cursor,
mediaStorePathInterpreterFactory.wrap(cursor),
tagInterpreterFactory.wrap(cursor),
genreNamesMap)
} }
final override suspend fun consume( override suspend fun consume(
query: MediaStoreExtractor.Query, query: MediaStoreExtractor.Query,
cache: Cache?, cache: Cache?,
incompleteSongs: Channel<RawSong>, incompleteSongs: Channel<RawSong>,
@ -204,12 +211,14 @@ private abstract class BaseMediaStoreExtractor(
) { ) {
while (query.moveToNext()) { while (query.moveToNext()) {
val rawSong = RawSong() val rawSong = RawSong()
query.populateFileInfo(rawSong) if (!query.populateFileInfo(rawSong)) {
continue
}
if (cache?.populate(rawSong) == true) { if (cache?.populate(rawSong) == true) {
completeSongs.send(rawSong) completeSongs.sendWithTimeout(rawSong)
} else { } else {
query.populateTags(rawSong) query.populateTags(rawSong)
incompleteSongs.send(rawSong) incompleteSongs.sendWithTimeout(rawSong)
} }
yield() yield()
} }
@ -218,60 +227,14 @@ private abstract class BaseMediaStoreExtractor(
query.close() query.close()
} }
/** class QueryImpl(
* The database columns available to all android versions supported by Auxio. Concrete private val cursor: Cursor,
* implementations can extend this projection to add version-specific columns. private val mediaStorePathInterpreter: MediaStorePathInterpreter,
*/ private val tagInterpreter: TagInterpreter,
protected open val projection: Array<String>
get() =
arrayOf(
// These columns are guaranteed to work on all versions of android
MediaStore.Audio.AudioColumns._ID,
MediaStore.Audio.AudioColumns.DATE_ADDED,
MediaStore.Audio.AudioColumns.DATE_MODIFIED,
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
MediaStore.Audio.AudioColumns.SIZE,
MediaStore.Audio.AudioColumns.DURATION,
MediaStore.Audio.AudioColumns.MIME_TYPE,
MediaStore.Audio.AudioColumns.TITLE,
MediaStore.Audio.AudioColumns.YEAR,
MediaStore.Audio.AudioColumns.ALBUM,
MediaStore.Audio.AudioColumns.ALBUM_ID,
MediaStore.Audio.AudioColumns.ARTIST,
AUDIO_COLUMN_ALBUM_ARTIST)
/**
* The companion template to add to the projection's selector whenever arguments are added by
* [addDirToSelector].
*
* @see addDirToSelector
*/
protected abstract val dirSelectorTemplate: String
/**
* Add a [Directory] to the given list of projection selector arguments.
*
* @param dir The [Directory] to add.
* @param args The destination list to append selector arguments to that are analogous to the
* given [Directory].
* @return true if the [Directory] was added, false otherwise.
* @see dirSelectorTemplate
*/
protected abstract fun addDirToSelector(dir: Directory, args: MutableList<String>): Boolean
protected abstract fun wrapQuery(
cursor: Cursor,
genreNamesMap: Map<Long, String>
): MediaStoreExtractor.Query
abstract class Query(
protected val cursor: Cursor,
private val genreNamesMap: Map<Long, String> private val genreNamesMap: Map<Long, String>
) : MediaStoreExtractor.Query { ) : MediaStoreExtractor.Query {
private val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) private val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
private val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE) private val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
private val displayNameIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
private val mimeTypeIndex = private val mimeTypeIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE) cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
private val sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE) private val sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
@ -288,21 +251,20 @@ private abstract class BaseMediaStoreExtractor(
private val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST) private val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
private val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST) private val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
final override val projectedTotal = cursor.count override val projectedTotal = cursor.count
final override fun moveToNext() = cursor.moveToNext() override fun moveToNext() = cursor.moveToNext()
final override fun close() = cursor.close() override fun close() = cursor.close()
override fun populateFileInfo(rawSong: RawSong) { override fun populateFileInfo(rawSong: RawSong): Boolean {
rawSong.mediaStoreId = cursor.getLong(idIndex) rawSong.mediaStoreId = cursor.getLong(idIndex)
rawSong.dateAdded = cursor.getLong(dateAddedIndex) rawSong.dateAdded = cursor.getLong(dateAddedIndex)
rawSong.dateModified = cursor.getLong(dateModifiedIndex) rawSong.dateModified = cursor.getLong(dateModifiedIndex)
// Try to use the DISPLAY_NAME column to obtain a (probably sane) file name
// from the android system.
rawSong.fileName = cursor.getStringOrNull(displayNameIndex)
rawSong.extensionMimeType = cursor.getString(mimeTypeIndex) rawSong.extensionMimeType = cursor.getString(mimeTypeIndex)
rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex) rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex)
rawSong.path = mediaStorePathInterpreter.extract() ?: return false
return true
} }
override fun populateTags(rawSong: RawSong) { override fun populateTags(rawSong: RawSong) {
@ -319,7 +281,8 @@ private abstract class BaseMediaStoreExtractor(
// A non-existent album name should theoretically be the name of the folder it contained // A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it
// the file is not actually in the root internal storage directory. We can't do // the file is not actually in the root internal storage directory. We can't do
// anything to fix this, really. // anything to fix this, really. We also can't really filter it out, since how can we
// know when it corresponds to the folder and not, say, Low Roar's breakout album "0"?
rawSong.albumName = cursor.getString(albumIndex) rawSong.albumName = cursor.getString(albumIndex)
// Android does not make a non-existent artist tag null, it instead fills it in // Android does not make a non-existent artist tag null, it instead fills it in
// as <unknown>, which makes absolutely no sense given how other columns default // as <unknown>, which makes absolutely no sense given how other columns default
@ -333,16 +296,12 @@ private abstract class BaseMediaStoreExtractor(
cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) } cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) }
// Get the genre value we had to query for in initialization // Get the genre value we had to query for in initialization
genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) } genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) }
// Get version/device-specific tags
tagInterpreter.populate(rawSong)
} }
} }
companion object { companion object {
/**
* The base selector that works across all versions of android. Does not exclude
* directories.
*/
private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
/** /**
* The album artist of a song. This column has existed since at least API 21, but until API * The album artist of a song. This column has existed since at least API 21, but until API
* 30 it was an undocumented extension for Google Play Music. This column will work on all * 30 it was an undocumented extension for Google Play Music. This column will work on all
@ -356,259 +315,122 @@ private abstract class BaseMediaStoreExtractor(
* until API 29. This will work on all versions that Auxio supports. * until API 29. This will work on all versions that Auxio supports.
*/ */
@Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL @Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
}
}
// Note: The separation between version-specific backends may not be the cleanest. To preserve /**
// speed, we only want to add redundancy on known issues, not with possible issues. * The base selector that works across all versions of android. Does not exclude
* directories.
*/
private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : /** The base projection that works across all versions of android. */
BaseMediaStoreExtractor(context, musicSettings) { private val BASE_PROJECTION =
override val projection: Array<String> arrayOf(
get() = // These columns are guaranteed to work on all versions of android
super.projection + MediaStore.Audio.AudioColumns._ID,
arrayOf( MediaStore.Audio.AudioColumns.DATE_ADDED,
MediaStore.Audio.AudioColumns.TRACK, MediaStore.Audio.AudioColumns.DATE_MODIFIED,
// Below API 29, we are restricted to the absolute path (Called DATA by MediaStore.Audio.AudioColumns.SIZE,
// MediaStore) when working with audio files. MediaStore.Audio.AudioColumns.DURATION,
MediaStore.Audio.AudioColumns.DATA) MediaStore.Audio.AudioColumns.MIME_TYPE,
MediaStore.Audio.AudioColumns.TITLE,
// The selector should be configured to convert the given directories instances to their MediaStore.Audio.AudioColumns.YEAR,
// absolute paths and then compare them to DATA. MediaStore.Audio.AudioColumns.ALBUM,
MediaStore.Audio.AudioColumns.ALBUM_ID,
override val dirSelectorTemplate: String MediaStore.Audio.AudioColumns.ARTIST,
get() = "${MediaStore.Audio.Media.DATA} LIKE ?" AUDIO_COLUMN_ALBUM_ARTIST)
override fun addDirToSelector(dir: Directory, args: MutableList<String>): Boolean {
// "%" signifies to accept any DATA value that begins with the Directory's path,
// thus recursively filtering all files in the directory.
args.add("${dir.volume.directoryCompat ?: return false}/${dir.relativePath}%")
return true
}
override fun wrapQuery(
cursor: Cursor,
genreNamesMap: Map<Long, String>,
): MediaStoreExtractor.Query =
Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
private class Query(
cursor: Cursor,
genreNamesMap: Map<Long, String>,
storageManager: StorageManager
) : BaseMediaStoreExtractor.Query(cursor, genreNamesMap) {
// Set up cursor indices for later use.
private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
private val volumes = storageManager.storageVolumesCompat
override fun populateFileInfo(rawSong: RawSong) {
super.populateFileInfo(rawSong)
val data = cursor.getString(dataIndex)
// On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume
// that this only applies to below API 29, as beyond API 29, this column not being
// present would completely break the scoped storage system. Fill it in with DATA
// if it's not available.
if (rawSong.fileName == null) {
rawSong.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
}
// Find the volume that transforms the DATA column into a relative path. This is
// the Directory we will use.
val rawPath = data.substringBeforeLast(File.separatorChar)
for (volume in volumes) {
val volumePath = volume.directoryCompat ?: continue
val strippedPath = rawPath.removePrefix(volumePath)
if (strippedPath != rawPath) {
rawSong.directory = Directory.from(volume, strippedPath)
break
}
}
}
override fun populateTags(rawSong: RawSong) {
super.populateTags(rawSong)
// See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up.
val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) {
rawTrack.unpackTrackNo()?.let { rawSong.track = it }
rawTrack.unpackDiscNo()?.let { rawSong.disc = it }
}
}
} }
} }
/** /**
* A [BaseMediaStoreExtractor] that implements common behavior supported from API 29 onwards. * Wrapper around a [Cursor] that interprets certain tags on a per-API basis.
* *
* @param context [Context] required to query the media database.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@RequiresApi(Build.VERSION_CODES.Q) private sealed interface TagInterpreter {
private abstract class BaseApi29MediaStoreExtractor( /**
context: Context, * Populate the [RawSong] with version-specific tags.
musicSettings: MusicSettings *
) : BaseMediaStoreExtractor(context, musicSettings) { * @param rawSong The [RawSong] to populate.
override val projection: Array<String> */
get() = fun populate(rawSong: RawSong)
super.projection +
arrayOf(
// After API 29, we now have access to the volume name and relative
// path, which simplifies working with Paths significantly.
MediaStore.Audio.AudioColumns.VOLUME_NAME,
MediaStore.Audio.AudioColumns.RELATIVE_PATH)
// The selector should be configured to compare both the volume name and relative path interface Factory {
// of the given directories, albeit with some conversion to the analogous MediaStore /** The columns that must be added to a query to support this interpreter. */
// column values. val projection: Array<String>
override val dirSelectorTemplate: String /**
get() = * Wrap a [Cursor] with this interpreter. This cursor should be the result of a query
"(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " + * containing the columns specified by [projection].
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)" *
* @param cursor The [Cursor] to wrap.
override fun addDirToSelector(dir: Directory, args: MutableList<String>): Boolean { * @return A new [TagInterpreter] that will work best on the device's API level.
// MediaStore uses a different naming scheme for it's volume column convert this */
// directory's volume to it. fun wrap(cursor: Cursor): TagInterpreter
args.add(dir.volume.mediaStoreVolumeNameCompat ?: return false)
// "%" signifies to accept any DATA value that begins with the Directory's path,
// thus recursively filtering all files in the directory.
args.add("${dir.relativePath}%")
return true
}
abstract class Query(
cursor: Cursor,
genreNamesMap: Map<Long, String>,
storageManager: StorageManager
) : BaseMediaStoreExtractor.Query(cursor, genreNamesMap) {
private val volumeIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
private val relativePathIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
private val volumes = storageManager.storageVolumesCompat
final override fun populateFileInfo(rawSong: RawSong) {
super.populateFileInfo(rawSong)
// Find the StorageVolume whose MediaStore name corresponds to this song.
// This is combined with the plain relative path column to create the directory.
val volumeName = cursor.getString(volumeIndex)
val relativePath = cursor.getString(relativePathIndex)
val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName }
if (volume != null) {
rawSong.directory = Directory.from(volume, relativePath)
}
}
} }
} }
/** private class Api21TagInterpreter(private val cursor: Cursor) : TagInterpreter {
* A [BaseMediaStoreExtractor] that completes the music loading process in a way compatible with at private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
* API 29.
*
* @param context [Context] required to query the media database.
* @author Alexander Capehart (OxygenCobalt)
*/
@RequiresApi(Build.VERSION_CODES.Q)
private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
BaseApi29MediaStoreExtractor(context, musicSettings) {
override val projection: Array<String> override fun populate(rawSong: RawSong) {
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) // See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up.
override fun wrapQuery( val rawTrack = cursor.getIntOrNull(trackIndex)
cursor: Cursor, if (rawTrack != null) {
genreNamesMap: Map<Long, String> rawTrack.unpackTrackNo()?.let { rawSong.track = it }
): MediaStoreExtractor.Query = rawTrack.unpackDiscNo()?.let { rawSong.disc = it }
Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
private class Query(
cursor: Cursor,
genreNamesMap: Map<Long, String>,
storageManager: StorageManager
) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) {
private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
override fun populateTags(rawSong: RawSong) {
super.populateTags(rawSong)
// This extractor is volume-aware, but does not support the modern track columns.
// Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation
// of how this column is set up.
val rawTrack = cursor.getIntOrNull(trackIndex)
if (rawTrack != null) {
rawTrack.unpackTrackNo()?.let { rawSong.track = it }
rawTrack.unpackDiscNo()?.let { rawSong.disc = it }
}
} }
} }
class Factory : TagInterpreter.Factory {
override val projection: Array<String>
get() = arrayOf(MediaStore.Audio.AudioColumns.TRACK)
override fun wrap(cursor: Cursor): TagInterpreter = Api21TagInterpreter(cursor)
}
/**
* Unpack the track number from a combined track + disc [Int] field. These fields appear within
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
* disc number is the 4th+ digit.
*
* @return The track number extracted from the combined integer value, or null if the value was
* zero.
*/
private fun Int.unpackTrackNo() = transformPositionField(mod(1000), null)
/**
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
* disc number is the 4th+ digit.
*
* @return The disc number extracted from the combined integer field, or null if the value was
* zero.
*/
private fun Int.unpackDiscNo() = transformPositionField(div(1000), null)
} }
/** private class Api30TagInterpreter(private val cursor: Cursor) : TagInterpreter {
* A [BaseMediaStoreExtractor] that completes the music loading process in a way compatible from API private val trackIndex =
* 30 onwards. cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
* private val discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
* @param context [Context] required to query the media database.
* @author Alexander Capehart (OxygenCobalt) override fun populate(rawSong: RawSong) {
*/ // Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
@RequiresApi(Build.VERSION_CODES.R) // the tag itself, which is to say that it is formatted as NN/TT tracks, where
private class Api30MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : // N is the number and T is the total. Parse the number while ignoring the
BaseApi29MediaStoreExtractor(context, musicSettings) { // total, as we have no use for it.
override val projection: Array<String> cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { rawSong.track = it }
get() = cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { rawSong.disc = it }
super.projection + }
class Factory : TagInterpreter.Factory {
override val projection: Array<String>
get() =
arrayOf( arrayOf(
// API 30 grant us access to the superior CD_TRACK_NUMBER and DISC_NUMBER
// fields, which take the place of TRACK.
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER, MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
MediaStore.Audio.AudioColumns.DISC_NUMBER) MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun wrapQuery( override fun wrap(cursor: Cursor): TagInterpreter = Api30TagInterpreter(cursor)
cursor: Cursor,
genreNamesMap: Map<Long, String>
): MediaStoreExtractor.Query =
Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
private class Query(
cursor: Cursor,
genreNamesMap: Map<Long, String>,
storageManager: StorageManager
) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) {
private val trackIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
private val discIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun populateTags(rawSong: RawSong) {
super.populateTags(rawSong)
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
// N is the number and T is the total. Parse the number while ignoring the
// total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let {
rawSong.track = it
}
cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { rawSong.disc = it }
}
} }
} }
/**
* Unpack the track number from a combined track + disc [Int] field. These fields appear within
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
* disc number is the 4th+ digit.
*
* @return The track number extracted from the combined integer value, or null if the value was
* zero.
*/
private fun Int.unpackTrackNo() = transformPositionField(mod(1000), null)
/**
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
* disc number is the 4th+ digit.
*
* @return The disc number extracted from the combined integer field, or null if the value was zero.
*/
private fun Int.unpackDiscNo() = transformPositionField(div(1000), null)

View file

@ -0,0 +1,237 @@
/*
* Copyright (c) 2024 Auxio Project
* MediaStorePathInterpreter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.fs
import android.database.Cursor
import android.os.Build
import android.provider.MediaStore
/**
* Wrapper around a [Cursor] that interprets path information on a per-API/manufacturer basis.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface MediaStorePathInterpreter {
/**
* Extract a [Path] from the wrapped [Cursor]. This should be called after the cursor has been
* moved to the row that should be interpreted.
*
* @return The [Path] instance, or null if the path could not be extracted.
*/
fun extract(): Path?
interface Factory {
/** The columns that must be added to a query to support this interpreter. */
val projection: Array<String>
/**
* Wrap a [Cursor] with this interpreter. This cursor should be the result of a query
* containing the columns specified by [projection].
*
* @param cursor The [Cursor] to wrap.
* @return A new [MediaStorePathInterpreter] that will work best on the device's API level.
*/
fun wrap(cursor: Cursor): MediaStorePathInterpreter
/**
* Create a selector that will filter the given paths. By default this will filter *to* the
* given paths, to exclude them, use a NOT.
*
* @param paths The paths to filter for.
* @return A selector that will filter to the given paths, or null if a selector could not
* be created from the paths.
*/
fun createSelector(paths: List<Path>): Selector?
/**
* A selector that will filter to the given paths.
*
* @param template The template to use for the selector.
* @param args The arguments to use for the selector.
* @see Factory.createSelector
*/
data class Selector(val template: String, val args: List<String>)
companion object {
/**
* Create a [MediaStorePathInterpreter.Factory] that will work best on the device's API
* level.
*
* @param volumeManager The [VolumeManager] to use for volume information.
*/
fun from(volumeManager: VolumeManager) =
when {
// Huawei violates the API docs and prevents you from accessing the new path
// fields without first granting access to them through SAF. Fall back to DATA
// instead.
Build.MANUFACTURER.equals("huawei", ignoreCase = true) ||
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ->
DataMediaStorePathInterpreter.Factory(volumeManager)
else -> VolumeMediaStorePathInterpreter.Factory(volumeManager)
}
}
}
}
/**
* Wrapper around a [Cursor] that interprets the DATA column as a path. Create an instance with
* [Factory].
*/
private class DataMediaStorePathInterpreter
private constructor(private val cursor: Cursor, volumeManager: VolumeManager) :
MediaStorePathInterpreter {
private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
private val volumes = volumeManager.getVolumes()
override fun extract(): Path? {
val data = Components.parseUnix(cursor.getString(dataIndex))
// Find the volume that transforms the DATA column into a relative path. This is
// the Directory we will use.
for (volume in volumes) {
val volumePath = volume.components ?: continue
if (volumePath.contains(data)) {
return Path(volume, data.depth(volumePath.components.size))
}
}
return null
}
/**
* Factory for [DataMediaStorePathInterpreter].
*
* @param volumeManager The [VolumeManager] to use for volume information.
*/
class Factory(private val volumeManager: VolumeManager) : MediaStorePathInterpreter.Factory {
override val projection: Array<String>
get() =
arrayOf(
MediaStore.Audio.AudioColumns.DISPLAY_NAME, MediaStore.Audio.AudioColumns.DATA)
override fun createSelector(
paths: List<Path>
): MediaStorePathInterpreter.Factory.Selector? {
val args = mutableListOf<String>()
var template = ""
for (i in paths.indices) {
val path = paths[i]
val volume = path.volume.components ?: continue
template +=
if (i == 0) {
"${MediaStore.Audio.AudioColumns.DATA} LIKE ?"
} else {
" OR ${MediaStore.Audio.AudioColumns.DATA} LIKE ?"
}
args.add("${volume}${path.components}%")
}
if (template.isEmpty()) {
return null
}
return MediaStorePathInterpreter.Factory.Selector(template, args)
}
override fun wrap(cursor: Cursor): MediaStorePathInterpreter =
DataMediaStorePathInterpreter(cursor, volumeManager)
}
}
/**
* Wrapper around a [Cursor] that interprets the VOLUME_NAME, RELATIVE_PATH, and DISPLAY_NAME
* columns as a path. Create an instance with [Factory].
*/
private class VolumeMediaStorePathInterpreter
private constructor(private val cursor: Cursor, volumeManager: VolumeManager) :
MediaStorePathInterpreter {
private val displayNameIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
private val volumeIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
private val relativePathIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
private val volumes = volumeManager.getVolumes()
override fun extract(): Path? {
// Find the StorageVolume whose MediaStore name corresponds to it.
val volumeName = cursor.getString(volumeIndex)
val volume = volumes.find { it.mediaStoreName == volumeName } ?: return null
// Relative path does not include file name, must use DISPLAY_NAME and add it
// in manually.
val relativePath = cursor.getString(relativePathIndex)
val displayName = cursor.getString(displayNameIndex)
val components = Components.parseUnix(relativePath).child(displayName)
return Path(volume, components)
}
/**
* Factory for [VolumeMediaStorePathInterpreter].
*
* @param volumeManager The [VolumeManager] to use for volume information.
*/
class Factory(private val volumeManager: VolumeManager) : MediaStorePathInterpreter.Factory {
override val projection: Array<String>
get() =
arrayOf(
// After API 29, we now have access to the volume name and relative
// path, which hopefully are more standard and less likely to break
// compared to DATA.
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
MediaStore.Audio.AudioColumns.VOLUME_NAME,
MediaStore.Audio.AudioColumns.RELATIVE_PATH)
// The selector should be configured to compare both the volume name and relative path
// of the given directories, albeit with some conversion to the analogous MediaStore
// column values.
override fun createSelector(
paths: List<Path>
): MediaStorePathInterpreter.Factory.Selector? {
val args = mutableListOf<String>()
var template = ""
for (i in paths.indices) {
val path = paths[i]
template =
if (i == 0) {
"(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
} else {
" OR (${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
}
// MediaStore uses a different naming scheme for it's volume column. Convert this
// directory's volume to it.
args.add(path.volume.mediaStoreName ?: return null)
// "%" signifies to accept any DATA value that begins with the Directory's path,
// thus recursively filtering all files in the directory.
args.add("${path.components}%")
}
if (template.isEmpty()) {
return null
}
return MediaStorePathInterpreter.Factory.Selector(template, args)
}
override fun wrap(cursor: Cursor): MediaStorePathInterpreter =
VolumeMediaStorePathInterpreter(cursor, volumeManager)
}
}

View file

@ -23,12 +23,11 @@ import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import java.text.CollationKey import java.text.CollationKey
import java.text.Collator import java.text.Collator
import org.oxycblt.auxio.music.MusicSettings
/** /**
* The name of a music item. * The name of a music item.
* *
* This class automatically implements * This class automatically implements advanced sorting heuristics for music naming,
* *
* @author Alexander Capehart * @author Alexander Capehart
*/ */
@ -80,7 +79,7 @@ sealed interface Name : Comparable<Name> {
is Unknown -> 1 is Unknown -> 1
} }
interface Factory { sealed interface Factory {
/** /**
* Create a new instance of [Name.Known] * Create a new instance of [Name.Known]
* *
@ -88,22 +87,16 @@ sealed interface Name : Comparable<Name> {
* @param sort The raw sort name obtained from the music item * @param sort The raw sort name obtained from the music item
*/ */
fun parse(raw: String, sort: String?): Known fun parse(raw: String, sort: String?): Known
}
companion object { /** Produces a simple [Known] with basic sorting heuristics that are locale-independent. */
/** data object SimpleFactory : Factory {
* Creates a new instance from the **current state** of the given [MusicSettings]'s override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort)
* user-defined name configuration. }
*
* @param settings The [MusicSettings] to use. /** Produces an intelligent [Known] with advanced, but more fragile heuristics. */
* @return A [Factory] instance reflecting the configuration state. data object IntelligentFactory : Factory {
*/ override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort)
fun from(settings: MusicSettings) =
if (settings.intelligentSorting) {
IntelligentKnownName.Factory
} else {
SimpleKnownName.Factory
}
}
} }
} }
@ -137,7 +130,6 @@ private val punctRegex by lazy { Regex("[\\p{Punct}+]") }
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@VisibleForTesting
data class SimpleKnownName(override val raw: String, override val sort: String?) : Name.Known() { data class SimpleKnownName(override val raw: String, override val sort: String?) : Name.Known() {
override val sortTokens = listOf(parseToken(sort ?: raw)) override val sortTokens = listOf(parseToken(sort ?: raw))
@ -148,10 +140,6 @@ data class SimpleKnownName(override val raw: String, override val sort: String?)
// Always use lexicographic mode since we aren't parsing any numeric components // Always use lexicographic mode since we aren't parsing any numeric components
return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC) return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC)
} }
data object Factory : Name.Known.Factory {
override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort)
}
} }
/** /**
@ -159,7 +147,6 @@ data class SimpleKnownName(override val raw: String, override val sort: String?)
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@VisibleForTesting
data class IntelligentKnownName(override val raw: String, override val sort: String?) : data class IntelligentKnownName(override val raw: String, override val sort: String?) :
Name.Known() { Name.Known() {
override val sortTokens = parseTokens(sort ?: raw) override val sortTokens = parseTokens(sort ?: raw)
@ -208,10 +195,6 @@ data class IntelligentKnownName(override val raw: String, override val sort: Str
} }
} }
data object Factory : Name.Known.Factory {
override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort)
}
companion object { companion object {
private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") } private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") }
} }

View file

@ -143,6 +143,18 @@ sealed interface ReleaseType {
get() = R.string.lbl_mixtape get() = R.string.lbl_mixtape
} }
/**
* A demo. These are usually [EP]-sized releases of music made to promote an Artist or a future
* release.
*/
data object Demo : ReleaseType {
override val refinement: Refinement?
get() = null
override val stringRes: Int
get() = R.string.lbl_demo
}
/** A specification of what kind of performance a particular release is. */ /** A specification of what kind of performance a particular release is. */
enum class Refinement { enum class Refinement {
/** A release consisting of a live performance */ /** A release consisting of a live performance */
@ -220,6 +232,7 @@ sealed interface ReleaseType {
type.equals("dj-mix", true) -> Mix type.equals("dj-mix", true) -> Mix
type.equals("live", true) -> convertRefinement(Refinement.LIVE) type.equals("live", true) -> convertRefinement(Refinement.LIVE)
type.equals("remix", true) -> convertRefinement(Refinement.REMIX) type.equals("remix", true) -> convertRefinement(Refinement.REMIX)
type.equals("demo", true) -> Demo
else -> convertRefinement(null) else -> convertRefinement(null)
} }
} }

View file

@ -18,9 +18,6 @@
package org.oxycblt.auxio.music.metadata package org.oxycblt.auxio.music.metadata
import androidx.annotation.VisibleForTesting
import org.oxycblt.auxio.music.MusicSettings
/** /**
* Defines the user-specified parsing of multi-value tags. This should be used to parse any tags * Defines the user-specified parsing of multi-value tags. This should be used to parse any tags
* that may be delimited with a separator character. * that may be delimited with a separator character.
@ -45,15 +42,12 @@ interface Separators {
const val AND = '&' const val AND = '&'
/** /**
* Creates a new instance from the **current state** of the given [MusicSettings]'s * Creates a new instance from a string of separator characters to use.
* user-defined separator configuration.
* *
* @param settings The [MusicSettings] to use. * @param chars The separator characters to use. Each character in the string will be
* @return A new [Separators] instance reflecting the configuration state. * checked for when splitting a string list.
* @return A new [Separators] instance reflecting the separators.
*/ */
fun from(settings: MusicSettings) = from(settings.separators)
@VisibleForTesting
fun from(chars: String) = fun from(chars: String) =
if (chars.isNotEmpty()) { if (chars.isNotEmpty()) {
CharSeparators(chars.toSet()) CharSeparators(chars.toSet())

View file

@ -23,7 +23,9 @@ import javax.inject.Inject
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.util.forEachWithTimeout
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.sendWithTimeout
/** /**
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the * The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
@ -55,14 +57,14 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork
logD("Beginning primary extraction loop") logD("Beginning primary extraction loop")
for (incompleteRawSong in incompleteSongs) { incompleteSongs.forEachWithTimeout { incompleteRawSong ->
spin@ while (true) { spin@ while (true) {
for (i in tagWorkerPool.indices) { for (i in tagWorkerPool.indices) {
val worker = tagWorkerPool[i] val worker = tagWorkerPool[i]
if (worker != null) { if (worker != null) {
val completeRawSong = worker.poll() val completeRawSong = worker.poll()
if (completeRawSong != null) { if (completeRawSong != null) {
completeSongs.send(completeRawSong) completeSongs.sendWithTimeout(completeRawSong)
yield() yield()
} else { } else {
continue continue
@ -83,7 +85,7 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork
if (task != null) { if (task != null) {
val completeRawSong = task.poll() val completeRawSong = task.poll()
if (completeRawSong != null) { if (completeRawSong != null) {
completeSongs.send(completeRawSong) completeSongs.sendWithTimeout(completeRawSong)
tagWorkerPool[i] = null tagWorkerPool[i] = null
yield() yield()
} else { } else {

View file

@ -32,7 +32,6 @@ import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.IndexingProgress import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.IndexingState
@ -124,12 +123,11 @@ class IndexerService :
// --- CONTROLLER CALLBACKS --- // --- CONTROLLER CALLBACKS ---
override fun requestIndex(withCache: Boolean) { override fun requestIndex(withCache: Boolean) {
logD("Starting new indexing job") logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})")
// Cancel the previous music loading job. // Cancel the previous music loading job.
currentIndexJob?.cancel() currentIndexJob?.cancel()
// Start a new music loading job on a co-routine. // Start a new music loading job on a co-routine.
currentIndexJob = currentIndexJob = musicRepository.index(this@IndexerService, withCache)
indexScope.launch { musicRepository.index(this@IndexerService, withCache) }
} }
override val context = this override val context = this

View file

@ -22,7 +22,6 @@ import java.lang.Exception
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.device.DeviceLibrary
@ -82,7 +81,8 @@ interface UserLibrary {
*/ */
suspend fun create( suspend fun create(
rawPlaylists: List<RawPlaylist>, rawPlaylists: List<RawPlaylist>,
deviceLibrary: DeviceLibrary deviceLibrary: DeviceLibrary,
nameFactory: Name.Known.Factory
): MutableUserLibrary ): MutableUserLibrary
} }
} }
@ -139,9 +139,7 @@ interface MutableUserLibrary : UserLibrary {
suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>): Boolean suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>): Boolean
} }
class UserLibraryFactoryImpl class UserLibraryFactoryImpl @Inject constructor(private val playlistDao: PlaylistDao) :
@Inject
constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) :
UserLibrary.Factory { UserLibrary.Factory {
override suspend fun query() = override suspend fun query() =
try { try {
@ -155,22 +153,22 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus
override suspend fun create( override suspend fun create(
rawPlaylists: List<RawPlaylist>, rawPlaylists: List<RawPlaylist>,
deviceLibrary: DeviceLibrary deviceLibrary: DeviceLibrary,
nameFactory: Name.Known.Factory
): MutableUserLibrary { ): MutableUserLibrary {
val nameFactory = Name.Known.Factory.from(musicSettings)
val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>() val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
for (rawPlaylist in rawPlaylists) { for (rawPlaylist in rawPlaylists) {
val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, nameFactory) val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, nameFactory)
playlistMap[playlistImpl.uid] = playlistImpl playlistMap[playlistImpl.uid] = playlistImpl
} }
return UserLibraryImpl(playlistDao, playlistMap, musicSettings) return UserLibraryImpl(playlistDao, playlistMap, nameFactory)
} }
} }
private class UserLibraryImpl( private class UserLibraryImpl(
private val playlistDao: PlaylistDao, private val playlistDao: PlaylistDao,
private val playlistMap: MutableMap<Music.UID, PlaylistImpl>, private val playlistMap: MutableMap<Music.UID, PlaylistImpl>,
private val musicSettings: MusicSettings private val nameFactory: Name.Known.Factory
) : MutableUserLibrary { ) : MutableUserLibrary {
override fun hashCode() = playlistMap.hashCode() override fun hashCode() = playlistMap.hashCode()
@ -186,7 +184,7 @@ private class UserLibraryImpl(
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
override suspend fun createPlaylist(name: String, songs: List<Song>): Playlist? { override suspend fun createPlaylist(name: String, songs: List<Song>): Playlist? {
val playlistImpl = PlaylistImpl.from(name, songs, Name.Known.Factory.from(musicSettings)) val playlistImpl = PlaylistImpl.from(name, songs, nameFactory)
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
val rawPlaylist = val rawPlaylist =
RawPlaylist( RawPlaylist(
@ -209,9 +207,7 @@ private class UserLibraryImpl(
val playlistImpl = val playlistImpl =
synchronized(this) { synchronized(this) {
requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" }
.also { .also { playlistMap[it.uid] = it.edit(name, nameFactory) }
playlistMap[it.uid] = it.edit(name, Name.Known.Factory.from(musicSettings))
}
} }
return try { return try {

View file

@ -38,7 +38,9 @@ import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.ui.PlaybackPagerAdapter
import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.playback.ui.StyledSeekBar
import org.oxycblt.auxio.playback.ui.SwipeCoverView
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -58,11 +60,13 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
class PlaybackPanelFragment : class PlaybackPanelFragment :
ViewBindingFragment<FragmentPlaybackPanelBinding>(), ViewBindingFragment<FragmentPlaybackPanelBinding>(),
Toolbar.OnMenuItemClickListener, Toolbar.OnMenuItemClickListener,
StyledSeekBar.Listener { StyledSeekBar.Listener,
SwipeCoverView.OnSwipeListener {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val listModel: ListViewModel by activityViewModels() private val listModel: ListViewModel by activityViewModels()
private var equalizerLauncher: ActivityResultLauncher<Intent>? = null private var equalizerLauncher: ActivityResultLauncher<Intent>? = null
private var coverAdapter: PlaybackPagerAdapter? = null
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
FragmentPlaybackPanelBinding.inflate(inflater) FragmentPlaybackPanelBinding.inflate(inflater)
@ -99,20 +103,10 @@ class PlaybackPanelFragment :
} }
} }
// Set up marquee on song information, alongside click handlers that navigate to each binding.playbackCover.onSwipeListener = this
// respective item. binding.playbackSong.setOnClickListener { navigateToCurrentSong() }
binding.playbackSong.apply { binding.playbackArtist.setOnClickListener { navigateToCurrentArtist() }
isSelected = true binding.playbackAlbum.setOnClickListener { navigateToCurrentAlbum() }
setOnClickListener { playbackModel.song.value?.let(detailModel::showAlbum) }
}
binding.playbackArtist.apply {
isSelected = true
setOnClickListener { navigateToCurrentArtist() }
}
binding.playbackAlbum.apply {
isSelected = true
setOnClickListener { navigateToCurrentAlbum() }
}
binding.playbackSeekBar.listener = this binding.playbackSeekBar.listener = this
@ -135,11 +129,8 @@ class PlaybackPanelFragment :
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
equalizerLauncher = null equalizerLauncher = null
coverAdapter = null
binding.playbackToolbar.setOnMenuItemClickListener(null) binding.playbackToolbar.setOnMenuItemClickListener(null)
// Marquee elements leak if they are not disabled when the views are destroyed.
binding.playbackSong.isSelected = false
binding.playbackArtist.isSelected = false
binding.playbackAlbum.isSelected = false
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
@ -170,6 +161,14 @@ class PlaybackPanelFragment :
playbackModel.seekTo(positionDs) playbackModel.seekTo(positionDs)
} }
override fun onSwipePrevious() {
playbackModel.prev()
}
override fun onSwipeNext() {
playbackModel.next()
}
private fun updateSong(song: Song?) { private fun updateSong(song: Song?) {
if (song == null) { if (song == null) {
// Nothing to do. // Nothing to do.
@ -212,6 +211,10 @@ class PlaybackPanelFragment :
requireBinding().playbackShuffle.isActivated = isShuffled requireBinding().playbackShuffle.isActivated = isShuffled
} }
private fun navigateToCurrentSong() {
playbackModel.song.value?.let(detailModel::showAlbum)
}
private fun navigateToCurrentArtist() { private fun navigateToCurrentArtist() {
playbackModel.song.value?.let(detailModel::showArtist) playbackModel.song.value?.let(detailModel::showArtist)
} }

View file

@ -22,6 +22,7 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -42,7 +43,7 @@ import org.oxycblt.auxio.util.logD
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditClickListListener<Song> { class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditClickListListener<Song> {
private val queueModel: QueueViewModel by activityViewModels() private val queueModel: QueueViewModel by viewModels()
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val queueAdapter = QueueAdapter(this) private val queueAdapter = QueueAdapter(this)
private var touchHelper: ItemTouchHelper? = null private var touchHelper: ItemTouchHelper? = null

View file

@ -121,14 +121,14 @@ class PlaybackService :
// battery/apk size/cache size // battery/apk size/cache size
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
arrayOf( arrayOf(
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
MediaCodecAudioRenderer( MediaCodecAudioRenderer(
this, this,
MediaCodecSelector.DEFAULT, MediaCodecSelector.DEFAULT,
handler, handler,
audioListener, audioListener,
AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
replayGainProcessor), replayGainProcessor))
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor))
} }
player = player =

View file

@ -0,0 +1,121 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaybackPagerAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.ui
import android.view.ViewGroup
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import kotlin.jvm.internal.Intrinsics
import org.oxycblt.auxio.databinding.ItemPlaybackSongBinding
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.inflater
/** @author Koitharu, Alexander Capehart (OxygenCobalt) */
class PlaybackPagerAdapter(private val listener: Listener) :
FlexibleListAdapter<Song, CoverViewHolder>(CoverViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoverViewHolder {
return CoverViewHolder.from(parent)
}
override fun onBindViewHolder(holder: CoverViewHolder, position: Int) {
holder.bind(getItem(position), listener)
}
override fun onViewRecycled(holder: CoverViewHolder) {
holder.recycle()
super.onViewRecycled(holder)
}
interface Listener {
fun navigateToCurrentArtist()
fun navigateToCurrentAlbum()
fun navigateToCurrentSong()
fun navigateToMenu()
}
}
class CoverViewHolder private constructor(private val binding: ItemPlaybackSongBinding) :
RecyclerView.ViewHolder(binding.root), DefaultLifecycleObserver {
init {
binding.root.layoutParams =
RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT)
}
/**
* Bind new data to this instance.
*
* @param item The new [Song] to bind.
*/
fun bind(item: Song, listener: PlaybackPagerAdapter.Listener) {
val context = binding.root.context
binding.playbackCover.bind(item)
// binding.playbackCover.bind(item)
binding.playbackSong.apply { text = item.name.resolve(context) }
binding.playbackArtist.apply {
text = item.artists.resolveNames(context)
setOnClickListener { listener.navigateToCurrentArtist() }
}
binding.playbackAlbum.apply {
text = item.album.name.resolve(context)
setOnClickListener { listener.navigateToCurrentAlbum() }
}
setSelected(true)
}
fun recycle() {
// Marquee elements leak if they are not disabled when the views are destroyed.
// TODO: Move to TextView impl to avoid having to deal with lifecycle here
setSelected(false)
}
private fun setSelected(value: Boolean) {
binding.playbackSong.isSelected = value
binding.playbackArtist.isSelected = value
binding.playbackAlbum.isSelected = value
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: ViewGroup) =
CoverViewHolder(ItemPlaybackSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : DiffUtil.ItemCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) =
oldItem.uid == newItem.uid
override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
}
}
}

View file

@ -0,0 +1,107 @@
/*
* Copyright (c) 2023 Auxio Project
* SwipeCoverView.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.playback.ui
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.GestureDetector.OnGestureListener
import android.view.MotionEvent
import android.view.ViewConfiguration
import androidx.annotation.AttrRes
import kotlin.math.abs
import org.oxycblt.auxio.image.CoverView
import org.oxycblt.auxio.util.isRtl
class SwipeCoverView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
CoverView(context, attrs, defStyleAttr), OnGestureListener {
private val gestureDetector = GestureDetector(context, this)
private val viewConfig = ViewConfiguration.get(context)
var onSwipeListener: OnSwipeListener? = null
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
}
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
return gestureDetector.onGenericMotionEvent(event) || super.onGenericMotionEvent(event)
}
override fun onDown(e: MotionEvent): Boolean = true
override fun onShowPress(e: MotionEvent) = Unit
override fun onSingleTapUp(e: MotionEvent): Boolean = false
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean = false
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
e1 ?: return false
val diffY = e2.y - e1.y
val diffX = e2.x - e1.x
if (abs(diffX) > abs(diffY) &&
abs(diffX) > viewConfig.scaledTouchSlop &&
abs(velocityX) > viewConfig.scaledMinimumFlingVelocity) {
if (diffX > 0) {
onSwipeRight()
} else {
onSwipeLeft()
}
return true
}
return false
}
override fun onLongPress(e: MotionEvent) = Unit
private fun onSwipeRight() {
onSwipeListener?.run { if (isRtl) onSwipeNext() else onSwipePrevious() }
}
private fun onSwipeLeft() {
onSwipeListener?.run { if (isRtl) onSwipePrevious() else onSwipeNext() }
}
interface OnSwipeListener {
fun onSwipePrevious()
fun onSwipeNext()
}
}

View file

@ -71,7 +71,7 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte
return SearchEngine.Items( return SearchEngine.Items(
songs = songs =
items.songs?.searchListImpl(query) { q, song -> items.songs?.searchListImpl(query) { q, song ->
song.path.name.contains(q, ignoreCase = true) song.path.name?.contains(q, ignoreCase = true) == true
}, },
albums = items.albums?.searchListImpl(query), albums = items.albums?.searchListImpl(query),
artists = items.artists?.searchListImpl(query), artists = items.artists?.searchListImpl(query),

View file

@ -23,6 +23,8 @@ import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.postDelayed import androidx.core.view.postDelayed
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
@ -50,7 +52,9 @@ import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
@ -58,8 +62,10 @@ import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.setFullWidthLookup import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
/** /**
* The [ListFragment] providing search functionality for the music library. * The [ListFragment] providing search functionality for the music library.
@ -77,6 +83,8 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels()
private val searchAdapter = SearchAdapter(this) private val searchAdapter = SearchAdapter(this)
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var pendingImportTarget: Playlist? = null
private var imm: InputMethodManager? = null private var imm: InputMethodManager? = null
private var launchedKeyboard = false private var launchedKeyboard = false
@ -98,6 +106,19 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
imm = binding.context.getSystemServiceCompat(InputMethodManager::class) imm = binding.context.getSystemServiceCompat(InputMethodManager::class)
getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
logW("No URI returned from file picker")
return@registerForActivityResult
}
logD("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget)
}
// --- UI SETUP ---
binding.searchNormalToolbar.apply { binding.searchNormalToolbar.apply {
// Initialize the current filtering mode. // Initialize the current filtering mode.
menu.findItem(searchModel.getFilterOptionId()).isChecked = true menu.findItem(searchModel.getFilterOptionId()).isChecked = true
@ -141,7 +162,8 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
collectImmediately(searchModel.searchResults, ::updateSearchResults) collectImmediately(searchModel.searchResults, ::updateSearchResults)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
collect(musicModel.playlistDecision.flow, ::handleDecision) collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
@ -283,18 +305,36 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
} }
} }
private fun handleDecision(decision: PlaylistDecision?) { private fun handlePlaylistDecision(decision: PlaylistDecision?) {
if (decision == null) return if (decision == null) return
val directions = val directions =
when (decision) { when (decision) {
is PlaylistDecision.Import -> {
logD("Importing playlist")
pendingImportTarget = decision.target
requireNotNull(getContentLauncher) {
"Content picker launcher was not available"
}
.launch(M3U.MIME_TYPE)
musicModel.playlistDecision.consume()
return
}
is PlaylistDecision.Rename -> { is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}") logD("Renaming ${decision.playlist}")
SearchFragmentDirections.renamePlaylist(decision.playlist.uid) SearchFragmentDirections.renamePlaylist(
decision.playlist.uid,
decision.template,
decision.applySongs.map { it.uid }.toTypedArray(),
decision.reason)
} }
is PlaylistDecision.Delete -> { is PlaylistDecision.Delete -> {
logD("Deleting ${decision.playlist}") logD("Deleting ${decision.playlist}")
SearchFragmentDirections.deletePlaylist(decision.playlist.uid) SearchFragmentDirections.deletePlaylist(decision.playlist.uid)
} }
is PlaylistDecision.Export -> {
logD("Exporting ${decision.playlist}")
SearchFragmentDirections.exportPlaylist(decision.playlist.uid)
}
is PlaylistDecision.Add -> { is PlaylistDecision.Add -> {
logD("Adding ${decision.songs.size} to a playlist") logD("Adding ${decision.songs.size} to a playlist")
SearchFragmentDirections.addToPlaylist( SearchFragmentDirections.addToPlaylist(
@ -307,6 +347,12 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
findNavController().navigateSafe(directions) findNavController().navigateSafe(directions)
} }
private fun handlePlaylistMessage(message: PlaylistMessage?) {
if (message == null) return
requireContext().showToast(message.stringRes)
musicModel.playlistMessage.consume()
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
searchAdapter.setPlaying(parent ?: song, isPlaying) searchAdapter.setPlaying(parent ?: song, isPlaying)
} }

View file

@ -68,12 +68,6 @@ class BottomSheetContentBehavior<V : View>(context: Context, attributeSet: Attri
if (consumed != lastConsumed) { if (consumed != lastConsumed) {
logD("Consumed amount changed, re-applying insets") logD("Consumed amount changed, re-applying insets")
lastConsumed = consumed lastConsumed = consumed
val insets = lastInsets
if (insets != null) {
child.dispatchApplyWindowInsets(insets)
}
lastInsets?.let(child::dispatchApplyWindowInsets) lastInsets?.let(child::dispatchApplyWindowInsets)
measureContent(parent, child, consumed) measureContent(parent, child, consumed)
layoutContent(child) layoutContent(child)

View file

@ -22,11 +22,16 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import java.util.concurrent.TimeoutException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
/** /**
* A wrapper around [StateFlow] exposing a one-time consumable event. * A wrapper around [StateFlow] exposing a one-time consumable event.
@ -146,3 +151,57 @@ private fun Fragment.launch(
) { ) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(state, block) } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(state, block) }
} }
/**
* Wraps [SendChannel.send] with a specified timeout.
*
* @param element The element to send.
* @param timeout The timeout in milliseconds. Defaults to 10 seconds.
* @throws TimeoutException If the timeout is reached, provides context on what element
* specifically.
*/
suspend fun <E> SendChannel<E>.sendWithTimeout(element: E, timeout: Long = 10000) {
try {
withTimeout(timeout) { send(element) }
} catch (e: TimeoutCancellationException) {
throw TimeoutException("Timed out sending element $element to channel: $e")
}
}
/**
* Wraps a [ReceiveChannel] consumption with a specified timeout. Note that the timeout will only
* start on the first element received, as to prevent initialization of dependent coroutines being
* interpreted as a timeout.
*
* @param action The action to perform on each element received.
* @param timeout The timeout in milliseconds. Defaults to 10 seconds.
* @throws TimeoutException If the timeout is reached, provides context on what element
* specifically.
*/
suspend fun <E> ReceiveChannel<E>.forEachWithTimeout(
timeout: Long = 10000,
action: suspend (E) -> Unit
) {
var exhausted = false
var subsequent = false
val handler: suspend () -> Unit = {
val value = receiveCatching()
if (value.isClosed && value.exceptionOrNull() == null) {
exhausted = true
} else {
action(value.getOrThrow())
}
}
while (!exhausted) {
try {
if (subsequent) {
withTimeout(timeout) { handler() }
} else {
handler()
subsequent = true
}
} catch (e: TimeoutCancellationException) {
throw TimeoutException("Timed out receiving element from channel: $e")
}
}
}

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24" android:viewportHeight="24">
android:tint="?attr/colorControlNormal"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M11,19V13H5V11H11V5H13V11H19V13H13V19Z" />
android:pathData="M11,19V13H5V11H11V5H13V11H19V13H13V19Z"/>
</vector> </vector>

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640L360,640Z" />
android:pathData="M200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640L360,640Z"/>
</vector> </vector>

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M360,720L800,720Q800,720 800,720Q800,720 800,720L800,613L360,613L360,720ZM160,347L280,347L280,240L160,240Q160,240 160,240Q160,240 160,240L160,347ZM160,534L280,534L280,427L160,427L160,534ZM160,720L280,720L280,613L160,613L160,720Q160,720 160,720Q160,720 160,720ZM360,534L800,534L800,427L360,427L360,534ZM360,347L800,347L800,240Q800,240 800,240Q800,240 800,240L360,240L360,347ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800Z" />
android:pathData="M360,720L800,720Q800,720 800,720Q800,720 800,720L800,613L360,613L360,720ZM160,347L280,347L280,240L160,240Q160,240 160,240Q160,240 160,240L160,347ZM160,534L280,534L280,427L160,427L160,534ZM160,720L280,720L280,613L160,613L160,720Q160,720 160,720Q160,720 160,720ZM360,534L800,534L800,427L360,427L360,534ZM360,347L800,347L800,240Q800,240 800,240Q800,240 800,240L360,240L360,347ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800Z"/>
</vector> </vector>

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L715,76L884,245L772,357ZM120,840L120,670L544,246L714,416L290,840L120,840ZM573,387L545,359L545,359L601,415L601,415L573,387Z" />
android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L715,76L884,245L772,357ZM120,840L120,670L544,246L714,416L290,840L120,840ZM573,387L545,359L545,359L601,415L601,415L573,387Z"/>
</vector> </vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,760L520,760L520,593L584,657L640,600L480,440L320,600L377,656L440,593L440,760ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L520,360ZM240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z" />
</vector>

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M780,900L720,840L840,720L720,600L780,540L960,720L780,900ZM320,840L320,760L160,760Q127,760 103.5,736.5Q80,713 80,680L80,200Q80,167 103.5,143.5Q127,120 160,120L800,120Q833,120 856.5,143.5Q880,167 880,200L880,480L800,480L800,200Q800,200 800,200Q800,200 800,200L160,200Q160,200 160,200Q160,200 160,200L160,680Q160,680 160,680Q160,680 160,680L680,680L680,760L600,760L600,840L320,840ZM440,600L520,600L520,480L640,480L640,400L520,400L520,280L440,280L440,400L320,400L320,480L440,480L440,600ZM160,680L160,680Q160,680 160,680Q160,680 160,680L160,200Q160,200 160,200Q160,200 160,200L160,200Q160,200 160,200Q160,200 160,200L160,680L160,680L160,680Z" />
android:pathData="M780,900L720,840L840,720L720,600L780,540L960,720L780,900ZM320,840L320,760L160,760Q127,760 103.5,736.5Q80,713 80,680L80,200Q80,167 103.5,143.5Q127,120 160,120L800,120Q833,120 856.5,143.5Q880,167 880,200L880,480L800,480L800,200Q800,200 800,200Q800,200 800,200L160,200Q160,200 160,200Q160,200 160,200L160,680Q160,680 160,680Q160,680 160,680L680,680L680,760L600,760L600,840L320,840ZM440,600L520,600L520,480L640,480L640,400L520,400L520,280L440,280L440,400L320,400L320,480L440,480L440,600ZM160,680L160,680Q160,680 160,680Q160,680 160,680L160,200Q160,200 160,200Q160,200 160,200L160,200Q160,200 160,200Q160,200 160,200L160,680L160,680L160,680Z"/>
</vector> </vector>

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24" android:viewportHeight="24">
android:tint="?attr/colorControlNormal"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M2.5,16V14H10.5V16ZM2.5,12V10H14.5V12ZM2.5,8V6H14.5V8ZM15.5,21V13L21.5,17Z" />
android:pathData="M2.5,16V14H10.5V16ZM2.5,12V10H14.5V12ZM2.5,8V6H14.5V8ZM15.5,21V13L21.5,17Z"/>
</vector> </vector>

View file

@ -2,11 +2,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M120,640L120,560L400,560L400,640L120,640ZM120,480L120,400L560,400L560,480L120,480ZM120,320L120,240L560,240L560,320L120,320ZM640,800L640,640L480,640L480,560L640,560L640,400L720,400L720,560L880,560L880,640L720,640L720,800L640,800Z" />
android:pathData="M120,640L120,560L400,560L400,640L120,640ZM120,480L120,400L560,400L560,480L120,480ZM120,320L120,240L560,240L560,320L120,320ZM640,800L640,640L480,640L480,560L640,560L640,400L720,400L720,560L880,560L880,640L720,640L720,800L640,800Z"/>
</vector> </vector>

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M440,600L520,600L520,480L640,480L640,400L520,400L520,280L440,280L440,400L320,400L320,480L440,480L440,600ZM320,840L320,760L160,760Q127,760 103.5,736.5Q80,713 80,680L80,200Q80,167 103.5,143.5Q127,120 160,120L800,120Q833,120 856.5,143.5Q880,167 880,200L880,680Q880,713 856.5,736.5Q833,760 800,760L640,760L640,840L320,840ZM160,680L800,680Q800,680 800,680Q800,680 800,680L800,200Q800,200 800,200Q800,200 800,200L160,200Q160,200 160,200Q160,200 160,200L160,680Q160,680 160,680Q160,680 160,680ZM160,680Q160,680 160,680Q160,680 160,680L160,200Q160,200 160,200Q160,200 160,200L160,200Q160,200 160,200Q160,200 160,200L160,680Q160,680 160,680Q160,680 160,680L160,680Z" />
android:pathData="M440,600L520,600L520,480L640,480L640,400L520,400L520,280L440,280L440,400L320,400L320,480L440,480L440,600ZM320,840L320,760L160,760Q127,760 103.5,736.5Q80,713 80,680L80,200Q80,167 103.5,143.5Q127,120 160,120L800,120Q833,120 856.5,143.5Q880,167 880,200L880,680Q880,713 856.5,736.5Q833,760 800,760L640,760L640,840L320,840ZM160,680L800,680Q800,680 800,680Q800,680 800,680L800,200Q800,200 800,200Q800,200 800,200L160,200Q160,200 160,200Q160,200 160,200L160,680Q160,680 160,680Q160,680 160,680ZM160,680Q160,680 160,680Q160,680 160,680L160,200Q160,200 160,200Q160,200 160,200L160,200Q160,200 160,200Q160,200 160,200L160,680Q160,680 160,680Q160,680 160,680L160,680Z"/>
</vector> </vector>

View file

@ -2,11 +2,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="@color/sel_activatable_icon"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="@color/sel_activatable_icon"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M840,280L840,840L120,840L120,120L680,120L840,280ZM760,314L646,200L200,200L200,760L760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760L200,760L200,200L200,200L200,314Z" />
android:pathData="M840,280L840,840L120,840L120,120L680,120L840,280ZM760,314L646,200L200,200L200,760L760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760L200,760L200,200L200,200L200,314Z"/>
</vector> </vector>

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M720,880Q670,880 635,845Q600,810 600,760Q600,753 601,745.5Q602,738 604,732L322,568Q305,583 284,591.5Q263,600 240,600Q190,600 155,565Q120,530 120,480Q120,430 155,395Q190,360 240,360Q263,360 284,368.5Q305,377 322,392L604,228Q602,222 601,214.5Q600,207 600,200Q600,150 635,115Q670,80 720,80Q770,80 805,115Q840,150 840,200Q840,250 805,285Q770,320 720,320Q697,320 676,311.5Q655,303 638,288L356,452Q358,458 359,465.5Q360,473 360,480Q360,487 359,494.5Q358,502 356,508L638,672Q655,657 676,648.5Q697,640 720,640Q770,640 805,675Q840,710 840,760Q840,810 805,845Q770,880 720,880ZM720,240Q737,240 748.5,228.5Q760,217 760,200Q760,183 748.5,171.5Q737,160 720,160Q703,160 691.5,171.5Q680,183 680,200Q680,217 691.5,228.5Q703,240 720,240ZM240,520Q257,520 268.5,508.5Q280,497 280,480Q280,463 268.5,451.5Q257,440 240,440Q223,440 211.5,451.5Q200,463 200,480Q200,497 211.5,508.5Q223,520 240,520ZM720,800Q737,800 748.5,788.5Q760,777 760,760Q760,743 748.5,731.5Q737,720 720,720Q703,720 691.5,731.5Q680,743 680,760Q680,777 691.5,788.5Q703,800 720,800ZM720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200ZM240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480ZM720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Z" />
android:pathData="M720,880Q670,880 635,845Q600,810 600,760Q600,753 601,745.5Q602,738 604,732L322,568Q305,583 284,591.5Q263,600 240,600Q190,600 155,565Q120,530 120,480Q120,430 155,395Q190,360 240,360Q263,360 284,368.5Q305,377 322,392L604,228Q602,222 601,214.5Q600,207 600,200Q600,150 635,115Q670,80 720,80Q770,80 805,115Q840,150 840,200Q840,250 805,285Q770,320 720,320Q697,320 676,311.5Q655,303 638,288L356,452Q358,458 359,465.5Q360,473 360,480Q360,487 359,494.5Q358,502 356,508L638,672Q655,657 676,648.5Q697,640 720,640Q770,640 805,675Q840,710 840,760Q840,810 805,845Q770,880 720,880ZM720,240Q737,240 748.5,228.5Q760,217 760,200Q760,183 748.5,171.5Q737,160 720,160Q703,160 691.5,171.5Q680,183 680,200Q680,217 691.5,228.5Q703,240 720,240ZM240,520Q257,520 268.5,508.5Q280,497 280,480Q280,463 268.5,451.5Q257,440 240,440Q223,440 211.5,451.5Q200,463 200,480Q200,497 211.5,508.5Q223,520 240,520ZM720,800Q737,800 748.5,788.5Q760,777 760,760Q760,743 748.5,731.5Q737,720 720,720Q703,720 691.5,731.5Q680,743 680,760Q680,777 691.5,788.5Q703,800 720,800ZM720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200ZM240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480ZM720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Z"/>
</vector> </vector>

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M560,800L560,720L664,720L537,593L594,536L720,662L720,560L800,560L800,800L560,800ZM216,800L160,744L664,240L560,240L560,160L800,160L800,400L720,400L720,296L216,800ZM367,423L160,216L216,160L423,367L367,423Z" />
android:pathData="M560,800L560,720L664,720L537,593L594,536L720,662L720,560L800,560L800,800L560,800ZM216,800L160,744L664,240L560,240L560,160L800,160L800,400L720,400L720,296L216,800ZM367,423L160,216L216,160L423,367L367,423Z"/>
</vector> </vector>

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorPrimary"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorPrimary"> <path
<path android:fillColor="@android:color/white"
android:fillColor="@android:color/white" android:pathData="M546,817L546,703L627,703L528,603L607,524L706,623L706,543L820,543L820,817L546,817ZM219,823L140,744L627,257L546,257L546,143L820,143L820,417L706,417L706,336L219,823ZM359,435L140,216L219,137L438,356L359,435Z" />
android:pathData="M546,817L546,703L627,703L528,603L607,524L706,623L706,543L820,543L820,817L546,817ZM219,823L140,744L627,257L546,257L546,143L820,143L820,417L706,417L706,336L219,823ZM359,435L140,216L219,137L438,356L359,435Z"/>
</vector> </vector>

View file

@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval"> android:shape="oval">
<solid <solid android:color="?attr/colorPrimary" />
android:color="?attr/colorPrimary" />
<size <size
android:width="20dp" android:width="20dp"
android:height="20dp" /> android:height="20dp" />

View file

@ -16,7 +16,7 @@
app:title="@string/lbl_playback" app:title="@string/lbl_playback"
tools:subtitle="@string/lbl_all_songs" /> tools:subtitle="@string/lbl_all_songs" />
<org.oxycblt.auxio.image.CoverView <org.oxycblt.auxio.playback.ui.SwipeCoverView
android:id="@+id/playback_cover" android:id="@+id/playback_cover"
style="@style/Widget.Auxio.Image.Full" style="@style/Widget.Auxio.Image.Full"
android:layout_margin="@dimen/spacing_medium" android:layout_margin="@dimen/spacing_medium"

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.oxycblt.auxio.image.CoverView
android:id="@+id/playback_cover"
style="@style/Widget.Auxio.Image.Full"
android:layout_margin="@dimen/spacing_medium"
app:enablePlaybackIndicator="false"
app:enableSelectionBadge="false"
app:layout_constraintBottom_toTopOf="@id/playback_song"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/playback_song"
style="@style/Widget.Auxio.TextView.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium"
app:layout_constraintBottom_toTopOf="@+id/playback_artist"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Song Name" />
<TextView
android:id="@+id/playback_artist"
style="@style/Widget.Auxio.TextView.Secondary.Marquee"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium"
app:layout_constraintBottom_toTopOf="@+id/playback_album"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Artist Name" />
<TextView
android:id="@+id/playback_album"
style="@style/Widget.Auxio.TextView.Secondary.Marquee"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Album Name" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -4,18 +4,19 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingTop="@dimen/spacing_medium"
android:paddingStart="@dimen/spacing_medium" android:paddingStart="@dimen/spacing_medium"
android:paddingTop="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_medium" android:paddingEnd="@dimen/spacing_medium"
android:paddingBottom="@dimen/spacing_mid_medium"> android:paddingBottom="@dimen/spacing_mid_medium">
<org.oxycblt.auxio.image.CoverView <org.oxycblt.auxio.image.CoverView
android:id="@+id/detail_cover" android:id="@+id/detail_cover"
style="@style/Widget.Auxio.Image.Huge" style="@style/Widget.Auxio.Image.Huge"
app:enablePlaybackIndicator="false"
app:enableSelectionBadge="false"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:enablePlaybackIndicator="false"
app:enableSelectionBadge="false"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />
<TextView <TextView

View file

@ -4,18 +4,18 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingTop="@dimen/spacing_medium"
android:paddingStart="@dimen/spacing_medium" android:paddingStart="@dimen/spacing_medium"
android:paddingTop="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_medium" android:paddingEnd="@dimen/spacing_medium"
android:paddingBottom="@dimen/spacing_mid_medium"> android:paddingBottom="@dimen/spacing_mid_medium">
<org.oxycblt.auxio.image.CoverView <org.oxycblt.auxio.image.CoverView
android:id="@+id/detail_cover" android:id="@+id/detail_cover"
style="@style/Widget.Auxio.Image.Large" style="@style/Widget.Auxio.Image.Large"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:enablePlaybackIndicator="false" app:enablePlaybackIndicator="false"
app:enableSelectionBadge="false" app:enableSelectionBadge="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />
<TextView <TextView

View file

@ -16,7 +16,7 @@
app:title="@string/lbl_playback" app:title="@string/lbl_playback"
tools:subtitle="@string/lbl_all_songs" /> tools:subtitle="@string/lbl_all_songs" />
<org.oxycblt.auxio.image.CoverView <org.oxycblt.auxio.playback.ui.SwipeCoverView
android:id="@+id/playback_cover" android:id="@+id/playback_cover"
style="@style/Widget.Auxio.Image.Full" style="@style/Widget.Auxio.Image.Full"
android:layout_margin="@dimen/spacing_medium" android:layout_margin="@dimen/spacing_medium"
@ -64,7 +64,6 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
tools:text="Album Name" /> tools:text="Album Name" />
<org.oxycblt.auxio.playback.ui.StyledSeekBar <org.oxycblt.auxio.playback.ui.StyledSeekBar
android:id="@+id/playback_seek_bar" android:id="@+id/playback_seek_bar"
android:layout_width="match_parent" android:layout_width="match_parent"

View file

@ -10,9 +10,9 @@
<org.oxycblt.auxio.image.CoverView <org.oxycblt.auxio.image.CoverView
android:id="@+id/detail_cover" android:id="@+id/detail_cover"
style="@style/Widget.Auxio.Image.MidHuge" style="@style/Widget.Auxio.Image.MidHuge"
app:layout_constraintDimensionRatio="1"
app:enablePlaybackIndicator="false" app:enablePlaybackIndicator="false"
app:enableSelectionBadge="false" app:enableSelectionBadge="false"
app:layout_constraintDimensionRatio="1"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.oxycblt.auxio.image.CoverView
android:id="@+id/playback_cover"
style="@style/Widget.Auxio.Image.Full"
android:layout_margin="@dimen/spacing_medium"
app:enablePlaybackIndicator="false"
app:enableSelectionBadge="false"
app:layout_constraintBottom_toTopOf="@id/playback_song"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/playback_song"
style="@style/Widget.Auxio.TextView.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium"
app:layout_constraintBottom_toTopOf="@+id/playback_artist"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Song Name" />
<TextView
android:id="@+id/playback_artist"
style="@style/Widget.Auxio.TextView.Secondary.Marquee"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium"
app:layout_constraintBottom_toTopOf="@+id/playback_album"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Artist Name" />
<TextView
android:id="@+id/playback_album"
style="@style/Widget.Auxio.TextView.Secondary.Marquee"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Album Name" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -9,10 +9,10 @@
<org.oxycblt.auxio.image.CoverView <org.oxycblt.auxio.image.CoverView
android:id="@+id/detail_cover" android:id="@+id/detail_cover"
style="@style/Widget.Auxio.Image.Huge" style="@style/Widget.Auxio.Image.Huge"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:enablePlaybackIndicator="false" app:enablePlaybackIndicator="false"
app:enableSelectionBadge="false" app:enableSelectionBadge="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />
<TextView <TextView

View file

@ -12,11 +12,13 @@
android:name="androidx.navigation.fragment.NavHostFragment" android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior" app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior"
app:navGraph="@navigation/inner" app:navGraph="@navigation/inner"
app:defaultNavHost="true"
tools:layout="@layout/fragment_home" /> tools:layout="@layout/fragment_home" />
<View android:id="@+id/main_scrim" android:layout_height="match_parent" android:layout_width="match_parent"/>
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/playback_sheet" android:id="@+id/playback_sheet"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -72,6 +74,8 @@
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<View android:id="@+id/sheet_scrim" android:layout_height="match_parent" android:layout_width="match_parent"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?><!--
<!--
~ Copyright (C) 2015 The Android Open Source Project ~ Copyright (C) 2015 The Android Open Source Project
~ ~
~ Licensed under the Apache License, Version 2.0 (the "License"); ~ Licensed under the Apache License, Version 2.0 (the "License");
@ -14,8 +13,7 @@
~ See the License for the specific language governing permissions and ~ See the License for the specific language governing permissions and
~ limitations under the License. ~ limitations under the License.
--> -->
<FrameLayout <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container" android:id="@+id/container"
@ -23,29 +21,29 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:fitsSystemWindows="true"> android:fitsSystemWindows="true">
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinator" android:id="@+id/coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<View
android:id="@+id/touch_outside"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:focusable="false" android:fitsSystemWindows="true">
android:importantForAccessibility="no"
android:soundEffectsEnabled="false"
tools:ignore="UnusedAttribute"/>
<FrameLayout <View
android:id="@+id/design_bottom_sheet" android:id="@+id/touch_outside"
style="?attr/bottomSheetStyle" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="match_parent"
android:layout_height="wrap_content" android:focusable="false"
android:layout_gravity="center_horizontal|top" android:importantForAccessibility="no"
app:layout_behavior="com.google.android.material.bottomsheet.BackportBottomSheetBehavior"/> android:soundEffectsEnabled="false"
tools:ignore="UnusedAttribute" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> <FrameLayout
android:id="@+id/design_bottom_sheet"
style="?attr/bottomSheetStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|top"
app:layout_behavior="com.google.android.material.bottomsheet.BackportBottomSheetBehavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</FrameLayout> </FrameLayout>

View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android" <TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/deletion_info" android:id="@+id/deletion_info"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="@dimen/spacing_large"
android:paddingTop="@dimen/spacing_medium" android:paddingTop="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_large" android:paddingEnd="@dimen/spacing_large"
android:paddingStart="@dimen/spacing_large"
android:layout_height="match_parent"
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge" android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
xmlns:tools="http://schemas.android.com/tools" tools:text="Delete Playlist 16? This cannot be undone." />
tools:text="Delete Playlist 16? This cannot be undone."/>

View file

@ -1,24 +1,23 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingStart="@dimen/spacing_large"
android:paddingTop="@dimen/spacing_medium" android:paddingTop="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_large" android:paddingEnd="@dimen/spacing_large"
android:paddingStart="@dimen/spacing_large"
tools:context=".MainActivity"> tools:context=".MainActivity">
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:id="@+id/error_container" android:id="@+id/error_container"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
style="@style/Widget.Material3.CardView.Filled"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"> app:layout_constraintTop_toTopOf="parent">
<FrameLayout <FrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@ -37,12 +36,12 @@
android:id="@+id/error_stack_trace" android:id="@+id/error_stack_trace"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingTop="@dimen/spacing_medium"
android:paddingStart="@dimen/spacing_medium"
android:paddingBottom="@dimen/spacing_medium"
android:paddingEnd="@dimen/size_copy_button"
android:breakStrategy="simple" android:breakStrategy="simple"
android:hyphenationFrequency="none" android:hyphenationFrequency="none"
android:paddingStart="@dimen/spacing_medium"
android:paddingTop="@dimen/spacing_medium"
android:paddingEnd="@dimen/size_copy_button"
android:paddingBottom="@dimen/spacing_medium"
android:typeface="monospace" android:typeface="monospace"
tools:text="Stack trace here" /> tools:text="Stack trace here" />
@ -56,10 +55,10 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
app:icon="@drawable/ic_copy_24"
android:layout_margin="@dimen/spacing_small" android:layout_margin="@dimen/spacing_small"
android:src="@drawable/ic_code_24"
app:backgroundTint="?attr/colorPrimaryContainer" app:backgroundTint="?attr/colorPrimaryContainer"
android:src="@drawable/ic_code_24" /> app:icon="@drawable/ic_copy_24" />
</FrameLayout> </FrameLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>

View file

@ -30,8 +30,8 @@
android:layout_marginTop="@dimen/spacing_tiny" android:layout_marginTop="@dimen/spacing_tiny"
android:layout_marginEnd="@dimen/spacing_large" android:layout_marginEnd="@dimen/spacing_large"
android:gravity="center" android:gravity="center"
app:layout_constraintTop_toBottomOf="@+id/dirs_mode_header"
app:checkedButton="@+id/dirs_mode_exclude" app:checkedButton="@+id/dirs_mode_exclude"
app:layout_constraintTop_toBottomOf="@+id/dirs_mode_header"
app:selectionRequired="true" app:selectionRequired="true"
app:singleSelection="true"> app:singleSelection="true">
@ -41,8 +41,8 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
tools:icon="@drawable/ic_check_24" android:text="@string/set_dirs_mode_exclude"
android:text="@string/set_dirs_mode_exclude" /> tools:icon="@drawable/ic_check_24" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton <org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/dirs_mode_include" android:id="@+id/dirs_mode_include"
@ -70,7 +70,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium" android:layout_marginTop="@dimen/spacing_medium"
app:layout_constraintTop_toBottomOf="@+id/dirs_mode_desc"/> app:layout_constraintTop_toBottomOf="@+id/dirs_mode_desc" />
<TextView <TextView
android:id="@+id/dirs_list_header" android:id="@+id/dirs_list_header"
@ -83,15 +83,15 @@
app:layout_constraintTop_toBottomOf="@+id/dirs_list_header_divider" /> app:layout_constraintTop_toBottomOf="@+id/dirs_list_header_divider" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton <org.oxycblt.auxio.ui.RippleFixMaterialButton
style="@style/Widget.Auxio.Button.Icon.Small"
android:id="@+id/dirs_add" android:id="@+id/dirs_add"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:icon="@drawable/ic_add_24"
android:contentDescription="@string/lbl_add"
android:layout_marginEnd="@dimen/spacing_mid_large" android:layout_marginEnd="@dimen/spacing_mid_large"
app:layout_constraintTop_toBottomOf="@+id/dirs_list_header_divider" android:contentDescription="@string/lbl_add"
app:layout_constraintEnd_toEndOf="@+id/dirs_list_header" /> app:icon="@drawable/ic_add_24"
app:layout_constraintEnd_toEndOf="@+id/dirs_list_header"
app:layout_constraintTop_toBottomOf="@+id/dirs_list_header_divider" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/dirs_recycler" android:id="@+id/dirs_recycler"
@ -117,7 +117,7 @@
android:textAlignment="center" android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Auxio.LabelLarge" android:textAppearance="@style/TextAppearance.Auxio.LabelLarge"
android:textColor="?android:attr/textColorSecondary" android:textColor="?android:attr/textColorSecondary"
app:layout_constraintTop_toTopOf="@+id/dirs_recycler"/> app:layout_constraintTop_toTopOf="@+id/dirs_recycler" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/export_paths_header"
style="@style/Widget.Auxio.TextView.Header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/spacing_large"
android:paddingEnd="@dimen/spacing_large"
android:text="@string/lbl_path_style"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/export_paths_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_large"
android:layout_marginTop="@dimen/spacing_tiny"
android:layout_marginEnd="@dimen/spacing_large"
android:gravity="center"
app:checkedButton="@+id/export_relative_paths"
app:layout_constraintTop_toBottomOf="@+id/export_paths_header"
app:selectionRequired="true"
app:singleSelection="true">
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/export_relative_paths"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/lbl_path_style_relative"
tools:icon="@drawable/ic_check_24" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/export_absolute_paths"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/lbl_path_style_absolute" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:orientation="horizontal"
android:paddingTop="@dimen/spacing_tiny"
android:paddingBottom="@dimen/spacing_tiny">
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/export_windows_paths"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_mid_medium"
android:clickable="false"
android:focusable="false"
android:paddingStart="@dimen/spacing_medium"
android:text="@string/lbl_windows_paths"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.Auxio.BodyLarge"
tools:ignore="RtlSymmetry,contentDescription" />
</FrameLayout>
</LinearLayout>

View file

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.textfield.TextInputLayout xmlns:android="http://schemas.android.com/apk/res/android" <com.google.android.material.textfield.TextInputLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/playlist_container" android:id="@+id/playlist_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingStart="@dimen/spacing_mid_large"
android:paddingTop="@dimen/spacing_medium" android:paddingTop="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_mid_large" android:paddingEnd="@dimen/spacing_mid_large"
android:paddingStart="@dimen/spacing_mid_large"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:hintEnabled="false"> app:hintEnabled="false">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText

View file

@ -32,10 +32,10 @@
<org.oxycblt.auxio.list.recycler.DialogRecyclerView <org.oxycblt.auxio.list.recycler.DialogRecyclerView
android:id="@+id/sort_mode_recycler" android:id="@+id/sort_mode_recycler"
style="@style/Widget.Auxio.RecyclerView.Linear"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
style="@style/Widget.Auxio.RecyclerView.Linear"
tools:listitem="@layout/item_sort_mode" /> tools:listitem="@layout/item_sort_mode" />
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
@ -54,9 +54,9 @@
android:id="@+id/sort_direction_group" android:id="@+id/sort_direction_group"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/spacing_medium"
android:layout_marginTop="@dimen/spacing_tiny" android:layout_marginTop="@dimen/spacing_tiny"
android:gravity="center" android:gravity="center"
android:layout_marginHorizontal="@dimen/spacing_medium"
app:layout_constraintTop_toBottomOf="@+id/sort_header" app:layout_constraintTop_toBottomOf="@+id/sort_header"
app:selectionRequired="false" app:selectionRequired="false"
app:singleSelection="true" app:singleSelection="true"
@ -97,9 +97,9 @@
style="@style/Widget.Material3.Button.TextButton.Dialog" style="@style/Widget.Material3.Button.TextButton.Dialog"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_mid_medium"
android:layout_marginEnd="@dimen/spacing_medium" android:layout_marginEnd="@dimen/spacing_medium"
android:layout_marginBottom="@dimen/spacing_mid_medium" android:layout_marginBottom="@dimen/spacing_mid_medium"
android:layout_marginTop="@dimen/spacing_mid_medium"
android:text="@string/lbl_ok" android:text="@string/lbl_ok"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

View file

@ -42,8 +42,8 @@
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:paddingBottom="@dimen/spacing_tiny" android:layout_height="match_parent"
android:layout_height="match_parent"> android:paddingBottom="@dimen/spacing_tiny">
<ImageView <ImageView
android:id="@+id/about_auxio_icon" android:id="@+id/about_auxio_icon"
@ -175,8 +175,8 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingBottom="@dimen/spacing_tiny" android:orientation="vertical"
android:orientation="vertical"> android:paddingBottom="@dimen/spacing_tiny">
<TextView <TextView
android:id="@+id/about_library_counts" android:id="@+id/about_library_counts"

View file

@ -33,8 +33,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
app:navigationIcon="@drawable/ic_close_24" app:menu="@menu/toolbar_selection"
app:menu="@menu/toolbar_selection" /> app:navigationIcon="@drawable/ic_close_24" />
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/detail_edit_toolbar" android:id="@+id/detail_edit_toolbar"
@ -42,8 +42,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
app:navigationIcon="@drawable/ic_close_24" app:menu="@menu/toolbar_edit"
app:menu="@menu/toolbar_edit" /> app:navigationIcon="@drawable/ic_close_24" />
</org.oxycblt.auxio.ui.MultiToolbar> </org.oxycblt.auxio.ui.MultiToolbar>

View file

@ -8,6 +8,7 @@
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:transitionGroup="true"> android:transitionGroup="true">
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout <org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/home_appbar" android:id="@+id/home_appbar"
style="@style/Widget.Auxio.AppBarLayout"> style="@style/Widget.Auxio.AppBarLayout">
@ -31,8 +32,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
app:navigationIcon="@drawable/ic_close_24" app:menu="@menu/toolbar_selection"
app:menu="@menu/toolbar_selection" /> app:navigationIcon="@drawable/ic_close_24" />
</org.oxycblt.auxio.ui.MultiToolbar> </org.oxycblt.auxio.ui.MultiToolbar>
@ -70,8 +71,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_margin="@dimen/spacing_medium" android:layout_margin="@dimen/spacing_medium"
android:visibility="invisible" android:fitsSystemWindows="true"
android:fitsSystemWindows="true"> android:visibility="invisible">
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
@ -110,10 +111,10 @@
android:id="@+id/home_indexing_actions" android:id="@+id/home_indexing_actions"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginStart="@dimen/spacing_medium" android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium" android:layout_marginEnd="@dimen/spacing_medium"
android:layout_marginBottom="@dimen/spacing_medium" android:layout_marginBottom="@dimen/spacing_medium"
android:orientation="horizontal"
android:visibility="invisible" android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
tools:layout_editor_absoluteX="16dp"> tools:layout_editor_absoluteX="16dp">
@ -147,17 +148,33 @@
</FrameLayout> </FrameLayout>
<org.oxycblt.auxio.home.EdgeFrameLayout <org.oxycblt.auxio.home.EdgeFrameLayout
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
app:layout_anchor="@id/home_content" android:layout_gravity="bottom|end"
app:layout_anchorGravity="bottom|end"> android:clipChildren="false"
android:clipToPadding="false"
app:layout_anchor="@id/home_content">
<org.oxycblt.auxio.home.FlipFloatingActionButton
android:id="@+id/home_fab" <org.oxycblt.auxio.home.ThemedSpeedDialView
style="@style/Widget.Auxio.FloatingActionButton.Adaptive" android:id="@+id/home_new_playlist_fab"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_medium" /> android:layout_gravity="bottom|end"
android:clickable="true"
android:focusable="true"
android:gravity="bottom|end"
app:sdMainFabAnimationRotateAngle="135"
app:sdMainFabClosedIconColor="@android:color/white"
app:sdMainFabClosedSrc="@drawable/ic_add_24"/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/home_shuffle_fab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/spacing_medium"
android:src="@drawable/ic_shuffle_off_24" />
</org.oxycblt.auxio.home.EdgeFrameLayout> </org.oxycblt.auxio.home.EdgeFrameLayout>

View file

@ -13,11 +13,13 @@
android:name="androidx.navigation.fragment.NavHostFragment" android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior" app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior"
app:navGraph="@navigation/inner" app:navGraph="@navigation/inner"
app:defaultNavHost="true"
tools:layout="@layout/fragment_home" /> tools:layout="@layout/fragment_home" />
<View android:id="@+id/main_scrim" android:layout_height="match_parent" android:layout_width="match_parent"/>
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/playback_sheet" android:id="@+id/playback_sheet"
style="@style/Widget.Auxio.DisableDropShadows" style="@style/Widget.Auxio.DisableDropShadows"
@ -48,9 +50,9 @@
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/queue_handle_wrapper" android:id="@+id/queue_handle_wrapper"
android:contentDescription="@string/desc_queue_bar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="@dimen/size_bottom_sheet_bar"> android:layout_height="@dimen/size_bottom_sheet_bar"
android:contentDescription="@string/desc_queue_bar">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView <com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/queue_handle" android:id="@+id/queue_handle"
@ -81,6 +83,8 @@
</LinearLayout> </LinearLayout>
<View android:id="@+id/sheet_scrim" android:layout_height="match_parent" android:layout_width="match_parent"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -16,7 +16,8 @@
app:title="@string/lbl_playback" app:title="@string/lbl_playback"
tools:subtitle="@string/lbl_all_songs" /> tools:subtitle="@string/lbl_all_songs" />
<org.oxycblt.auxio.image.CoverView
<org.oxycblt.auxio.playback.ui.SwipeCoverView
android:id="@+id/playback_cover" android:id="@+id/playback_cover"
style="@style/Widget.Auxio.Image.Full" style="@style/Widget.Auxio.Image.Full"
android:layout_marginStart="@dimen/spacing_medium" android:layout_marginStart="@dimen/spacing_medium"
@ -29,8 +30,6 @@
app:layout_constraintVertical_chainStyle="packed" /> app:layout_constraintVertical_chainStyle="packed" />
<!-- Playback information is wrapped in a container so that marquee doesn't break -->
<LinearLayout <LinearLayout
android:id="@+id/playback_info_container" android:id="@+id/playback_info_container"
android:layout_width="0dp" android:layout_width="0dp"

View file

@ -55,8 +55,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
app:navigationIcon="@drawable/ic_close_24" app:menu="@menu/toolbar_selection"
app:menu="@menu/toolbar_selection" /> app:navigationIcon="@drawable/ic_close_24" />
</org.oxycblt.auxio.ui.MultiToolbar> </org.oxycblt.auxio.ui.MultiToolbar>

View file

@ -26,9 +26,9 @@
android:id="@+id/song_track_placeholder" android:id="@+id/song_track_placeholder"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:src="@drawable/ic_song_24"
android:scaleType="center"
android:contentDescription="@string/def_track" android:contentDescription="@string/def_track"
android:scaleType="center"
android:src="@drawable/ic_song_24"
android:visibility="invisible" android:visibility="invisible"
app:tint="@color/sel_on_cover_bg" app:tint="@color/sel_on_cover_bg"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />

View file

@ -5,16 +5,16 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingTop="@dimen/spacing_medium"
android:paddingStart="@dimen/spacing_medium" android:paddingStart="@dimen/spacing_medium"
android:paddingTop="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_medium" android:paddingEnd="@dimen/spacing_medium"
android:paddingBottom="@dimen/spacing_mid_medium"> android:paddingBottom="@dimen/spacing_mid_medium">
<org.oxycblt.auxio.image.CoverView <org.oxycblt.auxio.image.CoverView
android:id="@+id/detail_cover" android:id="@+id/detail_cover"
style="@style/Widget.Auxio.Image.MidHuge"
app:enablePlaybackIndicator="false" app:enablePlaybackIndicator="false"
app:enableSelectionBadge="false" app:enableSelectionBadge="false"
style="@style/Widget.Auxio.Image.MidHuge"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"

View file

@ -25,8 +25,8 @@
android:id="@+id/disc_icon" android:id="@+id/disc_icon"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:src="@drawable/ic_album_24"
android:scaleType="center" android:scaleType="center"
android:src="@drawable/ic_album_24"
app:tint="@color/sel_on_cover_bg" app:tint="@color/sel_on_cover_bg"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />
@ -54,9 +54,9 @@
android:layout_marginEnd="@dimen/spacing_mid_medium" android:layout_marginEnd="@dimen/spacing_mid_medium"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
tools:visibility="gone"
app:layout_constraintStart_toEndOf="@+id/disc_cover" app:layout_constraintStart_toEndOf="@+id/disc_cover"
app:layout_constraintTop_toBottomOf="@+id/disc_number" app:layout_constraintTop_toBottomOf="@+id/disc_number"
tools:text="Part 1" /> tools:text="Part 1"
tools:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -3,17 +3,17 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:orientation="horizontal" android:orientation="horizontal">
android:layout_height="wrap_content">
<TextView <TextView
android:id="@+id/header_title" android:id="@+id/header_title"
style="@style/Widget.Auxio.TextView.Header" style="@style/Widget.Auxio.TextView.Header"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium" android:layout_marginEnd="@dimen/spacing_mid_medium"
android:layout_weight="1"
app:layout_constraintEnd_toStartOf="@+id/header_button" app:layout_constraintEnd_toStartOf="@+id/header_button"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
tools:text="Songs" /> tools:text="Songs" />

Some files were not shown because too many files have changed in this diff Show more