From 56a4102023a17d9a2f588aa10068f593b906ac57 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 30 Jun 2023 12:36:14 +0300 Subject: [PATCH 01/72] Switch tracks by swipe on cover --- .../java/org/oxycblt/auxio/image/CoverView.kt | 2 +- .../auxio/playback/PlaybackPanelFragment.kt | 14 ++- .../auxio/playback/ui/SwipeCoverView.kt | 107 ++++++++++++++++++ .../layout-h480dp/fragment_playback_panel.xml | 2 +- .../fragment_playback_panel.xml | 2 +- .../res/layout/fragment_playback_panel.xml | 2 +- 6 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/ui/SwipeCoverView.kt diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt index 1ec38068e..9180c0a35 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -73,7 +73,7 @@ import org.oxycblt.auxio.util.getInteger * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class CoverView +open class CoverView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index 02b59c01e..b2026b7d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -39,6 +39,7 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.ui.StyledSeekBar +import org.oxycblt.auxio.playback.ui.SwipeCoverView import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD @@ -58,7 +59,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat class PlaybackPanelFragment : ViewBindingFragment(), Toolbar.OnMenuItemClickListener, - StyledSeekBar.Listener { + StyledSeekBar.Listener, + SwipeCoverView.OnSwipeListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() @@ -111,7 +113,7 @@ class PlaybackPanelFragment : isSelected = true setOnClickListener { navigateToCurrentAlbum() } } - + binding.playbackCover.onSwipeListener = this binding.playbackSeekBar.listener = this // Set up actions @@ -191,6 +193,14 @@ class PlaybackPanelFragment : playbackModel.seekTo(positionDs) } + override fun onSwipePrevious() { + playbackModel.prev() + } + + override fun onSwipeNext() { + playbackModel.next() + } + private fun updateSong(song: Song?) { if (song == null) { // Nothing to do. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/SwipeCoverView.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/SwipeCoverView.kt new file mode 100644 index 000000000..dc0fcf9b0 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/SwipeCoverView.kt @@ -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 . + */ + +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() + } +} diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index f2919eacc..665a7d04b 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -16,7 +16,7 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - - - Date: Sun, 12 Nov 2023 11:05:14 -0700 Subject: [PATCH 02/72] Revert "playback: temp revert swipe to next behavior" This reverts commit df7ec27d1cd9e89142ccb92e4417b7a7d32196a3. --- .../auxio/playback/PlaybackPanelFragment.kt | 95 ++++++++++---- .../auxio/playback/ui/PlaybackPagerAdapter.kt | 124 ++++++++++++++++++ .../layout-h480dp/fragment_playback_panel.xml | 52 +------- .../res/layout-h480dp/item_playback_song.xml | 56 ++++++++ .../fragment_playback_panel.xml | 50 +------ .../res/layout-sw600dp/item_playback_song.xml | 56 ++++++++ .../res/layout/fragment_playback_panel.xml | 56 +------- .../main/res/layout/item_playback_song.xml | 55 ++++++++ 8 files changed, 378 insertions(+), 166 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt create mode 100644 app/src/main/res/layout-h480dp/item_playback_song.xml create mode 100644 app/src/main/res/layout-sw600dp/item_playback_song.xml create mode 100644 app/src/main/res/layout/item_playback_song.xml diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index b7228d508..53cbbe7fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -29,18 +29,26 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.Toolbar import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import dagger.hilt.android.AndroidEntryPoint +import java.lang.reflect.Field +import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.playback.queue.QueueViewModel 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.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.showToast @@ -58,11 +66,14 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat class PlaybackPanelFragment : ViewBindingFragment(), Toolbar.OnMenuItemClickListener, - StyledSeekBar.Listener { + StyledSeekBar.Listener, + PlaybackPagerAdapter.Listener { private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() + private val queueModel: QueueViewModel by activityViewModels() private val listModel: ListViewModel by activityViewModels() private var equalizerLauncher: ActivityResultLauncher? = null + private var coverAdapter: PlaybackPagerAdapter? = null override fun onCreateBinding(inflater: LayoutInflater) = FragmentPlaybackPanelBinding.inflate(inflater) @@ -99,19 +110,13 @@ class PlaybackPanelFragment : } } - // Set up marquee on song information, alongside click handlers that navigate to each - // respective item. - binding.playbackSong.apply { - isSelected = true - setOnClickListener { playbackModel.song.value?.let(detailModel::showAlbum) } - } - binding.playbackArtist.apply { - isSelected = true - setOnClickListener { navigateToCurrentArtist() } - } - binding.playbackAlbum.apply { - isSelected = true - setOnClickListener { navigateToCurrentAlbum() } + // cover carousel adapter + coverAdapter = PlaybackPagerAdapter(this) + binding.playbackCoverPager.apply { + adapter = coverAdapter + registerOnPageChangeCallback(OnCoverChangedCallback(queueModel)) + val recycler = VP_RECYCLER_FIELD.get(this@apply) as RecyclerView + recycler.isNestedScrollingEnabled = false } binding.playbackSeekBar.listener = this @@ -131,15 +136,14 @@ class PlaybackPanelFragment : collectImmediately(playbackModel.repeatMode, ::updateRepeat) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playbackModel.isShuffled, ::updateShuffled) + collectImmediately(queueModel.queue, ::updateQueue) + collectImmediately(queueModel.index, ::updateQueuePosition) } override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { equalizerLauncher = null + coverAdapter = 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 { @@ -170,6 +174,18 @@ class PlaybackPanelFragment : playbackModel.seekTo(positionDs) } + private fun updateQueue(queue: List) { + coverAdapter?.update(queue, UpdateInstructions.Replace(0)) + } + + private fun updateQueuePosition(position: Int) { + val pager = requireBinding().playbackCoverPager + val distance = abs(pager.currentItem - position) + if (distance != 0) { + pager.setCurrentItem(position, distance == 1) + } + } + private fun updateSong(song: Song?) { if (song == null) { // Nothing to do. @@ -177,12 +193,7 @@ class PlaybackPanelFragment : } val binding = requireBinding() - val context = requireContext() logD("Updating song display: $song") - binding.playbackCover.bind(song) - binding.playbackSong.text = song.name.resolve(context) - binding.playbackArtist.text = song.artists.resolveNames(context) - binding.playbackAlbum.text = song.album.name.resolve(context) binding.playbackSeekBar.durationDs = song.durationMs.msToDs() } @@ -212,11 +223,43 @@ class PlaybackPanelFragment : requireBinding().playbackShuffle.isActivated = isShuffled } - private fun navigateToCurrentArtist() { + override fun navigateToCurrentSong() { + playbackModel.song.value?.let(detailModel::showAlbum) + } + + override fun navigateToCurrentArtist() { playbackModel.song.value?.let(detailModel::showArtist) } - private fun navigateToCurrentAlbum() { + override fun navigateToCurrentAlbum() { playbackModel.song.value?.let { detailModel.showAlbum(it.album) } } + + override fun navigateToMenu() { + // TODO + } + + private class OnCoverChangedCallback(private val queueViewModel: QueueViewModel) : + OnPageChangeCallback() { + + private var targetPosition = RecyclerView.NO_POSITION + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + targetPosition = position + } + + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_IDLE && + targetPosition != RecyclerView.NO_POSITION && + targetPosition != queueViewModel.index.value) { + queueViewModel.goto(targetPosition) + } + } + } + + private companion object { + val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt new file mode 100644 index 000000000..d0f176231 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt @@ -0,0 +1,124 @@ +/* + * 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 . + */ + +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(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) + setOnClickListener { listener.navigateToCurrentSong() } + } + 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() { + override fun areItemsTheSame(oldItem: Song, newItem: Song) = + oldItem.uid == newItem.uid + + override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + } + } +} diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index e68de3423..28f69b2b2 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -16,54 +16,14 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - - - - - - - - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout-h480dp/item_playback_song.xml b/app/src/main/res/layout-h480dp/item_playback_song.xml new file mode 100644 index 000000000..9ce0bcf47 --- /dev/null +++ b/app/src/main/res/layout-h480dp/item_playback_song.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml index eecb65e6e..b7ead10f6 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -16,54 +16,14 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - - - - - - - - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout-sw600dp/item_playback_song.xml b/app/src/main/res/layout-sw600dp/item_playback_song.xml new file mode 100644 index 000000000..9ce0bcf47 --- /dev/null +++ b/app/src/main/res/layout-sw600dp/item_playback_song.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_playback_panel.xml b/app/src/main/res/layout/fragment_playback_panel.xml index 857cb7545..e873722e1 100644 --- a/app/src/main/res/layout/fragment_playback_panel.xml +++ b/app/src/main/res/layout/fragment_playback_panel.xml @@ -16,64 +16,22 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - - - - - - - - - - - - - - @@ -159,4 +117,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_playback_song.xml b/app/src/main/res/layout/item_playback_song.xml new file mode 100644 index 000000000..3e8c0c6a1 --- /dev/null +++ b/app/src/main/res/layout/item_playback_song.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file From b4b830fbf426710eadb4ad149dacddc597a32a3d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:05:40 -0700 Subject: [PATCH 03/72] build: separate 3.2.1 changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ecfb63b5..a8cc477c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ #### What's New - Added ability to rewind/skip tracks by swiping back/forward +## 3.2.1 + #### What's Improved - Added support for native M4A multi-value tags based on duplicate atoms From bf3c30e8afaf50a60daa37f0f11af71c6d7b1e86 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:11:50 -0700 Subject: [PATCH 04/72] music: add demo release type This is part of the MusicBrainz spec, but I didn't think of implementing it. Turns out it's stupidly common among music releases, so may as well. Resolves #590. --- CHANGELOG.md | 1 + .../org/oxycblt/auxio/detail/DetailViewModel.kt | 2 ++ .../org/oxycblt/auxio/music/info/ReleaseType.kt | 13 +++++++++++++ app/src/main/res/values/strings.xml | 4 ++++ .../org/oxycblt/auxio/music/info/ReleaseTypeTest.kt | 1 + 5 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8cc477c1..5173006d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ #### What's New - Added ability to rewind/skip tracks by swiping back/forward +- Added support for demo release type ## 3.2.1 diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index 648424458..3613d96c6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -607,6 +607,7 @@ constructor( is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS is ReleaseType.Mix -> AlbumGrouping.DJMIXES is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES + is ReleaseType.Demo -> AlbumGrouping.DEMOS } } } @@ -709,6 +710,7 @@ constructor( SOUNDTRACKS(R.string.lbl_soundtracks), DJMIXES(R.string.lbl_mixes), MIXTAPES(R.string.lbl_mixtapes), + DEMOS(R.string.lbl_demos), APPEARANCES(R.string.lbl_appears_on), LIVE(R.string.lbl_live_group), REMIXES(R.string.lbl_remix_group), diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt index 24260912b..3fe45b202 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt @@ -143,6 +143,18 @@ sealed interface ReleaseType { 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. */ enum class Refinement { /** A release consisting of a live performance */ @@ -220,6 +232,7 @@ sealed interface ReleaseType { type.equals("dj-mix", true) -> Mix type.equals("live", true) -> convertRefinement(Refinement.LIVE) type.equals("remix", true) -> convertRefinement(Refinement.REMIX) + type.equals("demo", true) -> Demo else -> convertRefinement(null) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31700900b..5f8eed478 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,6 +63,10 @@ Mixtapes Mixtape + + Demo + + Demos DJ Mixes diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt index 9ca019a40..1294e3daf 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt @@ -36,6 +36,7 @@ class ReleaseTypeTest { assertEquals(ReleaseType.Soundtrack, ReleaseType.parse(listOf("album", "soundtrack"))) assertEquals(ReleaseType.Mix, ReleaseType.parse(listOf("album", "dj-mix"))) assertEquals(ReleaseType.Mixtape, ReleaseType.parse(listOf("album", "mixtape/street"))) + assertEquals(ReleaseType.Demo, ReleaseType.parse(listOf("album", "demo"))) } @Test From d6801354cea772c354ccb0a020fefea466650fcb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:24:06 -0700 Subject: [PATCH 05/72] music: only include explicit albums in count Only include an artists explicit albums (ones directly linked w/album artist) in their count. This is arguably more appropriate than the prior behavior, given Auxio's collaborator/artist distinction. Resolves #581. --- CHANGELOG.md | 4 ++++ .../detail/header/ArtistDetailHeaderAdapter.kt | 6 +++++- .../auxio/list/menu/MenuDialogFragmentImpl.kt | 6 +++++- .../oxycblt/auxio/list/recycler/ViewHolders.kt | 8 ++++++-- .../oxycblt/auxio/music/device/DeviceMusicImpl.kt | 15 ++++++++++++--- app/src/main/res/values/strings.xml | 1 + 6 files changed, 33 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5173006d5..b4cac8eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Added ability to rewind/skip tracks by swiping back/forward - Added support for demo release type +#### What's Changed +- Albums linked to an artist only as a collaborator are no longer included +in an artist's album count + ## 3.2.1 #### What's Improved diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt index cb2343219..e85c892e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt @@ -71,7 +71,11 @@ private constructor(private val binding: ItemDetailHeaderBinding) : binding.detailInfo.text = binding.context.getString( 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()) { binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size) } else { diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt index a7eef2392..a7fadce1f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt @@ -178,7 +178,11 @@ class ArtistMenuDialogFragment : MenuDialogFragment() { binding.menuInfo.text = getString( 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()) { context.getPlural(R.plurals.fmt_song_count, menu.artist.songs.size) } else { diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index c829248e5..36565b63f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -164,7 +164,11 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin binding.parentInfo.text = binding.context.getString( 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()) { binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size) } else { @@ -199,7 +203,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = oldItem.name == newItem.name && - oldItem.albums.size == newItem.albums.size && + oldItem.explicitAlbums.size == newItem.explicitAlbums.size && oldItem.songs.size == newItem.songs.size } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 91a4e1702..bc5418677 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -432,7 +432,7 @@ class ArtistImpl( ?: Name.Unknown(R.string.def_artist) override val songs: Set - override val albums: Set + override val albums: Set = emptySet() override val explicitAlbums: Set override val implicitAlbums: Set override val durationMs: Long? @@ -463,7 +463,7 @@ class ArtistImpl( } songs = distinctSongs - albums = albumMap.keys + val albums = albumMap.keys explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true } implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true } durationMs = songs.sumOf { it.durationMs }.positiveOrNull() @@ -506,7 +506,16 @@ class ArtistImpl( * @return This instance upcasted to [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 = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) .genres(songs.flatMapTo(mutableSetOf()) { it.genres }) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f8eed478..c7866d184 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -357,6 +357,7 @@ No disc No track No songs + No albums No music playing From 0ad7a8955ae1dc3ed5f46b440d89cb27121f8e59 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:32:51 -0700 Subject: [PATCH 06/72] music: eliminate all reference to artists albums It's no longer used in any capacity. --- app/src/main/java/org/oxycblt/auxio/music/Music.kt | 6 ------ .../java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt | 1 - 2 files changed, 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 766ea462c..7d6fae73e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -317,12 +317,6 @@ interface Album : MusicParent { * @author Alexander Capehart (OxygenCobalt) */ 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 /** Albums directly credited to this [Artist] via a "Album Artist" tag. */ val explicitAlbums: Collection /** Albums indirectly credited to this [Artist] via an "Artist" tag. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index bc5418677..e3e2232ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -432,7 +432,6 @@ class ArtistImpl( ?: Name.Unknown(R.string.def_artist) override val songs: Set - override val albums: Set = emptySet() override val explicitAlbums: Set override val implicitAlbums: Set override val durationMs: Long? From 9ae6b20fd15ca1193dfd598202937cb22dd9e7a0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:54:37 -0700 Subject: [PATCH 07/72] music: decouple settings somewhat Try to decouple the stateful music settings object from the stateless internals of the music loader. This should make unit testing far easier. --- .../oxycblt/auxio/music/MusicRepository.kt | 25 +++- .../auxio/music/device/DeviceLibrary.kt | 13 +- .../org/oxycblt/auxio/music/fs/FsModule.kt | 5 +- .../auxio/music/fs/MediaStoreExtractor.kt | 56 ++++---- .../java/org/oxycblt/auxio/music/info/Name.kt | 39 ++---- .../auxio/music/metadata/Separators.kt | 14 +- .../oxycblt/auxio/music/user/UserLibrary.kt | 22 ++- .../org/oxycblt/auxio/music/info/NameTest.kt | 126 ++++++++---------- 8 files changed, 133 insertions(+), 167 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index d5263da7b..55cfeaf0a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -36,6 +36,8 @@ import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.device.RawSong 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.user.MutableUserLibrary import org.oxycblt.auxio.music.user.UserLibrary @@ -223,7 +225,8 @@ constructor( private val mediaStoreExtractor: MediaStoreExtractor, private val tagExtractor: TagExtractor, private val deviceLibraryFactory: DeviceLibrary.Factory, - private val userLibraryFactory: UserLibrary.Factory + private val userLibraryFactory: UserLibrary.Factory, + private val musicSettings: MusicSettings ) : MusicRepository { private val updateListeners = mutableListOf() private val indexingListeners = mutableListOf() @@ -356,6 +359,8 @@ constructor( } private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) { + // TODO: Find a way to break up this monster of a method, preferably as another class. + val start = System.currentTimeMillis() // 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. @@ -365,6 +370,17 @@ constructor( 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 // 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 @@ -376,7 +392,7 @@ constructor( worker.scope.async { val query = try { - mediaStoreExtractor.query() + mediaStoreExtractor.query(constraints) } catch (e: Exception) { // 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 @@ -445,7 +461,8 @@ constructor( worker.scope.async(Dispatchers.Default) { val deviceLibrary = try { - deviceLibraryFactory.create(completeSongs, processedSongs) + deviceLibraryFactory.create( + completeSongs, processedSongs, separators, nameFactory) } catch (e: Exception) { processedSongs.close(e) return@async Result.failure(e) @@ -518,7 +535,7 @@ constructor( logD("Awaiting DeviceLibrary creation") val deviceLibrary = deviceLibraryJob.await().getOrThrow() logD("Starting UserLibrary creation") - val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary) + val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory) // Loading process is functionally done, indicate such logD( diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 527dcd198..c694a65ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -28,7 +28,6 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.useQuery @@ -110,19 +109,19 @@ interface DeviceLibrary { suspend fun create( rawSongs: Channel, processedSongs: Channel, + separators: Separators, + nameFactory: Name.Known.Factory ): DeviceLibraryImpl } } -class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) : - DeviceLibrary.Factory { +class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory { override suspend fun create( rawSongs: Channel, - processedSongs: Channel + processedSongs: Channel, + separators: Separators, + nameFactory: Name.Known.Factory ): DeviceLibraryImpl { - val nameFactory = Name.Known.Factory.from(musicSettings) - val separators = Separators.from(musicSettings) - val songGrouping = mutableMapOf() val albumGrouping = mutableMapOf>() val artistGrouping = mutableMapOf>() diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index 10c4192bc..828a468da 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -24,12 +24,11 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import org.oxycblt.auxio.music.MusicSettings @Module @InstallIn(SingletonComponent::class) class FsModule { @Provides - fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) = - MediaStoreExtractor.from(context, musicSettings) + fun mediaStoreExtractor(@ApplicationContext context: Context) = + MediaStoreExtractor.from(context) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 76e62e897..392103d80 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -29,7 +29,6 @@ import androidx.core.database.getStringOrNull import java.io.File import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.cache.Cache import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.info.Date @@ -50,9 +49,11 @@ interface MediaStoreExtractor { /** * 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. */ - suspend fun query(): Query + suspend fun query(constraints: Constraints): Query /** * Consume the [Cursor] loaded after [query]. @@ -84,46 +85,44 @@ interface MediaStoreExtractor { fun populateTags(rawSong: RawSong) } + data class Constraints(val excludeNonMusic: Boolean, val musicDirs: MusicDirectories) + companion object { /** * Create a framework-backed instance. * * @param context [Context] required. - * @param musicSettings [MusicSettings] required. * @return A new [MediaStoreExtractor] that will work best on the device's API level. */ - fun from(context: Context, musicSettings: MusicSettings): MediaStoreExtractor = + fun from(context: Context): MediaStoreExtractor = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> - Api30MediaStoreExtractor(context, musicSettings) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> - Api29MediaStoreExtractor(context, musicSettings) - else -> Api21MediaStoreExtractor(context, musicSettings) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreExtractor(context) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreExtractor(context) + else -> Api21MediaStoreExtractor(context) } } } -private abstract class BaseMediaStoreExtractor( - protected val context: Context, - private val musicSettings: MusicSettings -) : MediaStoreExtractor { - final override suspend fun query(): MediaStoreExtractor.Query { +private abstract class BaseMediaStoreExtractor(protected val context: Context) : + MediaStoreExtractor { + final override suspend fun query( + constraints: MediaStoreExtractor.Constraints + ): MediaStoreExtractor.Query { val start = System.currentTimeMillis() val args = mutableListOf() var selector = BASE_SELECTOR // Filter out audio that is not music, if enabled. - if (musicSettings.excludeNonMusic) { + if (constraints.excludeNonMusic) { logD("Excluding non-music") selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1" } // Set up the projection to follow the music directory configuration. - val dirs = musicSettings.musicDirs - if (dirs.dirs.isNotEmpty()) { + if (constraints.musicDirs.dirs.isNotEmpty()) { selector += " AND " - if (!dirs.shouldInclude) { + if (!constraints.musicDirs.shouldInclude) { logD("Excluding directories in selector") // Without a NOT, the query will be restricted to the specified paths, resulting // in the "Include" mode. With a NOT, the specified paths will not be included, @@ -134,10 +133,10 @@ private abstract class BaseMediaStoreExtractor( // 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)) { + for (i in constraints.musicDirs.dirs.indices) { + if (addDirToSelector(constraints.musicDirs.dirs[i], args)) { selector += - if (i < dirs.dirs.lastIndex) { + if (i < constraints.musicDirs.dirs.lastIndex) { "$dirSelectorTemplate OR " } else { dirSelectorTemplate @@ -362,8 +361,7 @@ private abstract class BaseMediaStoreExtractor( // 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. -private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : - BaseMediaStoreExtractor(context, musicSettings) { +private class Api21MediaStoreExtractor(context: Context) : BaseMediaStoreExtractor(context) { override val projection: Array get() = super.projection + @@ -447,10 +445,8 @@ private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSet * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) -private abstract class BaseApi29MediaStoreExtractor( - context: Context, - musicSettings: MusicSettings -) : BaseMediaStoreExtractor(context, musicSettings) { +private abstract class BaseApi29MediaStoreExtractor(context: Context) : + BaseMediaStoreExtractor(context) { override val projection: Array get() = super.projection + @@ -512,8 +508,7 @@ private abstract class BaseApi29MediaStoreExtractor( * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) -private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : - BaseApi29MediaStoreExtractor(context, musicSettings) { +private class Api29MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) { override val projection: Array get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) @@ -553,8 +548,7 @@ private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSet * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.R) -private class Api30MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : - BaseApi29MediaStoreExtractor(context, musicSettings) { +private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) { override val projection: Array get() = super.projection + diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index 09f4d8035..30626f01e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -23,12 +23,11 @@ import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import java.text.CollationKey import java.text.Collator -import org.oxycblt.auxio.music.MusicSettings /** * The name of a music item. * - * This class automatically implements + * This class automatically implements advanced sorting heuristics for music naming, * * @author Alexander Capehart */ @@ -80,7 +79,7 @@ sealed interface Name : Comparable { is Unknown -> 1 } - interface Factory { + sealed interface Factory { /** * Create a new instance of [Name.Known] * @@ -88,22 +87,16 @@ sealed interface Name : Comparable { * @param sort The raw sort name obtained from the music item */ fun parse(raw: String, sort: String?): Known + } - companion object { - /** - * Creates a new instance from the **current state** of the given [MusicSettings]'s - * user-defined name configuration. - * - * @param settings The [MusicSettings] to use. - * @return A [Factory] instance reflecting the configuration state. - */ - fun from(settings: MusicSettings) = - if (settings.intelligentSorting) { - IntelligentKnownName.Factory - } else { - SimpleKnownName.Factory - } - } + /** Produces a simple [Known] with basic sorting heuristics that are locale-independent. */ + data object SimpleFactory : Factory { + override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort) + } + + /** Produces an intelligent [Known] with advanced, but more fragile heuristics. */ + data object IntelligentFactory : Factory { + override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort) } } @@ -137,7 +130,6 @@ private val punctRegex by lazy { Regex("[\\p{Punct}+]") } * * @author Alexander Capehart (OxygenCobalt) */ -@VisibleForTesting data class SimpleKnownName(override val raw: String, override val sort: String?) : Name.Known() { 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 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) */ -@VisibleForTesting data class IntelligentKnownName(override val raw: String, override val sort: String?) : Name.Known() { 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 { private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt index 8d2740e74..678e1ef2f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt @@ -18,9 +18,6 @@ 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 * that may be delimited with a separator character. @@ -45,15 +42,12 @@ interface Separators { const val AND = '&' /** - * Creates a new instance from the **current state** of the given [MusicSettings]'s - * user-defined separator configuration. + * Creates a new instance from a string of separator characters to use. * - * @param settings The [MusicSettings] to use. - * @return A new [Separators] instance reflecting the configuration state. + * @param chars The separator characters to use. Each character in the string will be + * 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) = if (chars.isNotEmpty()) { CharSeparators(chars.toSet()) diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index faae9594b..70943b7d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -22,7 +22,6 @@ import java.lang.Exception import javax.inject.Inject import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary @@ -82,7 +81,8 @@ interface UserLibrary { */ suspend fun create( rawPlaylists: List, - deviceLibrary: DeviceLibrary + deviceLibrary: DeviceLibrary, + nameFactory: Name.Known.Factory ): MutableUserLibrary } } @@ -139,9 +139,7 @@ interface MutableUserLibrary : UserLibrary { suspend fun rewritePlaylist(playlist: Playlist, songs: List): Boolean } -class UserLibraryFactoryImpl -@Inject -constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : +class UserLibraryFactoryImpl @Inject constructor(private val playlistDao: PlaylistDao) : UserLibrary.Factory { override suspend fun query() = try { @@ -155,22 +153,22 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus override suspend fun create( rawPlaylists: List, - deviceLibrary: DeviceLibrary + deviceLibrary: DeviceLibrary, + nameFactory: Name.Known.Factory ): MutableUserLibrary { - val nameFactory = Name.Known.Factory.from(musicSettings) val playlistMap = mutableMapOf() for (rawPlaylist in rawPlaylists) { val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, nameFactory) playlistMap[playlistImpl.uid] = playlistImpl } - return UserLibraryImpl(playlistDao, playlistMap, musicSettings) + return UserLibraryImpl(playlistDao, playlistMap, nameFactory) } } private class UserLibraryImpl( private val playlistDao: PlaylistDao, private val playlistMap: MutableMap, - private val musicSettings: MusicSettings + private val nameFactory: Name.Known.Factory ) : MutableUserLibrary { 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 suspend fun createPlaylist(name: String, songs: List): 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 } val rawPlaylist = RawPlaylist( @@ -209,9 +207,7 @@ private class UserLibraryImpl( val playlistImpl = synchronized(this) { requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } - .also { - playlistMap[it.uid] = it.edit(name, Name.Known.Factory.from(musicSettings)) - } + .also { playlistMap[it.uid] = it.edit(name, nameFactory) } } return try { diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt index fd80d51c4..078a1f154 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -18,30 +18,14 @@ package org.oxycblt.auxio.music.info -import io.mockk.every -import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertTrue import org.junit.Test -import org.oxycblt.auxio.music.MusicSettings class NameTest { - @Test - fun name_simple_from_settings() { - val musicSettings = mockk { every { intelligentSorting } returns false } - assertTrue(Name.Known.Factory.from(musicSettings) is SimpleKnownName.Factory) - } - - @Test - fun name_intelligent_from_settings() { - val musicSettings = mockk { every { intelligentSorting } returns true } - assertTrue(Name.Known.Factory.from(musicSettings) is IntelligentKnownName.Factory) - } - @Test fun name_simple_withoutPunct() { - val name = SimpleKnownName("Loveless", null) + val name = Name.Known.SimpleFactory.parse("Loveless", null) assertEquals("Loveless", name.raw) assertEquals(null, name.sort) assertEquals("L", name.thumb) @@ -52,7 +36,7 @@ class NameTest { @Test fun name_simple_withPunct() { - val name = SimpleKnownName("alt-J", null) + val name = Name.Known.SimpleFactory.parse("alt-J", null) assertEquals("alt-J", name.raw) assertEquals(null, name.sort) assertEquals("A", name.thumb) @@ -63,7 +47,7 @@ class NameTest { @Test fun name_simple_oopsAllPunct() { - val name = SimpleKnownName("!!!", null) + val name = Name.Known.SimpleFactory.parse("!!!", null) assertEquals("!!!", name.raw) assertEquals(null, name.sort) assertEquals("!", name.thumb) @@ -74,7 +58,7 @@ class NameTest { @Test fun name_simple_spacedPunct() { - val name = SimpleKnownName("& Yet & Yet", null) + val name = Name.Known.SimpleFactory.parse("& Yet & Yet", null) assertEquals("& Yet & Yet", name.raw) assertEquals(null, name.sort) assertEquals("Y", name.thumb) @@ -85,7 +69,7 @@ class NameTest { @Test fun name_simple_withSort() { - val name = SimpleKnownName("The Smile", "Smile") + val name = Name.Known.SimpleFactory.parse("The Smile", "Smile") assertEquals("The Smile", name.raw) assertEquals("Smile", name.sort) assertEquals("S", name.thumb) @@ -96,7 +80,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withoutNumerics() { - val name = IntelligentKnownName("Loveless", null) + val name = Name.Known.IntelligentFactory.parse("Loveless", null) assertEquals("Loveless", name.raw) assertEquals(null, name.sort) assertEquals("L", name.thumb) @@ -107,7 +91,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withSpacedStartNumerics() { - val name = IntelligentKnownName("15 Step", null) + val name = Name.Known.IntelligentFactory.parse("15 Step", null) assertEquals("15 Step", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) @@ -121,7 +105,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withPackedStartNumerics() { - val name = IntelligentKnownName("23Kid", null) + val name = Name.Known.IntelligentFactory.parse("23Kid", null) assertEquals("23Kid", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) @@ -135,7 +119,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withSpacedMiddleNumerics() { - val name = IntelligentKnownName("Foo 1 2 Bar", null) + val name = Name.Known.IntelligentFactory.parse("Foo 1 2 Bar", null) assertEquals("Foo 1 2 Bar", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) @@ -158,7 +142,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withPackedMiddleNumerics() { - val name = IntelligentKnownName("Foo12Bar", null) + val name = Name.Known.IntelligentFactory.parse("Foo12Bar", null) assertEquals("Foo12Bar", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) @@ -175,7 +159,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withSpacedEndNumerics() { - val name = IntelligentKnownName("Foo 1", null) + val name = Name.Known.IntelligentFactory.parse("Foo 1", null) assertEquals("Foo 1", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) @@ -189,7 +173,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withPackedEndNumerics() { - val name = IntelligentKnownName("Error404", null) + val name = Name.Known.IntelligentFactory.parse("Error404", null) assertEquals("Error404", name.raw) assertEquals(null, name.sort) assertEquals("E", name.thumb) @@ -203,7 +187,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withThe_withoutNumerics() { - val name = IntelligentKnownName("The National Anthem", null) + val name = Name.Known.IntelligentFactory.parse("The National Anthem", null) assertEquals("The National Anthem", name.raw) assertEquals(null, name.sort) assertEquals("N", name.thumb) @@ -214,7 +198,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withAn_withoutNumerics() { - val name = IntelligentKnownName("An Eagle in Your Mind", null) + val name = Name.Known.IntelligentFactory.parse("An Eagle in Your Mind", null) assertEquals("An Eagle in Your Mind", name.raw) assertEquals(null, name.sort) assertEquals("E", name.thumb) @@ -225,7 +209,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withA_withoutNumerics() { - val name = IntelligentKnownName("A Song For Our Fathers", null) + val name = Name.Known.IntelligentFactory.parse("A Song For Our Fathers", null) assertEquals("A Song For Our Fathers", name.raw) assertEquals(null, name.sort) assertEquals("S", name.thumb) @@ -236,7 +220,7 @@ class NameTest { @Test fun name_intelligent_withPunct_withoutArticle_withoutNumerics() { - val name = IntelligentKnownName("alt-J", null) + val name = Name.Known.IntelligentFactory.parse("alt-J", null) assertEquals("alt-J", name.raw) assertEquals(null, name.sort) assertEquals("A", name.thumb) @@ -247,7 +231,7 @@ class NameTest { @Test fun name_intelligent_oopsAllPunct_withoutArticle_withoutNumerics() { - val name = IntelligentKnownName("!!!", null) + val name = Name.Known.IntelligentFactory.parse("!!!", null) assertEquals("!!!", name.raw) assertEquals(null, name.sort) assertEquals("!", name.thumb) @@ -258,7 +242,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_shortArticle_withNumerics() { - val name = IntelligentKnownName("the 1", null) + val name = Name.Known.IntelligentFactory.parse("the 1", null) assertEquals("the 1", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) @@ -269,7 +253,7 @@ class NameTest { @Test fun name_intelligent_spacedPunct_withoutArticle_withoutNumerics() { - val name = IntelligentKnownName("& Yet & Yet", null) + val name = Name.Known.IntelligentFactory.parse("& Yet & Yet", null) assertEquals("& Yet & Yet", name.raw) assertEquals(null, name.sort) assertEquals("Y", name.thumb) @@ -280,7 +264,7 @@ class NameTest { @Test fun name_intelligent_withPunct_withoutArticle_withNumerics() { - val name = IntelligentKnownName("Design : 2 : 3", null) + val name = Name.Known.IntelligentFactory.parse("Design : 2 : 3", null) assertEquals("Design : 2 : 3", name.raw) assertEquals(null, name.sort) assertEquals("D", name.thumb) @@ -300,7 +284,7 @@ class NameTest { @Test fun name_intelligent_oopsAllPunct_withoutArticle_oopsAllNumerics() { - val name = IntelligentKnownName("2 + 2 = 5", null) + val name = Name.Known.IntelligentFactory.parse("2 + 2 = 5", null) assertEquals("2 + 2 = 5", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) @@ -323,7 +307,7 @@ class NameTest { @Test fun name_intelligent_withSort() { - val name = IntelligentKnownName("The Smile", "Smile") + val name = Name.Known.IntelligentFactory.parse("The Smile", "Smile") assertEquals("The Smile", name.raw) assertEquals("Smile", name.sort) assertEquals("S", name.thumb) @@ -334,40 +318,40 @@ class NameTest { @Test fun name_equals_simple() { - val a = SimpleKnownName("The Same", "Same") - val b = SimpleKnownName("The Same", "Same") + val a = Name.Known.SimpleFactory.parse("The Same", "Same") + val b = Name.Known.SimpleFactory.parse("The Same", "Same") assertEquals(a, b) } @Test fun name_equals_differentSort() { - val a = SimpleKnownName("The Same", "Same") - val b = SimpleKnownName("The Same", null) + val a = Name.Known.SimpleFactory.parse("The Same", "Same") + val b = Name.Known.SimpleFactory.parse("The Same", null) assertNotEquals(a, b) assertNotEquals(a.hashCode(), b.hashCode()) } @Test fun name_equals_intelligent_differentTokens() { - val a = IntelligentKnownName("The Same", "Same") - val b = IntelligentKnownName("Same", "Same") + val a = Name.Known.IntelligentFactory.parse("The Same", "Same") + val b = Name.Known.IntelligentFactory.parse("Same", "Same") assertNotEquals(a, b) assertNotEquals(a.hashCode(), b.hashCode()) } @Test fun name_compareTo_simple_withoutSort_withoutArticle_withoutNumeric() { - val a = SimpleKnownName("A", null) - val b = SimpleKnownName("B", null) + val a = Name.Known.SimpleFactory.parse("A", null) + val b = Name.Known.SimpleFactory.parse("B", null) assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_simple_withoutSort_withArticle_withoutNumeric() { - val a = SimpleKnownName("A Brain in a Bottle", null) - val b = SimpleKnownName("Acid Rain", null) - val c = SimpleKnownName("Boralis / Contrastellar", null) - val d = SimpleKnownName("Breathe In", null) + val a = Name.Known.SimpleFactory.parse("A Brain in a Bottle", null) + val b = Name.Known.SimpleFactory.parse("Acid Rain", null) + val c = Name.Known.SimpleFactory.parse("Boralis / Contrastellar", null) + val d = Name.Known.SimpleFactory.parse("Breathe In", null) assertEquals(-1, a.compareTo(b)) assertEquals(-1, a.compareTo(c)) assertEquals(-1, a.compareTo(d)) @@ -375,40 +359,40 @@ class NameTest { @Test fun name_compareTo_simple_withSort_withoutArticle_withNumeric() { - val a = SimpleKnownName("15 Step", null) - val b = SimpleKnownName("128 Harps", null) - val c = SimpleKnownName("1969", null) + val a = Name.Known.SimpleFactory.parse("15 Step", null) + val b = Name.Known.SimpleFactory.parse("128 Harps", null) + val c = Name.Known.SimpleFactory.parse("1969", null) assertEquals(1, a.compareTo(b)) assertEquals(-1, a.compareTo(c)) } @Test fun name_compareTo_simple_withPartialSort() { - val a = SimpleKnownName("A", "C") - val b = SimpleKnownName("B", null) + val a = Name.Known.SimpleFactory.parse("A", "C") + val b = Name.Known.SimpleFactory.parse("B", null) assertEquals(1, a.compareTo(b)) } @Test fun name_compareTo_simple_withSort() { - val a = SimpleKnownName("D", "A") - val b = SimpleKnownName("C", "B") + val a = Name.Known.SimpleFactory.parse("D", "A") + val b = Name.Known.SimpleFactory.parse("C", "B") assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withoutSort_withoutArticle_withoutNumeric() { - val a = IntelligentKnownName("A", null) - val b = IntelligentKnownName("B", null) + val a = Name.Known.IntelligentFactory.parse("A", null) + val b = Name.Known.IntelligentFactory.parse("B", null) assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withoutSort_withArticle_withoutNumeric() { - val a = IntelligentKnownName("A Brain in a Bottle", null) - val b = IntelligentKnownName("Acid Rain", null) - val c = IntelligentKnownName("Boralis / Contrastellar", null) - val d = IntelligentKnownName("Breathe In", null) + val a = Name.Known.IntelligentFactory.parse("A Brain in a Bottle", null) + val b = Name.Known.IntelligentFactory.parse("Acid Rain", null) + val c = Name.Known.IntelligentFactory.parse("Boralis / Contrastellar", null) + val d = Name.Known.IntelligentFactory.parse("Breathe In", null) assertEquals(1, a.compareTo(b)) assertEquals(1, a.compareTo(c)) assertEquals(-1, a.compareTo(d)) @@ -416,9 +400,9 @@ class NameTest { @Test fun name_compareTo_intelligent_withoutSort_withoutArticle_withNumeric() { - val a = IntelligentKnownName("15 Step", null) - val b = IntelligentKnownName("128 Harps", null) - val c = IntelligentKnownName("1969", null) + val a = Name.Known.IntelligentFactory.parse("15 Step", null) + val b = Name.Known.IntelligentFactory.parse("128 Harps", null) + val c = Name.Known.IntelligentFactory.parse("1969", null) assertEquals(-1, a.compareTo(b)) assertEquals(-1, b.compareTo(c)) assertEquals(-2, a.compareTo(c)) @@ -426,15 +410,15 @@ class NameTest { @Test fun name_compareTo_intelligent_withPartialSort_withoutArticle_withoutNumeric() { - val a = SimpleKnownName("A", "C") - val b = SimpleKnownName("B", null) + val a = Name.Known.SimpleFactory.parse("A", "C") + val b = Name.Known.SimpleFactory.parse("B", null) assertEquals(1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withSort_withoutArticle_withoutNumeric() { - val a = IntelligentKnownName("D", "A") - val b = IntelligentKnownName("C", "B") + val a = Name.Known.IntelligentFactory.parse("D", "A") + val b = Name.Known.IntelligentFactory.parse("C", "B") assertEquals(-1, a.compareTo(b)) } @@ -447,7 +431,7 @@ class NameTest { @Test fun name_compareTo_mixed() { val a = Name.Unknown(0) - val b = IntelligentKnownName("A", null) + val b = Name.Known.IntelligentFactory.parse("A", null) assertEquals(-1, a.compareTo(b)) } } From 5204b591148c19ed47422b38f516857cd8b39e5c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 26 Nov 2023 14:56:55 -0700 Subject: [PATCH 08/72] info: add android 14 to bug template --- .github/ISSUE_TEMPLATE/bug-crash-report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml index 652dba0b8..7b94b9916 100644 --- a/.github/ISSUE_TEMPLATE/bug-crash-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml @@ -34,6 +34,7 @@ body: attributes: label: What android version do you use? options: + - Android 14 - Android 13 - Android 12L - Android 12 From 7d9ed7d114ddba509b5f978438378b43eaeeb186 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 3 Dec 2023 19:48:58 +0100 Subject: [PATCH 09/72] Translations update from Hosted Weblate (#610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Spanish) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Spanish) Currently translated at 99.3% (290 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Czech) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Czech) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/es/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/lt/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/zh_Hans/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/uk/ * Translated using Weblate (Hindi) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/zh_Hant/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pa/ * Translated using Weblate (Hindi) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hi/ * Translated using Weblate (Russian) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Russian) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ru/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/be/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 19.1% (56 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hant/ * Translated using Weblate (German) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (German) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/de/ --------- Co-authored-by: Iago Co-authored-by: gallegonovato Co-authored-by: Fjuro Co-authored-by: BMT[UA] Co-authored-by: Eric Co-authored-by: Vaclovas Intas Co-authored-by: ShareASmile Co-authored-by: abc0922001 Co-authored-by: Макар Разин Co-authored-by: qwerty287 --- app/src/main/res/values-be/strings.xml | 3 + app/src/main/res/values-cs/strings.xml | 3 + app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 67 ++++++++++--------- app/src/main/res/values-hi/strings.xml | 3 + app/src/main/res/values-lt/strings.xml | 3 + app/src/main/res/values-pa/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-uk/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 1 + .../metadata/android/be/full_description.txt | 2 +- .../metadata/android/cs/full_description.txt | 2 +- .../metadata/android/de/full_description.txt | 2 +- .../android/es-ES/full_description.txt | 2 +- .../metadata/android/hi/full_description.txt | 2 +- .../metadata/android/lt/full_description.txt | 4 +- .../metadata/android/pa/full_description.txt | 2 +- .../metadata/android/ru/full_description.txt | 2 +- .../metadata/android/uk/full_description.txt | 2 +- .../android/zh-CN/full_description.txt | 2 +- .../android/zh-Hant/full_description.txt | 22 ++++++ 22 files changed, 96 insertions(+), 43 deletions(-) create mode 100644 fastlane/metadata/android/zh-Hant/full_description.txt diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 0084fbd04..a17118fb0 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -304,4 +304,7 @@ Скапіравана Інфармацыя пра памылку Справаздача пра памылку + Няма альбомаў + Дэма + Дэманстрацыі \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3a2ef1255..e39a84ca3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -315,4 +315,7 @@ Informace o chybě Zkopírovat Nahlásit + Žádná alba + Demo + Dema \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 35e2abb16..4c0fa5b55 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -306,4 +306,7 @@ Kopiert Melden Fehlerinformation + Keine Alben + Demo + Demos \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a4a109704..20af977ec 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -14,7 +14,7 @@ Buscar Filtrar Todo - Organizar + Ordenar Nombre Artista Álbum @@ -43,7 +43,7 @@ Desarrollado por Alexander Capehart Ajustes - Aspecto y sensación + Aspecto y Comportamiento Tema Automático Claro @@ -52,31 +52,31 @@ Tema negro Usar un tema completamente negro Pantalla - Pestañas de biblioteca + Pestañas de la biblioteca Cambiar visibilidad y orden de las pestañas de la biblioteca Carátulas redondeadas - Habilite las esquinas redondeadas en los elementos adicionales de la interfaz del usuario (requiere que las portadas de los álbumes estén redondeadas) - Usar acciones de notificación alternativas + Habilitar las esquinas redondeadas en los elementos adicionales de la interfaz del usuario (requiere que las portadas de los álbumes estén redondeadas) + Usar acciones de notificación personalizadas Sonido Estrategia de la ganancia de la repetición - Por pista - Por álbum + Preferir pista + Preferir álbum Preferir el álbum si se está en reproducción - Comportamiento + Personalizar Cuando se está reproduciendo de la biblioteca Recordar mezcla - Mantener mezcla cuando se reproduce una nueva canción - Rebobinar atrás - Rebobinar al saltar a la canción anterior - Pausa en repetición - Pausa cuando se repite una canción + Mantener mezcla activada cuando se reproduce una nueva canción + Rebobinar antes de saltar al anterior + Rebobinar antes de saltar a la canción anterior + Pausar al repetir + Pausar cuando se repite una canción Contenido Guardar estado de reproducción - Guardar el estado de reproduccion ahora + Guardar el estado de reproducción ahora Actualizar música Recargar la biblioteca musical, utilizando las etiquetas en caché cuando sea posible - Sin música + No se ha encontrado música Falló la carga de música Auxio necesita permiso para leer su biblioteca de música No se encontró ninguna aplicación que pueda manejar esta tarea @@ -89,12 +89,12 @@ Saltar a la siguiente canción Saltar a la última canción Cambiar modo de repetición - Act/des mezcla - Mezclar todo + Activar o desactivar mezcla + Mezclar todas las canciones Quitar canción de la cola Mover canción en la cola Mover pestaña - Borrar historial de búsqueda + Borrar búsqueda Quitar carpeta Icono de Auxio Carátula de álbum @@ -177,11 +177,11 @@ Frecuencia de muestreo Cancelar Reproducción automática con auriculares - Reestablecer el estado de reproducción - Reestablecer el estado de reproducción guardado previamente (si existe) + Restablecer el estado de reproducción + Restablecer el estado de reproducción guardado previamente (si existe) Carpetas de música Gestionar de dónde se cargará la música - La músicasolo se cargará de las carpetas que añadas. + La música solo se cargará de las carpetas que añadas. Dinámico Disco %d Reproducción extendidas (EPs) @@ -193,9 +193,9 @@ Pistas de audio Mixtapes (recopilación de canciones) Mixtape (recopilación de canciones) - Remezcla + Remezclas Nombre de archivo - Siempre empezar la reproducción cuando se conectan unos auriculares (puede no funcionar en todos los dispositivos) + Siempre empezar la reproducción cuando se conecten auriculares (puede no funcionar en todos los dispositivos) Pre-amp ReplayGain El pre-amp se aplica al ajuste existente durante la reproducción Ajuste con etiquetas @@ -206,7 +206,7 @@ Álbum en directo Single en directo Compilación - Directo + En directo Audio MPEG-1 Audio MPEG-4 %d kbps @@ -214,7 +214,7 @@ EP en directo Single remix Compilaciones - EP remix + EP de remixes Directorio superior Eliminar el estado de reproducción guardado previamente (si existe) Abrir la cola @@ -222,15 +222,15 @@ Estado limpiado Limpiar el estado de reproducción Separadores de varios valores - Excluye la música + Excluye los archivos que no sean música Configurar caracteres que denotan múltiples valores de la etiqueta Coma (,) Punto y coma (;) Barra oblicua (/) Recopilación en directo - Compilaciones de remezclas - Mezclas del DJ - Mezclas del DJ + Compilación de remezclas + Mezclas de DJ + Mezcla de DJ Ecualizador Portadas de álbumes Apagado @@ -251,7 +251,7 @@ %d artistas %d artistas - Imposible guardar el estado + No se pudo guardar el estado No se puede borrar el estado Borrar la caché de las etiquetas y recargar completamente la biblioteca musical (más lento, pero más completo) Volver a escanear la música @@ -297,11 +297,11 @@ Aparece en Compartir Sin disco - Carátula del álbum Force Square + Forzar carátulas de álbum cuadradas Recorta todas las portadas de los álbumes a una relación de aspecto 1:1 Canción Vista - Reproducir la canción por tí mismo + Reproducir la canción por sí misma Ordenar por Dirección Selección de imágenes @@ -310,4 +310,7 @@ Información sobre el error Copiado Informar + Sin álbumes + Demostración + Demostraciones \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 0384924fd..84be232d8 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -305,4 +305,7 @@ रिपोर्ट करें कापी किया गया और + कोई एल्बम नहीं + डेमो + डेमो \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 0b2138093..94c41de55 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -304,4 +304,7 @@ Nukopijuota Daugiau Pranešti + Nėra albumų + Demo + Demos \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 1d385c7bc..c33f1e852 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -298,4 +298,7 @@ ਤਰੁੱਟੀ ਦੀ ਜਾਣਕਾਰੀ ਕਾਪੀ ਕੀਤਾ ਗਿਆ ਰਿਪੋਰਟ ਕਰੋ + ਕੋਈ ਐਲਬਮ ਨਹੀਂ + ਡੈਮੋ + ਡੈਮੋ \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2dc979122..4859680ad 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -313,4 +313,7 @@ Информация об ошибке Отчёт об ошибке Скопировано + Няма альбомаў + Демо + Дэманстрацыі \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f4b12a5aa..2164136ed 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -310,4 +310,7 @@ Інформація про помилку Скопійовано Звіт + Альбомів немає + Демо + Демонстрації \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 51381f724..f3742f1d0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -304,4 +304,7 @@ 更多 已复制 错误信息 + 无专辑 + 演示 + 样曲 \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b46cd1c4a..c59791d5c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -72,4 +72,5 @@ 專輯 單曲 單曲 + 在更多的用戶界面元素上啟用圓角(需要專輯封面也要設定圓角) \ No newline at end of file diff --git a/fastlane/metadata/android/be/full_description.txt b/fastlane/metadata/android/be/full_description.txt index 25badc179..dc3182282 100644 --- a/fastlane/metadata/android/be/full_description.txt +++ b/fastlane/metadata/android/be/full_description.txt @@ -19,4 +19,4 @@ Auxio - гэта мясцовы музычны плэер з хуткім і н - Аўтазапуск гарнітуры - Стыльныя віджэты, якія аўтаматычна адаптуюцца да іх памеру - Цалкам прыватны і ў аўтаномным рэжыме -- Ніякіх круглявых вокладак альбомаў (Калі вы не хочаце іх. Тады вы можаце.) +- Ніякіх круглявых вокладак альбомаў (па змаўчанні) diff --git a/fastlane/metadata/android/cs/full_description.txt b/fastlane/metadata/android/cs/full_description.txt index 26cabb0d0..caf9e51ef 100644 --- a/fastlane/metadata/android/cs/full_description.txt +++ b/fastlane/metadata/android/cs/full_description.txt @@ -20,4 +20,4 @@ přesná/původní data, štítky pro řazení a další - Automatické přehrávání při připojení sluchátek - Stylové widgety, které se automaticky adaptují své velikosti - Plně soukromý a offline -- Žádné zakulacené obaly alb (Pokud je tedy nechcete. Jinak jsou k dispozici.) +- Žádné zakulacené obaly alb (ve výchozím nastavení) diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index 00b49f827..3eedd16a2 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -20,4 +20,4 @@ Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, ab - Autoplay bei Kopfhörern - Stylische Widgets, die ihre Größe anpassen - vollständig privat und offline -- keine abgerundeten Album-Cover (Außer die willst. Dann geht das.) +- keine abgerundeten Album-Cover (standardmäßig) diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index fca66cb77..67ee646c7 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -20,4 +20,4 @@ fechas precisas/originales, ordenar etiquetas y más - Reproducción automática de auriculares - Widgets con estilo que se adaptan automáticamente a su tamaño - Completamente privado y fuera de línea -- No hay portadas de álbumes redondeadas (a menos que las quieras. Entonces puedes) +- Sin carátulas redondeadas (por defecto) diff --git a/fastlane/metadata/android/hi/full_description.txt b/fastlane/metadata/android/hi/full_description.txt index 2c5fdb04a..81b86358b 100644 --- a/fastlane/metadata/android/hi/full_description.txt +++ b/fastlane/metadata/android/hi/full_description.txt @@ -20,4 +20,4 @@ Auxio एक तेज़, विश्वसनीय UI/UX वाला एक - हेडसेट ऑटोप्ले - स्टाइलिश विजेट जो स्वचालित रूप से अपने आकार के अनुकूल हो जाते हैं - पूरी तरह से निजी और ऑफ़लाइन -- कोई गोलाकार एल्बम कवर नहीं (जब तक आप उन्हें नहीं चाहते। फिर तुम कर सकते हो।) +- कोई गोलाकार एल्बम कवर नहीं (डिफ़ॉल्ट तौर पर) diff --git a/fastlane/metadata/android/lt/full_description.txt b/fastlane/metadata/android/lt/full_description.txt index 372e28de5..043666dfc 100644 --- a/fastlane/metadata/android/lt/full_description.txt +++ b/fastlane/metadata/android/lt/full_description.txt @@ -1,4 +1,4 @@ -Auxio yra vietinis muzikos grotuvas su greita, patikima UI/UX be daugybės nenaudingų funkcijų, esančių kituose muzikos grotuvuose. Sukurta remiantis iš šiuolaikinių medijos grojimo bibliotekų, Auxio turi geresnį bibliotekos palaikymą ir klausymo kokybę, palyginti su kitomis programomis, kurios naudoja pasenusias Android funkcijas. Trumpai tariant, Jame groja muziką. +Auxio yra vietinis muzikos grotuvas su greita, patikima UI/UX be daugybės nenaudingų funkcijų, esančių kituose muzikos grotuvuose. Sukurta remiantis iš šiuolaikinių medijos grojimo bibliotekų, Auxio turi geresnį bibliotekos palaikymą ir klausymo kokybę, palyginti su kitomis programomis, kurios naudoja pasenusias Android funkcijas. Trumpai tariant, jame groja muziką. Funkcijos @@ -20,4 +20,4 @@ tikslias/originalias datas, rūšiavimo žymas ir dar daugiau - Automatinis ausinių grojimas - Stilingi valdikliai, kurie automatiškai prisitaiko prie savo dydžio - Visiškai privatus ir neprisijungęs -- Jokių suapvalintų albumų viršelių (Nebent nori. Tada gali.) +- Jokių suapvalintų albumų viršelių (pagal numatytuosius nustatymus) diff --git a/fastlane/metadata/android/pa/full_description.txt b/fastlane/metadata/android/pa/full_description.txt index c98396ffa..c896207aa 100644 --- a/fastlane/metadata/android/pa/full_description.txt +++ b/fastlane/metadata/android/pa/full_description.txt @@ -20,4 +20,4 @@ Auxio ਇੱਕ ਤੇਜ਼, ਭਰੋਸੇਮੰਦ UI/UX ਵਾਲਾ ਇੱ - ਹੈੱਡਸੈੱਟ ਆਟੋਪਲੇ - ਸਟਾਈਲਿਸ਼ ਵਿਜੇਟਸ ਜੋ ਆਪਣੇ ਆਪ ਉਹਨਾਂ ਦੇ ਆਕਾਰ ਦੇ ਅਨੁਕੂਲ ਬਣਦੇ ਹਨ - ਪੂਰੀ ਤਰ੍ਹਾਂ ਨਿੱਜੀ ਅਤੇ ਆਫਲਾਈਨ -- ਕੋਈ ਗੋਲ ਐਲਬਮ ਕਵਰ ਨਹੀਂ (ਜਦੋਂ ਤੱਕ ਤੁਸੀਂ ਉਹਨਾਂ ਨੂੰ ਨਹੀਂ ਚਾਹੁੰਦੇ ਹੋ। ਤੁਸੀਂ ਕਰ ਸਕਦੇ ਹੋ।) +- ਕੋਈ ਗੋਲ ਐਲਬਮ ਕਵਰ ਨਹੀਂ (ਡਿਫ਼ਾਲਟ ਤੌਰ ਤੇ) diff --git a/fastlane/metadata/android/ru/full_description.txt b/fastlane/metadata/android/ru/full_description.txt index c58195709..634f1e263 100644 --- a/fastlane/metadata/android/ru/full_description.txt +++ b/fastlane/metadata/android/ru/full_description.txt @@ -19,4 +19,4 @@ Auxio — это локальный музыкальный плеер с быс - Автоматическое воспроизведение в наушниках - Адаптивные виджеты - Полностью частный и офлайн -- Никаких закруглённых обложек альбомов (если вы их не хотите) +- Никаких закруглённых обложек альбомов (по умолчанию) diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index afbd7ac2c..890722b67 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -20,4 +20,4 @@ Auxio – це локальний музичний плеєр зі швидки - Автоматичне відтворення в навушниках - Стильні віджети, які автоматично підлаштовуються під розмір - Повністю приватний і офлайн -- Жодних заокруглених обкладинок альбомів (якщо ви їх не хочете) +- Жодних заокруглених обкладинок альбомів (за замовчуванням) diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt index 25b518644..318031e5c 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -20,4 +20,4 @@ Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没 - 耳机连接时自动播放 - 按桌面尺寸自适应的风格化微件 - 完全离线且私密 -- 没有圆角的专辑封面(如果你想要也可以拥有) +- 没有圆角的专辑封面(默认设置) diff --git a/fastlane/metadata/android/zh-Hant/full_description.txt b/fastlane/metadata/android/zh-Hant/full_description.txt new file mode 100644 index 000000000..26596541a --- /dev/null +++ b/fastlane/metadata/android/zh-Hant/full_description.txt @@ -0,0 +1,22 @@ +Auxio 是一款本機音樂播放器,擁有快速且可靠的 UI/UX,不含其他音樂播放器中許多無用的功能。Auxio 基於現代媒體播放庫構建,與使用過時 Android 功能的其他應用相比,擁有更優越的庫支援和聆聽品質。簡而言之,它播放音樂。 + +功能 + +- 基於 Media3 ExoPlayer 的播放功能 +- 源自最新 Material Design 指南的靈敏 UI +- 優化 UX,重視易用性高於邊緣情況 +- 可自訂的行為 +- 支援碟數、多位藝術家、發行類型、精確/原始日期、排序標籤等等 +- 進階藝術家系統,統一藝術家與專輯藝術家 +- 支援 SD 卡的資料夾管理 +- 可靠的播放列表功能 +- 播放狀態持久性 +- 完整的 ReplayGain 支援(適用於 MP3、FLAC、OGG、OPUS 和 MP4 檔案) +- 外部均衡器支援(例如 Wavelet) +- 無邊界設計 +- 內嵌封面支援 +- 搜尋功能 +- 耳機自動播放 +- 時尚的小工具,自動適應大小 +- 完全私密且離線 +- 默認不使用圓角專輯封面 From b1c48f13fd9c51160e325fb4bd66b32affb8cf03 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 11:02:39 -0700 Subject: [PATCH 10/72] build: update agp AGP -> 8.2.0 Requires me to enable Java 8 desugaring for some...reason. --- app/build.gradle | 2 ++ build.gradle | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index f6a0d3abc..f2684c848 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,6 +31,7 @@ android { } compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } @@ -127,6 +128,7 @@ dependencies { // Exoplayer (Vendored) implementation project(":media-lib-exoplayer") implementation project(":media-lib-decoder-ffmpeg") + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" // Image loading implementation 'io.coil-kt:coil-base:2.4.0' diff --git a/build.gradle b/build.gradle index e2f1717dc..fdb033f43 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ buildscript { } plugins { - id "com.android.application" version '8.1.2' apply false + id "com.android.application" version '8.2.0' apply false id "androidx.navigation.safeargs.kotlin" version "$navigation_version" apply false id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false id "com.google.devtools.ksp" version '1.9.10-1.0.13' apply false From 7a90e7eef153414ea446d0c494ff83185e6af10a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 13:51:49 -0700 Subject: [PATCH 11/72] build: update deps Will need to put some work into updating some others --- app/build.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f2684c848..9298a778a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -88,8 +88,8 @@ dependencies { // General implementation "androidx.core:core-ktx:1.12.0" implementation "androidx.appcompat:appcompat:1.6.1" - implementation "androidx.activity:activity-ktx:1.8.0" - implementation "androidx.fragment:fragment-ktx:1.6.1" + implementation "androidx.activity:activity-ktx:1.8.2" + implementation "androidx.fragment:fragment-ktx:1.6.2" // Components // Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on @@ -112,13 +112,13 @@ dependencies { implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version" // Media - implementation "androidx.media:media:1.6.0" + implementation "androidx.media:media:1.7.0" // Preferences implementation "androidx.preference:preference-ktx:1.2.1" // Database - def room_version = '2.6.0-rc01' + def room_version = '2.6.1' implementation "androidx.room:room-runtime:$room_version" ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" From 4421d6cf36ab7c035d8b71515a9bf2942426f380 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 13:52:36 -0700 Subject: [PATCH 12/72] music: deduplicate by case At some point, the switch to keying raw music information broke my mitigation for duplicate tags that use similar cases. This then crashed the music loader in certain cases. Fix it by making the check use raw music keys. Resolves #614 --- .../oxycblt/auxio/music/device/DeviceMusicImpl.kt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index e3e2232ad..ec71efbf4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -150,26 +150,26 @@ class SongImpl( val artistSortNames = separators.split(rawSong.artistSortNames) val rawIndividualArtists = artistNames - .mapIndexedTo(mutableSetOf()) { i, name -> + .mapIndexed { i, name -> RawArtist( artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), name, artistSortNames.getOrNull(i)) } - .toList() + .distinctBy { it.key } val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds) val albumArtistNames = separators.split(rawSong.albumArtistNames) val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames) val rawAlbumArtists = albumArtistNames - .mapIndexedTo(mutableSetOf()) { i, name -> + .mapIndexed { i, name -> RawArtist( albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), name, albumArtistSortNames.getOrNull(i)) } - .toList() + .distinctBy { it.key } rawAlbum = RawAlbum( @@ -195,10 +195,7 @@ class SongImpl( val genreNames = (rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames)) rawGenres = - genreNames - .mapTo(mutableSetOf()) { RawGenre(it) } - .toList() - .ifEmpty { listOf(RawGenre()) } + genreNames.map { RawGenre(it) }.distinctBy { it.key }.ifEmpty { listOf(RawGenre()) } hashCode = 31 * hashCode + rawSong.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode() From 953b92108a6cc3a4b4271df541945a483b1354dd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 14:20:52 -0700 Subject: [PATCH 13/72] build: update to ndk r26 Builds, unsure if it will cause any signifigant changes. --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 9298a778a..bf925d562 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,7 +16,7 @@ android { // it here so that binary stripping will work. // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the // NDK use is unified - ndkVersion = "23.2.8568313" + ndkVersion "26.1.10909125" namespace "org.oxycblt.auxio" defaultConfig { From f4db2fcd80e7daa7a226bfef4e1b054b86e55c5c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 14:26:52 -0700 Subject: [PATCH 14/72] build: use ndk r25 Apparently GH actions doesn't like NDK r26 yet. --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index bf925d562..e49192e56 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,7 +16,7 @@ android { // it here so that binary stripping will work. // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the // NDK use is unified - ndkVersion "26.1.10909125" + ndkVersion "25.2.9519653" namespace "org.oxycblt.auxio" defaultConfig { From b9bcdf4a515f10174b17e51b474cdf9e883488ea Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 14:33:17 -0700 Subject: [PATCH 15/72] build: fix ndk --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index e49192e56..f414337b8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,7 +16,7 @@ android { // it here so that binary stripping will work. // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the // NDK use is unified - ndkVersion "25.2.9519653" + ndkVersion = "25.2.9519653" namespace "org.oxycblt.auxio" defaultConfig { From b7f33622e721936558ef3b20b710884b641d54ad Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 15:17:40 -0700 Subject: [PATCH 16/72] build: update media3 --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 40c3e5c68..0d4e1098a 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 40c3e5c68cbdf8758037aa40b4071cca8a53ee89 +Subproject commit 0d4e1098a8787c1db9b3ac1d8f3f5b861735b837 From bf1cbad1da3200037d91986b993d41f10315b37b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 15:44:59 -0700 Subject: [PATCH 17/72] build: update media --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 0d4e1098a..2cfefb8f3 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 0d4e1098a8787c1db9b3ac1d8f3f5b861735b837 +Subproject commit 2cfefb8f39d84412920d17be4ba76ebaabf2d6a6 From cd42c773044b6b270df1abd26bcc29db6856f1c9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 16:33:54 -0700 Subject: [PATCH 18/72] playback: use ffmpeg first Always decode with ffmpeg before decoding with MediaCodec. MediaCodec is unreliable on some devices in such a way as to cause a full loading failure on them. Prevent this by using ffmpeg. --- CHANGELOG.md | 4 ++++ .../java/org/oxycblt/auxio/playback/system/PlaybackService.kt | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4cac8eb4..0dcca4f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - Albums linked to an artist only as a collaborator are no longer included in an artist's album count +#### What's Fixed +- Fixed certain FLAC files failing to play on some devices + + ## 3.2.1 #### What's Improved diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 15c8cf0eb..7ac1c66cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -121,14 +121,14 @@ class PlaybackService : // battery/apk size/cache size val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> arrayOf( + FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), MediaCodecAudioRenderer( this, MediaCodecSelector.DEFAULT, handler, audioListener, AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, - replayGainProcessor), - FfmpegAudioRenderer(handler, audioListener, replayGainProcessor)) + replayGainProcessor)) } player = From 4cb309f01f33a5defa424dd42717d11257eaddfa Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 17:49:41 -0700 Subject: [PATCH 19/72] playback: eliminate old viewpager stuff --- .../auxio/playback/PlaybackPanelFragment.kt | 63 ++++--------------- .../auxio/playback/queue/QueueFragment.kt | 3 +- .../auxio/playback/ui/PlaybackPagerAdapter.kt | 5 +- .../auxio/playback/ui/SwipeCoverView.kt | 6 +- .../layout-h480dp/fragment_playback_panel.xml | 5 +- .../fragment_playback_panel.xml | 18 +++++- .../res/layout/fragment_playback_panel.xml | 40 +++++++++++- 7 files changed, 77 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index a893f455f..d03e0cb1c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -29,27 +29,20 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.Toolbar import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.widget.ViewPager2 -import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import dagger.hilt.android.AndroidEntryPoint -import java.lang.reflect.Field -import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.ListViewModel -import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.queue.QueueViewModel +import org.oxycblt.auxio.music.resolveNames 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.SwipeCoverView import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.showToast @@ -71,7 +64,6 @@ class PlaybackPanelFragment : SwipeCoverView.OnSwipeListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() - private val queueModel: QueueViewModel by activityViewModels() private val listModel: ListViewModel by activityViewModels() private var equalizerLauncher: ActivityResultLauncher? = null private var coverAdapter: PlaybackPagerAdapter? = null @@ -111,15 +103,11 @@ class PlaybackPanelFragment : } } - // cover carousel adapter - coverAdapter = PlaybackPagerAdapter(this) - binding.playbackCoverPager.apply { - adapter = coverAdapter - registerOnPageChangeCallback(OnCoverChangedCallback(queueModel)) - val recycler = VP_RECYCLER_FIELD.get(this@apply) as RecyclerView - recycler.isNestedScrollingEnabled = false - } binding.playbackCover.onSwipeListener = this + binding.playbackSong.setOnClickListener { navigateToCurrentSong() } + binding.playbackArtist.setOnClickListener { navigateToCurrentArtist() } + binding.playbackAlbum.setOnClickListener { navigateToCurrentAlbum() } + binding.playbackSeekBar.listener = this // Set up actions @@ -137,8 +125,6 @@ class PlaybackPanelFragment : collectImmediately(playbackModel.repeatMode, ::updateRepeat) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playbackModel.isShuffled, ::updateShuffled) - collectImmediately(queueModel.queue, ::updateQueue) - collectImmediately(queueModel.index, ::updateQueuePosition) } override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { @@ -190,7 +176,12 @@ class PlaybackPanelFragment : } val binding = requireBinding() + val context = requireContext() logD("Updating song display: $song") + binding.playbackCover.bind(song) + binding.playbackSong.text = song.name.resolve(context) + binding.playbackArtist.text = song.artists.resolveNames(context) + binding.playbackAlbum.text = song.album.name.resolve(context) binding.playbackSeekBar.durationDs = song.durationMs.msToDs() } @@ -220,43 +211,15 @@ class PlaybackPanelFragment : requireBinding().playbackShuffle.isActivated = isShuffled } - override fun navigateToCurrentSong() { + private fun navigateToCurrentSong() { playbackModel.song.value?.let(detailModel::showAlbum) } - override fun navigateToCurrentArtist() { + private fun navigateToCurrentArtist() { playbackModel.song.value?.let(detailModel::showArtist) } - override fun navigateToCurrentAlbum() { + private fun navigateToCurrentAlbum() { playbackModel.song.value?.let { detailModel.showAlbum(it.album) } } - - override fun navigateToMenu() { - // TODO - } - - private class OnCoverChangedCallback(private val queueViewModel: QueueViewModel) : - OnPageChangeCallback() { - - private var targetPosition = RecyclerView.NO_POSITION - - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - targetPosition = position - } - - override fun onPageScrollStateChanged(state: Int) { - super.onPageScrollStateChanged(state) - if (state == ViewPager2.SCROLL_STATE_IDLE && - targetPosition != RecyclerView.NO_POSITION && - targetPosition != queueViewModel.index.value) { - queueViewModel.goto(targetPosition) - } - } - } - - private companion object { - val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 2db007971..7a70bc6e8 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -22,6 +22,7 @@ import android.os.Bundle import android.view.LayoutInflater import androidx.core.view.isInvisible import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -42,7 +43,7 @@ import org.oxycblt.auxio.util.logD */ @AndroidEntryPoint class QueueFragment : ViewBindingFragment(), EditClickListListener { - private val queueModel: QueueViewModel by activityViewModels() + private val queueModel: QueueViewModel by viewModels() private val playbackModel: PlaybackViewModel by activityViewModels() private val queueAdapter = QueueAdapter(this) private var touchHelper: ItemTouchHelper? = null diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt index d0f176231..f98498c71 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt @@ -73,10 +73,7 @@ class CoverViewHolder private constructor(private val binding: ItemPlaybackSongB val context = binding.root.context binding.playbackCover.bind(item) // binding.playbackCover.bind(item) - binding.playbackSong.apply { - text = item.name.resolve(context) - setOnClickListener { listener.navigateToCurrentSong() } - } + binding.playbackSong.apply { text = item.name.resolve(context) } binding.playbackArtist.apply { text = item.artists.resolveNames(context) setOnClickListener { listener.navigateToCurrentArtist() } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/SwipeCoverView.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/SwipeCoverView.kt index dc0fcf9b0..08bb46b85 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/ui/SwipeCoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/SwipeCoverView.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.playback.ui import android.annotation.SuppressLint @@ -77,8 +77,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr val diffX = e2.x - e1.x if (abs(diffX) > abs(diffY) && abs(diffX) > viewConfig.scaledTouchSlop && - abs(velocityX) > viewConfig.scaledMinimumFlingVelocity - ) { + abs(velocityX) > viewConfig.scaledMinimumFlingVelocity) { if (diffX > 0) { onSwipeRight() } else { @@ -102,6 +101,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr interface OnSwipeListener { fun onSwipePrevious() + fun onSwipeNext() } } diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index fe6d81774..4157231ac 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -62,7 +62,8 @@ app:layout_constraintBottom_toTopOf="@+id/playback_seek_bar" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> + tools:text="Album Name" /> + - + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml index abc8d7e01..21152be5a 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -44,11 +44,25 @@ android:id="@+id/playback_artist" style="@style/Widget.Auxio.TextView.Secondary.Marquee" android:layout_width="0dp" - android:layout_height="0dp" + 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" /> + + + tools:text="Album Name" /> + + + + + + + + + + + + Date: Mon, 18 Dec 2023 18:33:13 -0700 Subject: [PATCH 20/72] music: throw on deadlocks Attempt to throw an exception when any part of the loading routine times out. --- .../oxycblt/auxio/music/MusicRepository.kt | 9 ++- .../auxio/music/device/DeviceLibrary.kt | 10 ++-- .../auxio/music/fs/MediaStoreExtractor.kt | 5 +- .../auxio/music/metadata/TagExtractor.kt | 8 ++- .../java/org/oxycblt/auxio/util/StateUtil.kt | 59 +++++++++++++++++++ 5 files changed, 80 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 55cfeaf0a..c61c4c5ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -41,6 +41,7 @@ import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.music.metadata.TagExtractor import org.oxycblt.auxio.music.user.MutableUserLibrary import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.util.forEachWithTimeout import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW @@ -448,6 +449,7 @@ constructor( try { tagExtractor.consume(incompleteSongs, completeSongs) } catch (e: Exception) { + logD("Tag extraction failed: $e") completeSongs.close(e) return@async } @@ -464,6 +466,7 @@ constructor( deviceLibraryFactory.create( completeSongs, processedSongs, separators, nameFactory) } catch (e: Exception) { + logD("DeviceLibrary creation failed: $e") processedSongs.close(e) return@async Result.failure(e) } @@ -474,8 +477,10 @@ constructor( // 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. val rawSongs = LinkedList() - for (rawSong in processedSongs) { - rawSongs.add(rawSong) + // Use a longer timeout so that dependent components can timeout and throw errors that + // 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 // indicating a defined amount of loaded songs in comparison to the projected amount // of songs that were queried. diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index c694a65ea..389b34fb6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -33,7 +33,9 @@ import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.useQuery import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.metadata.Separators +import org.oxycblt.auxio.util.forEachWithTimeout import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.sendWithTimeout import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -130,7 +132,7 @@ class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory { // TODO: Use comparators here // 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) // 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 @@ -142,8 +144,8 @@ class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory { // 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 // process. - processedSongs.send(rawSong) - continue + processedSongs.sendWithTimeout(rawSong) + return@forEachWithTimeout } songGrouping[song.uid] = song @@ -206,7 +208,7 @@ class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory { } } - processedSongs.send(rawSong) + processedSongs.sendWithTimeout(rawSong) } // Now that all songs are processed, also process albums and group them into their diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 392103d80..b25a360a7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -36,6 +36,7 @@ import org.oxycblt.auxio.music.metadata.parseId3v2PositionField import org.oxycblt.auxio.music.metadata.transformPositionField import org.oxycblt.auxio.util.getSystemServiceCompat 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 @@ -205,10 +206,10 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) : val rawSong = RawSong() query.populateFileInfo(rawSong) if (cache?.populate(rawSong) == true) { - completeSongs.send(rawSong) + completeSongs.sendWithTimeout(rawSong) } else { query.populateTags(rawSong) - incompleteSongs.send(rawSong) + incompleteSongs.sendWithTimeout(rawSong) } yield() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt index 4cca1a824..de49e2e81 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt @@ -23,7 +23,9 @@ import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield import org.oxycblt.auxio.music.device.RawSong +import org.oxycblt.auxio.util.forEachWithTimeout 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 @@ -55,14 +57,14 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork logD("Beginning primary extraction loop") - for (incompleteRawSong in incompleteSongs) { + incompleteSongs.forEachWithTimeout { incompleteRawSong -> spin@ while (true) { for (i in tagWorkerPool.indices) { val worker = tagWorkerPool[i] if (worker != null) { val completeRawSong = worker.poll() if (completeRawSong != null) { - completeSongs.send(completeRawSong) + completeSongs.sendWithTimeout(completeRawSong) yield() } else { continue @@ -83,7 +85,7 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork if (task != null) { val completeRawSong = task.poll() if (completeRawSong != null) { - completeSongs.send(completeRawSong) + completeSongs.sendWithTimeout(completeRawSong) tagWorkerPool[i] = null yield() } else { diff --git a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt index eb74d8f15..eb8358867 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt @@ -22,11 +22,16 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import java.util.concurrent.TimeoutException 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.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout /** * 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) } } + +/** + * 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 SendChannel.sendWithTimeout(element: E, timeout: Long = 10000) { + try { + withTimeout(timeout) { send(element) } + } catch (e: Exception) { + 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 ReceiveChannel.forEachWithTimeout( + timeout: Long = 10000, + action: suspend (E) -> Unit +) { + var exhausted = false + var subsequent = false + val handler: suspend () -> Unit = { + val value = receiveCatching() + if (value.isClosed) { + 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") + } + } +} From 08ca71b7b0a9d04e97cc1fab6736ec8be3569a1c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 19 Dec 2023 15:03:48 -0700 Subject: [PATCH 21/72] home: make playlist add a speed dial Add a speed dial menu that allows you to create a new playlist or import a playlist from elsewhere. --- app/build.gradle | 3 + .../auxio/home/FlipFloatingActionButton.kt | 117 -------- .../org/oxycblt/auxio/home/HomeFragment.kt | 120 +++++++- .../oxycblt/auxio/home/ThemedSpeedDialView.kt | 256 ++++++++++++++++++ .../oxycblt/auxio/music/MusicRepository.kt | 11 +- app/src/main/res/drawable/ic_import_24.xml | 11 + app/src/main/res/layout/fragment_home.xml | 40 ++- .../main/res/menu/new_playlist_actions.xml | 11 + app/src/main/res/values/strings.xml | 2 + 9 files changed, 427 insertions(+), 144 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt create mode 100644 app/src/main/res/drawable/ic_import_24.xml create mode 100644 app/src/main/res/menu/new_playlist_actions.xml diff --git a/app/build.gradle b/app/build.gradle index f414337b8..5d07581f0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -147,6 +147,9 @@ dependencies { // Logging implementation 'com.jakewharton.timber:timber:5.0.1' + // Speed dial + implementation "com.leinardi.android:speed-dial:3.3.0" + // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" diff --git a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt deleted file mode 100644 index c3cd4a82f..000000000 --- a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt +++ /dev/null @@ -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 . - */ - -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() - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 01558611d..e49c08a45 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -36,10 +36,12 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 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.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import java.lang.reflect.Field +import java.lang.reflect.Method import kotlin.math.abs import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R @@ -71,6 +73,7 @@ import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.lazyReflectedField +import org.oxycblt.auxio.util.lazyReflectedMethod import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.navigateSafe @@ -166,6 +169,35 @@ class HomeFragment : // re-creating the ViewPager. setupPager(binding) + binding.homeShuffleFab.setOnClickListener { + logD("Shuffling") + playbackModel.shuffleAll() + } + + binding.homeNewPlaylistFab.apply { + inflate(R.menu.new_playlist_actions) + setOnActionSelectedListener { action -> + when (action.id) { + R.id.action_new_playlist -> { + logD("Creating playlist") + musicModel.createPlaylist() + } + R.id.action_import_playlist -> { + TODO("Not implemented") + } + else -> {} + } + close() + true + } + } + + hideAllFabs() + updateFabVisibility( + homeModel.songList.value, + homeModel.isFastScrolling.value, + homeModel.currentTabType.value) + // --- VIEWMODEL SETUP --- collect(homeModel.recreateTabs.flow, ::handleRecreate) collectImmediately(homeModel.currentTabType, ::updateCurrentTab) @@ -291,17 +323,7 @@ class HomeFragment : MusicType.PLAYLISTS -> R.id.home_playlist_recycler } - if (tabType != MusicType.PLAYLISTS) { - logD("Flipping to shuffle button") - binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) { - playbackModel.shuffleAll() - } - } else { - logD("Flipping to playlist button") - binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) { - musicModel.createPlaylist() - } - } + updateFabVisibility(homeModel.songList.value, homeModel.isFastScrolling.value, tabType) } private fun handleRecreate(recreate: Unit?) { @@ -333,7 +355,10 @@ class HomeFragment : private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) { if (error == null) { logD("Received ok response") - binding.homeFab.show() + updateFabVisibility( + homeModel.songList.value, + homeModel.isFastScrolling.value, + homeModel.currentTabType.value) binding.homeIndexingContainer.visibility = View.INVISIBLE return } @@ -440,16 +465,75 @@ class HomeFragment : } private fun updateFab(songs: List, isFastScrolling: Boolean) { + updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value) + } + + private fun updateFabVisibility( + songs: List, + isFastScrolling: Boolean, + tabType: MusicType + ) { val binding = requireBinding() // 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 // popup to overlap with the FAB, so we hide the FAB when fast scrolling too. if (songs.isEmpty() || isFastScrolling) { logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]") - binding.homeFab.hide() + hideAllFabs() } else { - logD("Showing fab") - binding.homeFab.show() + if (tabType != MusicType.PLAYLISTS) { + 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 + } + + logD(binding.homeShuffleFab.isOrWillBeShown) + + 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) } } @@ -568,6 +652,12 @@ class HomeFragment : private companion object { val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") 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" } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt new file mode 100644 index 000000000..845bc617d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt @@ -0,0 +1,256 @@ +/* + * 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 . + */ + +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.Color +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.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.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 + + 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 { + setMargins(context.getDimenPixels(R.dimen.spacing_medium)) + } + useCompatPadding = false + } + val context = context + mainFabClosedBackgroundColor = + context + .getAttrColorCompat(com.google.android.material.R.attr.colorSecondaryContainer) + .defaultColor + mainFabClosedIconColor = + context + .getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondaryContainer) + .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() + } + } + }) + } + + 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 = Color.TRANSPARENT + 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) + .setLabelClickable(actionItem.isLabelClickable) + .setTheme(actionItem.theme) + .create() + return super.addActionItem(actionItem, position, animate)?.apply { + fab.apply { + updateLayoutParams { + val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large) + setMargins(horizontalMargin, 0, horizontalMargin, 0) + } + useCompatPadding = false + } + + labelBackground.apply { + useCompatPadding = false + setContentPadding(0, 0, 0, 0) + 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) + } + } + + companion object { + private val VIEW_PROPERTY_BACKGROUND_TINT = + object : Property(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(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(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 +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index c61c4c5ad..b223e28c8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -435,7 +435,8 @@ constructor( // 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 the main extractor loop. - incompleteSongs.close(e) + logE("MediaStore extraction failed: $e") + incompleteSongs.close(Exception("MediaStore extraction failed: e")) return@async } incompleteSongs.close() @@ -449,8 +450,8 @@ constructor( try { tagExtractor.consume(incompleteSongs, completeSongs) } catch (e: Exception) { - logD("Tag extraction failed: $e") - completeSongs.close(e) + logE("Tag extraction failed: $e") + completeSongs.close(Exception("Tag extraction failed: $e")) return@async } completeSongs.close() @@ -466,8 +467,8 @@ constructor( deviceLibraryFactory.create( completeSongs, processedSongs, separators, nameFactory) } catch (e: Exception) { - logD("DeviceLibrary creation failed: $e") - processedSongs.close(e) + logE("DeviceLibrary creation failed: $e") + processedSongs.close(Exception("DeviceLibrary creation failed: $e")) return@async Result.failure(e) } processedSongs.close() diff --git a/app/src/main/res/drawable/ic_import_24.xml b/app/src/main/res/drawable/ic_import_24.xml new file mode 100644 index 000000000..f63386171 --- /dev/null +++ b/app/src/main/res/drawable/ic_import_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index f1b5c8c80..d24c1a2d3 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -8,6 +8,7 @@ android:background="?attr/colorSurface" android:transitionGroup="true"> + @@ -147,18 +148,43 @@ + android:layout_gravity="bottom|end" + android:layout_height="match_parent"> - + + + app:sdMainFabAnimationRotateAngle="135" + android:clickable="true" + android:focusable="true" + android:gravity="bottom|end" + app:sdMainFabClosedSrc="@drawable/ic_add_24" + android:layout_gravity="bottom|end" + app:sdMainFabClosedIconColor="@android:color/white" + app:sdOverlayLayout="@+id/home_speed_dial_overlay" /> + + + + + + + diff --git a/app/src/main/res/menu/new_playlist_actions.xml b/app/src/main/res/menu/new_playlist_actions.xml new file mode 100644 index 000000000..7b9916426 --- /dev/null +++ b/app/src/main/res/menu/new_playlist_actions.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7866d184..f00389de8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,6 +88,8 @@ Playlist Playlists New playlist + Empty playlist + Imported playlist Rename Rename playlist Delete From 364675b252042497d30382cb61be732d700637b2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 19 Dec 2023 22:14:59 -0700 Subject: [PATCH 22/72] music: revamp paths Revamp paths with an entirely new abstraction that should improve testability and integration with M3U playlists. --- .../oxycblt/auxio/detail/SongDetailDialog.kt | 5 +- .../org/oxycblt/auxio/music/MusicSettings.kt | 16 +- .../auxio/music/device/DeviceMusicImpl.kt | 22 +- .../oxycblt/auxio/music/device/RawMusic.kt | 4 +- .../music/{fs => dirs}/DirectoryAdapter.kt | 55 ++-- .../auxio/music/dirs/DirectoryModule.kt | 31 +++ .../music/dirs/DocumentTreePathFactory.kt | 104 ++++++++ .../auxio/music/dirs/MusicDirectories.kt | 31 +++ .../music/{fs => dirs}/MusicDirsDialog.kt | 33 +-- .../java/org/oxycblt/auxio/music/fs/Fs.kt | 239 +++++++++++------- .../org/oxycblt/auxio/music/fs/FsModule.kt | 10 +- .../auxio/music/fs/MediaStoreExtractor.kt | 76 +++--- .../org/oxycblt/auxio/search/SearchEngine.kt | 2 +- .../main/res/layout/item_song_property.xml | 2 +- app/src/main/res/navigation/outer.xml | 2 +- app/src/main/res/values-ar-rIQ/strings.xml | 2 - app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-be/strings.xml | 2 - app/src/main/res/values-cs/strings.xml | 2 - app/src/main/res/values-de/strings.xml | 2 - app/src/main/res/values-el/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 2 - app/src/main/res/values-fi/strings.xml | 2 - app/src/main/res/values-fil/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 2 - app/src/main/res/values-gl/strings.xml | 2 - app/src/main/res/values-hi/strings.xml | 2 - app/src/main/res/values-hr/strings.xml | 2 - app/src/main/res/values-hu/strings.xml | 2 - app/src/main/res/values-in/strings.xml | 2 - app/src/main/res/values-it/strings.xml | 2 - app/src/main/res/values-iw/strings.xml | 2 - app/src/main/res/values-ja/strings.xml | 2 - app/src/main/res/values-ko/strings.xml | 2 - app/src/main/res/values-lt/strings.xml | 2 - app/src/main/res/values-ml/strings.xml | 1 - app/src/main/res/values-nb-rNO/strings.xml | 2 - app/src/main/res/values-nl/strings.xml | 2 - app/src/main/res/values-pa/strings.xml | 2 - app/src/main/res/values-pl/strings.xml | 2 - app/src/main/res/values-pt-rBR/strings.xml | 2 - app/src/main/res/values-pt-rPT/strings.xml | 2 - app/src/main/res/values-ro/strings.xml | 2 - app/src/main/res/values-ru/strings.xml | 2 - app/src/main/res/values-sl/strings.xml | 2 - app/src/main/res/values-sv/strings.xml | 2 - app/src/main/res/values-tr/strings.xml | 2 - app/src/main/res/values-uk/strings.xml | 2 - app/src/main/res/values-zh-rCN/strings.xml | 2 - app/src/main/res/values/strings.xml | 3 +- 50 files changed, 422 insertions(+), 277 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{fs => dirs}/DirectoryAdapter.kt (70%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/dirs/DocumentTreePathFactory.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt rename app/src/main/java/org/oxycblt/auxio/music/{fs => dirs}/MusicDirsDialog.kt (84%) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index f43da103c..d7c682ce6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -102,10 +102,7 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment { } } -class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context) : - Settings.Impl(context), MusicSettings { +class MusicSettingsImpl +@Inject +constructor( + @ApplicationContext context: Context, + val documentTreePathFactory: DocumentTreePathFactory +) : Settings.Impl(context), MusicSettings { private val storageManager = context.getSystemServiceCompat(StorageManager::class) override var musicDirs: MusicDirectories @@ -64,7 +68,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context val dirs = (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null) ?: emptySet()) - .mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) } + .mapNotNull(documentTreePathFactory::deserializeDocumentTreePath) return MusicDirectories( dirs, sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false)) @@ -73,7 +77,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context sharedPreferences.edit { putStringSet( getString(R.string.set_key_music_dirs), - value.dirs.map(Directory::toDocumentTreeUri).toSet()) + value.dirs.map(documentTreePathFactory::serializeDocumentTreePath).toSet()) putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude) apply() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index ec71efbf4..e96768db9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -28,7 +28,6 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Song 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.toCoverUri import org.oxycblt.auxio.music.info.Date @@ -85,14 +84,10 @@ class SongImpl( requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.fileName}: No id" } .toAudioUri() override val path = - Path( - name = + requireNotNull(rawSong.directory) { "Invalid raw ${rawSong.fileName}: No parent directory" } + .file( requireNotNull(rawSong.fileName) { "Invalid raw ${rawSong.fileName}: No display name" - }, - parent = - requireNotNull(rawSong.directory) { - "Invalid raw ${rawSong.fileName}: No parent directory" }) override val mimeType = MimeType( @@ -247,11 +242,11 @@ class SongImpl( * @return This instance upcasted to [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) { - "Malformed song ${path.name}: Artist grouping mismatch" + "Malformed song ${path}: Artist grouping mismatch" } for (i in _artists.indices) { // Non-destructively reorder the linked artists so that they align with @@ -262,10 +257,8 @@ class SongImpl( _artists[i] = other } - check(_genres.isNotEmpty()) { "Malformed song ${path.name}: No genres" } - check(_genres.size == rawGenres.size) { - "Malformed song ${path.name}: Genre grouping mismatch" - } + check(_genres.isNotEmpty()) { "Malformed song ${path}: No genres" } + check(_genres.size == rawGenres.size) { "Malformed song ${path}: Genre grouping mismatch" } for (i in _genres.indices) { // Non-destructively reorder the linked genres so that they align with // the genre ordering within the song metadata. @@ -519,6 +512,7 @@ class ArtistImpl( return this } } + /** * Library-backed implementation of [Genre]. * diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index 73fa3c753..1dea14722 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -22,7 +22,7 @@ import java.util.UUID import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Music 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.ReleaseType @@ -44,7 +44,7 @@ data class RawSong( /** @see Song.path */ var fileName: String? = null, /** @see Song.path */ - var directory: Directory? = null, + var directory: Path? = null, /** @see Song.size */ var size: Long? = null, /** @see Song.durationMs */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt similarity index 70% rename from app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt index 5e0799d72..9beedd79f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt @@ -16,13 +16,14 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.fs +package org.oxycblt.auxio.music.dirs import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemMusicDirBinding 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.inflater import org.oxycblt.auxio.util.logD @@ -35,11 +36,11 @@ import org.oxycblt.auxio.util.logD */ class DirectoryAdapter(private val listener: Listener) : RecyclerView.Adapter() { - private val _dirs = mutableListOf() + private val _dirs = mutableListOf() /** - * 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 = _dirs + val dirs: List = _dirs override fun getItemCount() = dirs.size @@ -50,37 +51,37 @@ class DirectoryAdapter(private val listener: 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) { - if (_dirs.contains(dir)) return - logD("Adding $dir") - _dirs.add(dir) + fun add(path: Path) { + if (_dirs.contains(path)) return + logD("Adding $path") + _dirs.add(path) 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) { - logD("Adding ${dirs.size} directories") - val oldLastIndex = dirs.lastIndex - _dirs.addAll(dirs) - notifyItemRangeInserted(oldLastIndex, dirs.size) + fun addAll(path: List) { + logD("Adding ${path.size} directories") + val oldLastIndex = path.lastIndex + _dirs.addAll(path) + 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) { - logD("Removing $dir") - val idx = _dirs.indexOf(dir) + fun remove(path: Path) { + logD("Removing $path") + val idx = _dirs.indexOf(path) _dirs.removeAt(idx) notifyItemRemoved(idx) } @@ -88,7 +89,7 @@ class DirectoryAdapter(private val listener: Listener) : /** A Listener for [DirectoryAdapter] interactions. */ interface Listener { /** Called when the delete button on a directory item is clicked. */ - fun onRemoveDirectory(dir: Directory) + fun onRemoveDirectory(dir: Path) } } @@ -102,12 +103,12 @@ class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBi /** * 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. */ - fun bind(dir: Directory, listener: DirectoryAdapter.Listener) { - binding.dirPath.text = dir.resolveName(binding.context) - binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(dir) } + fun bind(path: Path, listener: DirectoryAdapter.Listener) { + binding.dirPath.text = path.resolve(binding.context) + binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(path) } } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt new file mode 100644 index 000000000..f88d71449 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt @@ -0,0 +1,31 @@ +/* + * 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 . + */ + +package org.oxycblt.auxio.music.dirs + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DirectoryModule { + @Binds + fun documentTreePathFactory(factory: DocumentTreePathFactoryImpl): DocumentTreePathFactory +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/DocumentTreePathFactory.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/DocumentTreePathFactory.kt new file mode 100644 index 000000000..038e90b19 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/DocumentTreePathFactory.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 Auxio Project + * DocumentTreePathFactory.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 . + */ + +package org.oxycblt.auxio.music.dirs + +import android.net.Uri +import android.provider.DocumentsContract +import java.io.File +import javax.inject.Inject +import org.oxycblt.auxio.music.fs.Components +import org.oxycblt.auxio.music.fs.Path +import org.oxycblt.auxio.music.fs.Volume +import org.oxycblt.auxio.music.fs.VolumeManager + +/** + * A factory for parsing the reverse-engineered format of the URIs obtained from the document tree + * (i.e directory) folder. + * + * @author Alexander Capehart (OxygenCobalt) + */ +interface DocumentTreePathFactory { + /** + * Unpacks a document tree URI into a [Path] instance, using [deserializeDocumentTreePath]. + * + * @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 serializeDocumentTreePath(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 deserializeDocumentTreePath(path: String): Path? +} + +class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : + DocumentTreePathFactory { + 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 deserializeDocumentTreePath(treeUri) + } + + override fun serializeDocumentTreePath(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 deserializeDocumentTreePath(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.parse(relativePath)) + } + + private companion object { + const val DOCUMENT_URI_PRIMARY_NAME = "primary" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt new file mode 100644 index 000000000..c85b21ad5 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt @@ -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 . + */ + +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, val shouldInclude: Boolean) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt similarity index 84% rename from app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt index bca211f9a..1db3bb6c7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt @@ -16,13 +16,11 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.fs +package org.oxycblt.auxio.music.dirs import android.content.ActivityNotFoundException import android.net.Uri import android.os.Bundle -import android.os.storage.StorageManager -import android.provider.DocumentsContract import android.view.LayoutInflater import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts @@ -35,8 +33,8 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment -import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -50,7 +48,7 @@ class MusicDirsDialog : ViewBindingMaterialDialogFragment(), DirectoryAdapter.Listener { private val dirAdapter = DirectoryAdapter(this) private var openDocumentTreeLauncher: ActivityResultLauncher? = null - private var storageManager: StorageManager? = null + @Inject lateinit var documentTreePathFactory: DocumentTreePathFactory @Inject lateinit var musicSettings: MusicSettings override fun onCreateBinding(inflater: LayoutInflater) = @@ -70,10 +68,6 @@ class MusicDirsDialog : } override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) { - val context = requireContext() - val storageManager = - context.getSystemServiceCompat(StorageManager::class).also { storageManager = it } - openDocumentTreeLauncher = registerForActivityResult( ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs) @@ -107,9 +101,8 @@ class MusicDirsDialog : if (pendingDirs != null) { dirs = MusicDirectories( - pendingDirs.mapNotNull { - Directory.fromDocumentTreeUri(storageManager, it) - }, + pendingDirs.mapNotNull( + documentTreePathFactory::deserializeDocumentTreePath), savedInstanceState.getBoolean(KEY_PENDING_MODE)) } } @@ -133,18 +126,18 @@ class MusicDirsDialog : override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putStringArrayList( - KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map { it.toString() })) + KEY_PENDING_DIRS, + ArrayList(dirAdapter.dirs.map(documentTreePathFactory::serializeDocumentTreePath))) outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding())) } override fun onDestroyBinding(binding: DialogMusicDirsBinding) { super.onDestroyBinding(binding) - storageManager = null openDocumentTreeLauncher = null binding.dirsRecycler.adapter = null } - override fun onRemoveDirectory(dir: Directory) { + override fun onRemoveDirectory(dir: Path) { dirAdapter.remove(dir) requireBinding().dirsEmpty.isVisible = dirAdapter.dirs.isEmpty() } @@ -162,15 +155,7 @@ class MusicDirsDialog : return } - // 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) - val dir = - Directory.fromDocumentTreeUri( - requireNotNull(storageManager) { "StorageManager was not available" }, treeUri) + val dir = documentTreePathFactory.unpackDocumentTreeUri(uri) if (dir != null) { dirAdapter.add(dir) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 93a777a6a..494141fb7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -24,119 +24,174 @@ import android.os.storage.StorageManager import android.os.storage.StorageVolume import android.webkit.MimeTypeMap import java.io.File +import javax.inject.Inject import org.oxycblt.auxio.R +import org.oxycblt.auxio.util.logD /** - * A full absolute path to a file. Only intended for display purposes. For accessing files, URIs are - * preferred in all cases due to scoped storage limitations. + * An abstraction of an android file system path, including the volume and relative path. * - * @param name The name of the file. - * @param parent The parent [Directory] of the file. - * @author Alexander Capehart (OxygenCobalt) + * @param volume The volume that the path is on. + * @param components The components of the path of the file, relative to the root of the volume. */ -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 -/** - * A volume-aware relative path to a directory. - * - * @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) + /** The parent directory of the path, or itself if it's the root path. */ + val directory: Path + get() = Path(volume, components.parent()) + + override fun toString() = "Path(storageVolume=$volume, components=$components)" /** - * Converts this [Directory] instance into an opaque document tree path. This is a huge - * violation of the document 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 to - * directory. + * Transforms this [Path] into a "file" of the given name that's within the "directory" + * represented by the current path. Ex. "/storage/emulated/0/Music" -> + * "/storage/emulated/0/Music/file.mp3" * - * @return A URI [String] abiding by the document tree specification, or null if the [Directory] - * is not valid. + * @param fileName The name of the file to append to the path. + * @return The new [Path] instance. */ - fun toDocumentTreeUri() = - // 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" } - } + fun file(fileName: String) = Path(volume, components.child(fileName)) - override fun hashCode(): Int { - var result = volume.hashCode() - result = 31 * result + relativePath.hashCode() - return result - } + /** + * Resolves the [Path] in a human-readable format. + * + * @param context [Context] required to obtain human-readable strings. + */ + fun resolve(context: Context) = "${volume.resolveName(context)}/$components" +} - override fun equals(other: Any?) = - other is Directory && other.volume == volume && other.relativePath == relativePath +sealed interface Volume { + /** 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. */ - private const val DOCUMENT_URI_PRIMARY_NAME = "primary" + /** + * The components of the path to the volume, relative from the system root. Should not be used + * except for compatibility purposes. + */ + val components: Components? - /** - * Create a new directory instance from the given components. - * - * @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)) + /** Resolves the name of the volume in a human-readable format. */ + fun resolveName(context: Context): String - /** - * Create a new directory from a document tree URI. This is a huge violation of the document - * 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. - * - * @param storageManager [StorageManager] in order to obtain the [StorageVolume] specified - * in the given URI. - * @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) - } + /** A volume representing the device's internal storage. */ + interface Internal : Volume + + /** A volume representing an external storage device, identified by a UUID. */ + interface External : Volume { + /** The UUID of the volume. */ + val id: String? } } /** - * 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 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) + * @param components The components of the path. */ -data class MusicDirectories(val dirs: List, val shouldInclude: Boolean) +@JvmInline +value class Components private constructor(val components: List) { + /** The name of the file/directory. */ + val name: String? + get() = components.lastOrNull() + + override fun toString() = components.joinToString(File.separator) + + /** + * 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()).also { logD(it.components) } + } else { + this + } + + companion object { + /** + * Parses a path string into a [Components] instance by the system path separator. + * + * @param path The path string to parse. + * @return The [Components] instance. + */ + fun parse(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 +} + +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 class InternalVolumeImpl(val storageVolume: StorageVolume) : Volume.Internal { + override val mediaStoreName + get() = storageVolume.mediaStoreVolumeNameCompat + + override val components + get() = storageVolume.directoryCompat?.let(Components::parse) + + override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context) + } + + private 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::parse) + + override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context) + } +} /** * A mime type of a file. Only intended for display. diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index 828a468da..74703e8f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -19,16 +19,22 @@ package org.oxycblt.auxio.music.fs import android.content.Context +import android.os.storage.StorageManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import org.oxycblt.auxio.util.getSystemServiceCompat @Module @InstallIn(SingletonComponent::class) class FsModule { @Provides - fun mediaStoreExtractor(@ApplicationContext context: Context) = - MediaStoreExtractor.from(context) + fun volumeManager(@ApplicationContext context: Context): VolumeManager = + VolumeManagerImpl(context.getSystemServiceCompat(StorageManager::class)) + + @Provides + fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) = + MediaStoreExtractor.from(context, volumeManager) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index b25a360a7..1c0b58a7c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -21,7 +21,6 @@ package org.oxycblt.auxio.music.fs import android.content.Context import android.database.Cursor import android.os.Build -import android.os.storage.StorageManager import android.provider.MediaStore import androidx.annotation.RequiresApi import androidx.core.database.getIntOrNull @@ -31,10 +30,10 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield import org.oxycblt.auxio.music.cache.Cache 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.metadata.parseId3v2PositionField import org.oxycblt.auxio.music.metadata.transformPositionField -import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.sendWithTimeout @@ -93,13 +92,16 @@ interface MediaStoreExtractor { * Create a framework-backed instance. * * @param context [Context] required. + * @param volumeManager [VolumeManager] required. * @return A new [MediaStoreExtractor] that will work best on the device's API level. */ - fun from(context: Context): MediaStoreExtractor = + fun from(context: Context, volumeManager: VolumeManager): MediaStoreExtractor = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreExtractor(context) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreExtractor(context) - else -> Api21MediaStoreExtractor(context) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + Api30MediaStoreExtractor(context, volumeManager) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> + Api29MediaStoreExtractor(context, volumeManager) + else -> Api21MediaStoreExtractor(context, volumeManager) } } } @@ -249,15 +251,15 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) : protected abstract val dirSelectorTemplate: String /** - * Add a [Directory] to the given list of projection selector arguments. + * Add a [SystemPath] to the given list of projection selector arguments. * - * @param dir The [Directory] to add. + * @param path The [SystemPath] 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. + * given [SystemPath]. + * @return true if the [SystemPath] was added, false otherwise. * @see dirSelectorTemplate */ - protected abstract fun addDirToSelector(dir: Directory, args: MutableList): Boolean + protected abstract fun addDirToSelector(path: Path, args: MutableList): Boolean protected abstract fun wrapQuery( cursor: Cursor, @@ -362,7 +364,8 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) : // 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. -private class Api21MediaStoreExtractor(context: Context) : BaseMediaStoreExtractor(context) { +private class Api21MediaStoreExtractor(context: Context, private val volumeManager: VolumeManager) : + BaseMediaStoreExtractor(context) { override val projection: Array get() = super.projection + @@ -378,28 +381,27 @@ private class Api21MediaStoreExtractor(context: Context) : BaseMediaStoreExtract override val dirSelectorTemplate: String get() = "${MediaStore.Audio.Media.DATA} LIKE ?" - override fun addDirToSelector(dir: Directory, args: MutableList): Boolean { + override fun addDirToSelector(path: Path, args: MutableList): 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}%") + args.add("${path.volume.components ?: return false}${path.components}%") return true } override fun wrapQuery( cursor: Cursor, genreNamesMap: Map, - ): MediaStoreExtractor.Query = - Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class)) + ): MediaStoreExtractor.Query = Query(cursor, genreNamesMap, volumeManager) private class Query( cursor: Cursor, genreNamesMap: Map, - storageManager: StorageManager + volumeManager: VolumeManager ) : 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 + private val volumes = volumeManager.getVolumes() override fun populateFileInfo(rawSong: RawSong) { super.populateFileInfo(rawSong) @@ -417,10 +419,10 @@ private class Api21MediaStoreExtractor(context: Context) : BaseMediaStoreExtract // the Directory we will use. val rawPath = data.substringBeforeLast(File.separatorChar) for (volume in volumes) { - val volumePath = volume.directoryCompat ?: continue + val volumePath = (volume.components ?: continue).toString() val strippedPath = rawPath.removePrefix(volumePath) if (strippedPath != rawPath) { - rawSong.directory = Directory.from(volume, strippedPath) + rawSong.directory = Path(volume, Components.parse(strippedPath)) break } } @@ -466,26 +468,26 @@ private abstract class BaseApi29MediaStoreExtractor(context: Context) : "(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " + "AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)" - override fun addDirToSelector(dir: Directory, args: MutableList): Boolean { + override fun addDirToSelector(path: Path, args: MutableList): Boolean { // MediaStore uses a different naming scheme for it's volume column convert this // directory's volume to it. - args.add(dir.volume.mediaStoreVolumeNameCompat ?: return false) + args.add(path.volume.mediaStoreName ?: 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}%") + args.add("${path.components}%") return true } abstract class Query( cursor: Cursor, genreNamesMap: Map, - storageManager: StorageManager + private val volumeManager: VolumeManager ) : 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 + private val volumes = volumeManager.getVolumes() final override fun populateFileInfo(rawSong: RawSong) { super.populateFileInfo(rawSong) @@ -493,9 +495,9 @@ private abstract class BaseApi29MediaStoreExtractor(context: Context) : // 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 } + val volume = volumes.find { it.mediaStoreName == volumeName } if (volume != null) { - rawSong.directory = Directory.from(volume, relativePath) + rawSong.directory = Path(volume, Components.parse(relativePath)) } } } @@ -509,7 +511,8 @@ private abstract class BaseApi29MediaStoreExtractor(context: Context) : * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) -private class Api29MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) { +private class Api29MediaStoreExtractor(context: Context, private val volumeManager: VolumeManager) : + BaseApi29MediaStoreExtractor(context) { override val projection: Array get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) @@ -517,14 +520,13 @@ private class Api29MediaStoreExtractor(context: Context) : BaseApi29MediaStoreEx override fun wrapQuery( cursor: Cursor, genreNamesMap: Map - ): MediaStoreExtractor.Query = - Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class)) + ): MediaStoreExtractor.Query = Query(cursor, genreNamesMap, volumeManager) private class Query( cursor: Cursor, genreNamesMap: Map, - storageManager: StorageManager - ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) { + volumeManager: VolumeManager + ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, volumeManager) { private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) override fun populateTags(rawSong: RawSong) { @@ -549,7 +551,8 @@ private class Api29MediaStoreExtractor(context: Context) : BaseApi29MediaStoreEx * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.R) -private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) { +private class Api30MediaStoreExtractor(context: Context, private val volumeManager: VolumeManager) : + BaseApi29MediaStoreExtractor(context) { override val projection: Array get() = super.projection + @@ -562,14 +565,13 @@ private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreEx override fun wrapQuery( cursor: Cursor, genreNamesMap: Map - ): MediaStoreExtractor.Query = - Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class)) + ): MediaStoreExtractor.Query = Query(cursor, genreNamesMap, volumeManager) private class Query( cursor: Cursor, genreNamesMap: Map, - storageManager: StorageManager - ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) { + volumeManager: VolumeManager + ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, volumeManager) { private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER) private val discIndex = diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index 2c8d9158f..0e0944961 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -71,7 +71,7 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte return SearchEngine.Items( songs = 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), artists = items.artists?.searchListImpl(query), diff --git a/app/src/main/res/layout/item_song_property.xml b/app/src/main/res/layout/item_song_property.xml index 5334756bd..bc24a3a3e 100644 --- a/app/src/main/res/layout/item_song_property.xml +++ b/app/src/main/res/layout/item_song_property.xml @@ -9,7 +9,7 @@ android:paddingEnd="@dimen/spacing_medium" android:paddingTop="@dimen/spacing_small" android:paddingBottom="@dimen/spacing_small" - tools:hint="@string/lbl_file_name" + tools:hint="@string/lbl_path" app:expandedHintEnabled="false"> الغاء التنسيق الحجم - المسار إحصائيات المكتبة معدل البت - اسم الملف تجميع مباشر تجميعات خصائص الاغنية diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index ca6f6fafd..51bb12461 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -32,7 +32,6 @@ اذهب للفنان عرض والتحكم في تشغيل الموسيقى خلط - اسم الملف خلط الكل إلغاء حفظ diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index a17118fb0..97414d1ba 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -83,10 +83,8 @@ Чарга Перайсці да альбома Перайсці да выканаўцы - Імя файла Праглядзіце ўласцівасці Уласцівасці песні - Бацькоўскі шлях Фармат Перамяшаць усё Бітрэйт diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e39a84ca3..dd85dcc80 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -185,8 +185,6 @@ Přehrát ze zobrazené položky Zobrazit vlastnosti Vlastnosti skladby - Název souboru - Nadřazená cesta Formát Velikost Přenosová rychlost diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4c0fa5b55..02da0dab5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -179,8 +179,6 @@ Abtastrate Eigenschaften ansehen Lied-Eigenschaften - Dateiname - Elternpfad Format Größe Bitrate diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index b5730db8e..039c864ec 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -80,7 +80,6 @@ %d kbps Πρόσθεση Ιδιότητες τραγουδιού - Όνομα αρχείου Προβολή Ιδιοτήτων Στατιστικά συλλογής Ζωντανό άλμπουμ diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 20af977ec..747a2b559 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -194,7 +194,6 @@ Mixtapes (recopilación de canciones) Mixtape (recopilación de canciones) Remezclas - Nombre de archivo Siempre empezar la reproducción cuando se conecten auriculares (puede no funcionar en todos los dispositivos) Pre-amp ReplayGain El pre-amp se aplica al ajuste existente durante la reproducción @@ -215,7 +214,6 @@ Single remix Compilaciones EP de remixes - Directorio superior Eliminar el estado de reproducción guardado previamente (si existe) Abrir la cola Género diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 262cfde7b..0bbdde9ec 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -43,8 +43,6 @@ Siirry albumiin Näytä ominaisuudet Kappaleen ominaisuudet - Tiedostonimi - Ylätason polku Muoto Koko Bittitaajuus diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index d7e2bdc59..bc3ad9dc4 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -46,7 +46,6 @@ Puntahan ang album Tignan ang katangian Katangian ng kanta - Pangalan ng file Pormat Laki Tulin ng mga bit diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 742007eb0..11cf24113 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -82,8 +82,6 @@ Débit binaire Utiliser une autre action de notification Taux d\'échantillonnage - Chemin parent - Nom du fichier Tout mélanger Annuler Enregistrer diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index faf684102..d1a992526 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -53,11 +53,9 @@ Engadir á cola Excluir o que non é música Ir ao artista - Nome do arquivo Mesturar Mesturar todo Restablecer - Directorio superior Formato Tamaño Tasa de bits diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 84be232d8..265b733e5 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -61,14 +61,12 @@ एंड्रॉयड के लिए एक सीधा साधा, विवेकशील गाने बजाने वाला ऐप। नई प्लेलिस्ट अगला चलाएं - फ़ाइल का नाम लायब्रेरी टैब्स एल्बम से चलाएं सामग्री %d चयनित प्रारूप प्लेलिस्ट में जोड़ें - मुख्य पथ बिट-रेट रद्द करें सहेजें diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 22bab4d29..737a95181 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -40,8 +40,6 @@ Popis pjesama Reproduciraj sljedeću Svojstva pjesme - Naziv datoteke - Glavni direktorij Format Veličina Brzina prijenosa diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index c92aa6abf..ad272228a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -83,7 +83,6 @@ Keverés be/ki kapcsolása %s album borítója Visszajátszás - Szülő útvonal Mappa eltávolítása Lejátszólistához ad Formátum @@ -160,7 +159,6 @@ Inkább album, ha egyet játszik Zene frissítése Mégse - Fájl név Egyéni lejátszási sáv művelet A lejátszás mindig akkor indul el, ha a fejhallgató csatlakoztatva van (nem minden eszközön működik) Automatikus újratöltés diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 3b2486eb4..05c65a556 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -52,7 +52,6 @@ %d Album Properti lagu - Nama berkas Laju bit OK Batal @@ -65,7 +64,6 @@ Putar otomatis headset Selalu mulai memutar ketika headset tersambung (mungkin tidak berfungsi pada semua perangkat) Strategi ReplayGain - Path induk Ukuran Tingkat sampel Tab Pustaka diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1a4bf8938..68d67a9de 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -181,8 +181,6 @@ Frequenza di campionamento Vedi proprietà Proprietà brano - Nome file - Directory superiore Formato Dimensione Bitrate diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index d74cc9319..ecdb73c14 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -131,7 +131,6 @@ רצועה תור מעבר לאומן - שם קובץ ערבוב המצב שוחזר אודות @@ -242,7 +241,6 @@ תמונת רשימת השמעה עבור %s אדום ירוק - נתיב ראשי לא ניתן לשחזר את המצב רצועה %d יצירת רשימת השמעה חדשה diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 7c011f04e..e3b8dc43c 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -57,7 +57,6 @@ 表示されたアイテムから再生 再生停止 - ファイル名 追加した日付け サンプルレート 降順 @@ -245,7 +244,6 @@ 初期 (高速読み込み) 再生中の場合はアルバムを優先 複数値セパレータ - 親パス 変更されるたびに音楽ライブラリをリロードします (永続的な通知が必要です) サウンドと再生の動作を構成する 戻る前に巻き戻す diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0d9822a7f..97463dcae 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -163,7 +163,6 @@ 추가한 폴더에서만 음악을 불러옵니다. 곡 속성 속성 보기 - 파일 이름 샘플 속도 전송 속도 크기 @@ -195,7 +194,6 @@ 앰퍼샌드 (&) MPEG-1 오디오 추가한 날짜 - 상위 경로 맞춤형 재생 동작 버튼 반복 방식 대기열 열기 diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 94c41de55..da150c3ea 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -36,7 +36,6 @@ Maišyti Pridėtas į eilę Dainų ypatybės - Failo pavadinimas Išsaugoti Apie Pridėti @@ -214,7 +213,6 @@ DJ miksas Gyvai kompiliacija Remikso kompiliacija - Pirminis kelias Išvalyti anksčiau išsaugotą grojimo būseną (jei yra) Daugiareikšmiai separatoriai Pasvirasis brūkšnys (/) diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index f1ae5be53..b93ec52f9 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -25,7 +25,6 @@ വലിപ്പം ചേർക്കുക ശരി - ഉത്ഭവ പാത റദ്ദാക്കുക വെളിച്ചം കുറിച്ച് diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index c276bd0bd..230f1fe37 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -192,8 +192,6 @@ Format Vis egenskaper Spor-egenskaper - Filnavn - Overnevnt sti Pause ved gjentagelse Rød diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index c0ecc5905..8c760d532 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -121,9 +121,7 @@ Annuleren Bibliotheek tabbladen Jaar - Ouderpad Lied eigenschappen - Bestandsnaam Voorkeur album als er een speelt Voorkeur titel Voorkeur album diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index c33f1e852..2f029b2ab 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -55,7 +55,6 @@ ਐਲਬਮ \'ਤੇ ਜਾਓ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਵੇਖੋ ਗੀਤ ਦੀਆਂ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ - ਪੇਰੈਂਟ ਮਾਰਗ ਫਾਰਮੈਟ ਆਕਾਰ ਸ਼ਫਲ @@ -76,7 +75,6 @@ ਗੀਤ ਦੀ ਗਿਣਤੀ ਘਟਦੇ ਹੋਏ ਕਲਾਕਾਰ \'ਤੇ ਜਾਓ - ਫਾਈਲ ਦਾ ਨਾਮ ਬਿੱਟ ਰੇਟ ਸੈਂਪਲ ਰੇਟ ਸ਼ਾਮਿਲ ਕਰੋ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 737838fe6..fa4993f8d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -90,8 +90,6 @@ Autoodtwarzanie w słuchawkach Morski +%.1f dB - Nazwa pliku - Ścieżka katalogu Format Niebieskozielony Płyta %d diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6716a415a..366aa1e2e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -106,7 +106,6 @@ Artista Álbum Propriedades da música - Nome do arquivo Formato Tamanho Taxa de bits @@ -158,7 +157,6 @@ Ajuste em faixas sem metadados Reprodução automática em fones de ouvido Pré-amplificação da normalização de volume - Caminho principal OK Exibição Ativa cantos arredondados em elementos adicionais da interface do usuário diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index e02dbbe6c..2fa1582f3 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -115,7 +115,6 @@ Taxa de amostragem Salvar Separadores multi-valor - Nome do ficheiro Tamanho Propriedades Propriedades da música @@ -217,7 +216,6 @@ A carregar a sua biblioteca de músicas… (%1$d/%2$d) Retroceder antes de voltar Parar reprodução - Caminho principal Ativar cantos arredondados em elementos adicionais da interface do utilizador (requer que as capas dos álbuns sejam arredondadas) %d Selecionadas Misturas DJ diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index e892e2739..3ad8ebd47 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -103,10 +103,8 @@ Egalizator Bit rate Data adăugării - Calea principală Format Proprietățile cântecului - Numele fișierului Amestecare Adaugă Frecvența de eșantionare diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4859680ad..268cd49b1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -157,7 +157,6 @@ Внимание: Изменение предусиления на большое положительное значение может привести к появлению искажений на некоторых звуковых дорожках. Сведения Свойства трека - Путь Формат Размер Частота дискретизации @@ -165,7 +164,6 @@ Статистика библиотеки Восстановить состояние воспроизведения Продолжительность - Имя файла Мини-альбом Мини-альбомы Сингл diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 5d8da013d..1cbd74549 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -59,7 +59,6 @@ Shrani trenutno stanje predvajanja zdaj Preskoči na zadnjo pesem Ponovno naloži glasbeno knjižnico vsakič, ko se zazna sprememba (zahteva vztrajno obvestilo) - Pot do datoteke %d pesem %d pesmi @@ -79,7 +78,6 @@ Mešanice Izvajalec Pravilno razvrsti imena, ki se začnejo z številkami ali besedami, kot so \'the\' (najbolje deluje z angleško glasbo) - Ime datoteke Zelenkasto modra Vztrajnost Premešaj vse pesmi diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 16af4e8f1..2c9a072cb 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -52,7 +52,6 @@ Visa egenskaper Dela Egenskaper för låt - Överordnad mapp Format Storlek Samplingsfrekvens @@ -99,7 +98,6 @@ Disk Sortera Lägg till kö - Filnamn Lägg till Tillstånd tog bort Bithastighet diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 5e77b99ab..68997f497 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -52,10 +52,8 @@ %d albüm %d albümler - Dosya adı Özellikleri görüntüle Şarkı özellikleri - Ana yol Biçim Karıştır Hepsini karıştır diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 2164136ed..e2b1c3891 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -54,7 +54,6 @@ %d альбомів %d альбомів - Ім\'я файлу Формат Добре Скасувати @@ -79,7 +78,6 @@ Збірки Збірка Концертний альбом - Шлях до каталогу Екран Рік Обкладинки альбомів diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f3742f1d0..e7072c57d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -176,8 +176,6 @@ 音轨 查看属性 曲目属性 - 文件名 - 上级目录 格式 大小 比特率 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f00389de8..61ed0e770 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -135,8 +135,7 @@ Share Song properties - File name - Parent path + Path Format From fff8212b0a446ec2645136d27b718f048684c2d4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 11:02:01 -0700 Subject: [PATCH 23/72] music: add importing backend Add basic importing infrastructure and an M3U parser to the backend. --- .../auxio/music/fs/ContentPathResolver.kt | 111 ++++++++++++++++++ .../org/oxycblt/auxio/music/fs/FsModule.kt | 3 + .../auxio/music/import/ImportModule.kt | 32 +++++ .../org/oxycblt/auxio/music/import/M3U.kt | 73 ++++++++++++ .../auxio/music/import/PlaylistImporter.kt | 33 ++++++ 5 files changed, 252 insertions(+) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/import/ImportModule.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/import/PlaylistImporter.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt new file mode 100644 index 000000000..84db744c8 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt @@ -0,0 +1,111 @@ +package org.oxycblt.auxio.music.fs + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.core.database.getStringOrNull +import org.oxycblt.auxio.util.logE +import org.oxycblt.auxio.util.logW +import javax.inject.Inject + +/** + * Resolves a content URI into a [Path] instance. + * TODO: Integrate this with [MediaStoreExtractor]. + * @author Alexander Capehart (OxygenCobalt) + */ +interface ContentPathResolver { + /** + * Resolve a content [Uri] into it's corresponding [Path]. + * @param uri The content [Uri] to resolve. + * @return The corresponding [Path], or null if the [Uri] is invalid. + */ + fun resolve(uri: Uri): Path? + + companion object { + fun from(context: Context, volumeManager: VolumeManager) = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> + Api29ContentPathResolverImpl(context.contentResolverSafe, volumeManager) + + else -> Api21ContentPathResolverImpl(context.contentResolverSafe, volumeManager) + } + } +} + +private class Api21ContentPathResolverImpl( + private val contentResolver: ContentResolver, + private val volumeManager: VolumeManager +) : ContentPathResolver { + override fun resolve(uri: Uri): Path? { + val rawPath = contentResolver.useQuery( + uri, arrayOf(MediaStore.MediaColumns.DATA) + ) { cursor -> + cursor.moveToFirst() + cursor.getStringOrNull(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)) + } + + if (rawPath == null) { + logE("No data available for uri $uri") + return null + } + + val volumes = volumeManager.getVolumes() + for (volume in volumes) { + val volumePath = (volume.components ?: continue).toString() + val strippedPath = rawPath.removePrefix(volumePath) + if (strippedPath != rawPath) { + return Path(volume, Components.parse(strippedPath)) + } + } + + logE("No volume found for uri $uri") + return null + } +} + +private class Api29ContentPathResolverImpl( + private val contentResolver: ContentResolver, + private val volumeManager: VolumeManager +) : ContentPathResolver { + private data class RawPath(val volumeName: String?, val relativePath: String?) + + override fun resolve(uri: Uri): Path? { + val rawPath = contentResolver.useQuery( + uri, arrayOf( + MediaStore.MediaColumns.VOLUME_NAME, + MediaStore.MediaColumns.RELATIVE_PATH + ) + ) { cursor -> + cursor.moveToFirst() + RawPath( + cursor.getStringOrNull( + cursor.getColumnIndexOrThrow( + MediaStore.MediaColumns.VOLUME_NAME + ) + ), + cursor.getStringOrNull( + cursor.getColumnIndexOrThrow( + MediaStore.MediaColumns.RELATIVE_PATH + ) + ) + ) + } + + if (rawPath.volumeName == null || rawPath.relativePath == null) { + logE("No data available for uri $uri (raw path obtained: $rawPath)") + return null + } + + // Find the StorageVolume whose MediaStore name corresponds to this song. + // This is combined with the plain relative path column to create the directory. + val volume = volumeManager.getVolumes().find { it.mediaStoreName == rawPath.volumeName } + if (volume != null) { + return Path(volume, Components.parse(rawPath.relativePath)) + } + + logE("No volume found for uri $uri") + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index 74703e8f0..13d0a2d2a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -37,4 +37,7 @@ class FsModule { @Provides fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) = MediaStoreExtractor.from(context, volumeManager) + + @Provides + fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) = ContentPathResolver.from(context, volumeManager) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/import/ImportModule.kt b/app/src/main/java/org/oxycblt/auxio/music/import/ImportModule.kt new file mode 100644 index 000000000..4473ed9eb --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/import/ImportModule.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Auxio Project + * ForeignModule.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 . + */ + +package org.oxycblt.auxio.music.import + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface ImportModule { + @Binds fun playlistImporter(playlistImporter: PlaylistImporterImpl): PlaylistImporter + + @Binds fun m3u(m3u: M3UImpl): M3U +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt new file mode 100644 index 000000000..ac897c85d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt @@ -0,0 +1,73 @@ +/* + * 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 . + */ + +package org.oxycblt.auxio.music.import + +import org.oxycblt.auxio.music.fs.Components +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import org.oxycblt.auxio.music.fs.Path +import org.oxycblt.auxio.util.logW +import javax.inject.Inject + +interface M3U { + fun read(stream: InputStream, workingDirectory: Path): List? +} + +class M3UImpl @Inject constructor() : M3U { + override fun read(stream: InputStream, workingDirectory: Path): List? { + val reader = BufferedReader(InputStreamReader(stream)) + val media = mutableListOf() + + consumeFile@ while (true) { + collectMetadata@ while (true) { + val line = reader.readLine() ?: break@consumeFile + if (!line.startsWith("#")) { + break@collectMetadata + } + } + + val path = reader.readLine() + if (path == null) { + logW("Expected a path, instead got an EOF") + break@consumeFile + } + + val relativeComponents = Components.parse(path) + val absoluteComponents = + resolveRelativePath(relativeComponents, workingDirectory.components) + + media.add(Path(workingDirectory.volume, absoluteComponents)) + } + + return media.ifEmpty { null } + } + + private fun resolveRelativePath(relative: Components, workingDirectory: Components): Components { + var components = workingDirectory + for (component in relative.components) { + when (component) { + ".." -> components = components.parent() + "." -> {} + else -> components = components.child(component) + } + } + return components + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/import/PlaylistImporter.kt b/app/src/main/java/org/oxycblt/auxio/music/import/PlaylistImporter.kt new file mode 100644 index 000000000..a2c2bc7df --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/import/PlaylistImporter.kt @@ -0,0 +1,33 @@ +package org.oxycblt.auxio.music.import + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.fs.ContentPathResolver +import org.oxycblt.auxio.music.fs.Path +import org.oxycblt.auxio.music.fs.contentResolverSafe +import javax.inject.Inject + +interface PlaylistImporter { + suspend fun import(uri: Uri): ImportedPlaylist? +} + +data class ImportedPlaylist(val name: String?, val paths: List) + +class PlaylistImporterImpl @Inject constructor( + @ApplicationContext private val contentResolver: ContentResolver, + private val contentPathResolver: ContentPathResolver, + private val m3u: M3U +) : PlaylistImporter { + override suspend fun import(uri: Uri): ImportedPlaylist? { + val workingDirectory = contentPathResolver.resolve(uri) ?: return null + return contentResolver.openInputStream(uri)?.use { + val paths = m3u.read(it, workingDirectory) ?: return null + return ImportedPlaylist(null, paths) + } + } +} \ No newline at end of file From 88bce610ca99c3b1213eb6d5293ac6cd4286a97e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 11:03:32 -0700 Subject: [PATCH 24/72] music: connect playlist importing to frontend --- .../org/oxycblt/auxio/music/MusicViewModel.kt | 26 +++++++++++++++++++ .../auxio/music/device/DeviceLibrary.kt | 12 +++++++++ .../java/org/oxycblt/auxio/music/fs/Fs.kt | 6 ++--- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 314b38785..5d7568c71 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -18,6 +18,7 @@ package org.oxycblt.auxio.music +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -27,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.music.import.PlaylistImporter import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.logD @@ -42,6 +44,7 @@ class MusicViewModel constructor( private val listSettings: ListSettings, private val musicRepository: MusicRepository, + private val playlistImporter: PlaylistImporter ) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { private val _indexingState = MutableStateFlow(null) @@ -61,6 +64,10 @@ constructor( val playlistDecision: Event get() = _playlistDecision + private val _importError = MutableEvent() + /** Flag for when playlist importing failed. Consume this and show an error if active. */ + val importError: Event get() = _importError + init { musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -116,6 +123,25 @@ constructor( } } + /** + * Import a playlist from a file [Uri]. Errors pushed to [importError]. + * @param uri The [Uri] of the file to import. + * @see PlaylistImporter + */ + fun importPlaylist(uri: Uri) = + viewModelScope.launch(Dispatchers.IO) { + val importedPlaylist = playlistImporter.import(uri) + if (importedPlaylist == null) { + _importError.put(Unit) + return@launch + } + + val deviceLibrary = musicRepository.deviceLibrary ?: return@launch + val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) + + createPlaylist(importedPlaylist.name, songs) + } + /** * Rename the given playlist. * diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 389b34fb6..09ac80aef 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository 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.useQuery import org.oxycblt.auxio.music.info.Name @@ -74,6 +75,14 @@ interface DeviceLibrary { */ 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]. * @@ -266,6 +275,7 @@ class DeviceLibraryImpl( ) : DeviceLibrary { // 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 songPathMap = buildMap { songs.forEach { put(it.path, it) } } private val albumUidMap = buildMap { albums.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()) } } @@ -287,6 +297,8 @@ class DeviceLibraryImpl( override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid] + override fun findSongByPath(path: Path) = songPathMap[path] + override fun findSongForUri(context: Context, uri: Uri) = context.contentResolverSafe.useQuery( uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor -> diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 494141fb7..1f152ed7e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -46,8 +46,6 @@ data class Path( val directory: Path get() = Path(volume, components.parent()) - override fun toString() = "Path(storageVolume=$volume, components=$components)" - /** * Transforms this [Path] into a "file" of the given name that's within the "directory" * represented by the current path. Ex. "/storage/emulated/0/Music" -> @@ -169,7 +167,7 @@ class VolumeManagerImpl @Inject constructor(private val storageManager: StorageM } } - private class InternalVolumeImpl(val storageVolume: StorageVolume) : Volume.Internal { + private data class InternalVolumeImpl(val storageVolume: StorageVolume) : Volume.Internal { override val mediaStoreName get() = storageVolume.mediaStoreVolumeNameCompat @@ -179,7 +177,7 @@ class VolumeManagerImpl @Inject constructor(private val storageManager: StorageM override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context) } - private class ExternalVolumeImpl(val storageVolume: StorageVolume) : Volume.External { + private data class ExternalVolumeImpl(val storageVolume: StorageVolume) : Volume.External { override val id get() = storageVolume.uuidCompat From e553744c8e7f18d05a27d7c0cd8bdb66187611a4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 11:59:48 -0700 Subject: [PATCH 25/72] music: rename import to external Apparently hilt doesn't like a module with the name import, probably because of a keyword conflict. --- .../org/oxycblt/auxio/music/MusicViewModel.kt | 6 +- .../ExternalModule.kt} | 6 +- .../auxio/music/{import => external}/M3U.kt | 11 +-- .../{import => external}/PlaylistImporter.kt | 33 ++++++--- .../auxio/music/fs/ContentPathResolver.kt | 72 +++++++++++-------- .../org/oxycblt/auxio/music/fs/FsModule.kt | 3 +- 6 files changed, 81 insertions(+), 50 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{import/ImportModule.kt => external/ExternalModule.kt} (90%) rename app/src/main/java/org/oxycblt/auxio/music/{import => external}/M3U.kt (93%) rename app/src/main/java/org/oxycblt/auxio/music/{import => external}/PlaylistImporter.kt (52%) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 5d7568c71..3b41b445c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.oxycblt.auxio.list.ListSettings -import org.oxycblt.auxio.music.import.PlaylistImporter +import org.oxycblt.auxio.music.external.PlaylistImporter import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.logD @@ -66,7 +66,8 @@ constructor( private val _importError = MutableEvent() /** Flag for when playlist importing failed. Consume this and show an error if active. */ - val importError: Event get() = _importError + val importError: Event + get() = _importError init { musicRepository.addUpdateListener(this) @@ -125,6 +126,7 @@ constructor( /** * Import a playlist from a file [Uri]. Errors pushed to [importError]. + * * @param uri The [Uri] of the file to import. * @see PlaylistImporter */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/import/ImportModule.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt similarity index 90% rename from app/src/main/java/org/oxycblt/auxio/music/import/ImportModule.kt rename to app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt index 4473ed9eb..a1824fadc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/import/ImportModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * ForeignModule.kt is part of Auxio. + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.import +package org.oxycblt.auxio.music.external import dagger.Binds import dagger.Module @@ -25,7 +25,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -interface ImportModule { +interface ExternalModule { @Binds fun playlistImporter(playlistImporter: PlaylistImporterImpl): PlaylistImporter @Binds fun m3u(m3u: M3UImpl): M3U diff --git a/app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt similarity index 93% rename from app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt rename to app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index ac897c85d..19b0aa0ec 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -16,15 +16,15 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.import +package org.oxycblt.auxio.music.external -import org.oxycblt.auxio.music.fs.Components import java.io.BufferedReader import java.io.InputStream import java.io.InputStreamReader +import javax.inject.Inject +import org.oxycblt.auxio.music.fs.Components import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.util.logW -import javax.inject.Inject interface M3U { fun read(stream: InputStream, workingDirectory: Path): List? @@ -59,7 +59,10 @@ class M3UImpl @Inject constructor() : M3U { return media.ifEmpty { null } } - private fun resolveRelativePath(relative: Components, workingDirectory: Components): Components { + private fun resolveRelativePath( + relative: Components, + workingDirectory: Components + ): Components { var components = workingDirectory for (component in relative.components) { when (component) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/import/PlaylistImporter.kt b/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt similarity index 52% rename from app/src/main/java/org/oxycblt/auxio/music/import/PlaylistImporter.kt rename to app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt index a2c2bc7df..dcaad62ae 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/import/PlaylistImporter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt @@ -1,16 +1,29 @@ -package org.oxycblt.auxio.music.import +/* + * Copyright (c) 2023 Auxio Project + * PlaylistImporter.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 . + */ + +package org.oxycblt.auxio.music.external import android.content.ContentResolver -import android.content.Context import android.net.Uri import dagger.hilt.android.qualifiers.ApplicationContext -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary +import javax.inject.Inject import org.oxycblt.auxio.music.fs.ContentPathResolver import org.oxycblt.auxio.music.fs.Path -import org.oxycblt.auxio.music.fs.contentResolverSafe -import javax.inject.Inject interface PlaylistImporter { suspend fun import(uri: Uri): ImportedPlaylist? @@ -18,7 +31,9 @@ interface PlaylistImporter { data class ImportedPlaylist(val name: String?, val paths: List) -class PlaylistImporterImpl @Inject constructor( +class PlaylistImporterImpl +@Inject +constructor( @ApplicationContext private val contentResolver: ContentResolver, private val contentPathResolver: ContentPathResolver, private val m3u: M3U @@ -30,4 +45,4 @@ class PlaylistImporterImpl @Inject constructor( return ImportedPlaylist(null, paths) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt index 84db744c8..e72450450 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2023 Auxio Project + * ContentPathResolver.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 . + */ + package org.oxycblt.auxio.music.fs import android.content.ContentResolver @@ -7,17 +25,18 @@ import android.os.Build import android.provider.MediaStore import androidx.core.database.getStringOrNull import org.oxycblt.auxio.util.logE -import org.oxycblt.auxio.util.logW -import javax.inject.Inject /** * Resolves a content URI into a [Path] instance. - * TODO: Integrate this with [MediaStoreExtractor]. + * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Integrate this with [MediaStoreExtractor]. */ interface ContentPathResolver { /** * Resolve a content [Uri] into it's corresponding [Path]. + * * @param uri The content [Uri] to resolve. * @return The corresponding [Path], or null if the [Uri] is invalid. */ @@ -28,7 +47,6 @@ interface ContentPathResolver { when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29ContentPathResolverImpl(context.contentResolverSafe, volumeManager) - else -> Api21ContentPathResolverImpl(context.contentResolverSafe, volumeManager) } } @@ -39,12 +57,11 @@ private class Api21ContentPathResolverImpl( private val volumeManager: VolumeManager ) : ContentPathResolver { override fun resolve(uri: Uri): Path? { - val rawPath = contentResolver.useQuery( - uri, arrayOf(MediaStore.MediaColumns.DATA) - ) { cursor -> - cursor.moveToFirst() - cursor.getStringOrNull(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)) - } + val rawPath = + contentResolver.useQuery(uri, arrayOf(MediaStore.MediaColumns.DATA)) { cursor -> + cursor.moveToFirst() + cursor.getStringOrNull(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)) + } if (rawPath == null) { logE("No data available for uri $uri") @@ -72,26 +89,19 @@ private class Api29ContentPathResolverImpl( private data class RawPath(val volumeName: String?, val relativePath: String?) override fun resolve(uri: Uri): Path? { - val rawPath = contentResolver.useQuery( - uri, arrayOf( - MediaStore.MediaColumns.VOLUME_NAME, - MediaStore.MediaColumns.RELATIVE_PATH - ) - ) { cursor -> - cursor.moveToFirst() - RawPath( - cursor.getStringOrNull( - cursor.getColumnIndexOrThrow( - MediaStore.MediaColumns.VOLUME_NAME - ) - ), - cursor.getStringOrNull( - cursor.getColumnIndexOrThrow( - MediaStore.MediaColumns.RELATIVE_PATH - ) - ) - ) - } + val rawPath = + contentResolver.useQuery( + uri, + arrayOf( + MediaStore.MediaColumns.VOLUME_NAME, MediaStore.MediaColumns.RELATIVE_PATH)) { + cursor -> + cursor.moveToFirst() + RawPath( + cursor.getStringOrNull( + cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.VOLUME_NAME)), + cursor.getStringOrNull( + cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))) + } if (rawPath.volumeName == null || rawPath.relativePath == null) { logE("No data available for uri $uri (raw path obtained: $rawPath)") @@ -108,4 +118,4 @@ private class Api29ContentPathResolverImpl( logE("No volume found for uri $uri") return null } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index 13d0a2d2a..6298e98e1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -39,5 +39,6 @@ class FsModule { MediaStoreExtractor.from(context, volumeManager) @Provides - fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) = ContentPathResolver.from(context, volumeManager) + fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) = + ContentPathResolver.from(context, volumeManager) } From 2195431c66ae339df410be1350dbb5558bec442c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 12:12:01 -0700 Subject: [PATCH 26/72] music: dont inject contentresolver I don't know why, but I can't inject it without causing errors. --- .../org/oxycblt/auxio/music/external/PlaylistImporter.kt | 7 ++++--- app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt b/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt index dcaad62ae..a9177e8bc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt @@ -18,12 +18,13 @@ package org.oxycblt.auxio.music.external -import android.content.ContentResolver +import android.content.Context import android.net.Uri import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.music.fs.ContentPathResolver import org.oxycblt.auxio.music.fs.Path +import org.oxycblt.auxio.music.fs.contentResolverSafe interface PlaylistImporter { suspend fun import(uri: Uri): ImportedPlaylist? @@ -34,13 +35,13 @@ data class ImportedPlaylist(val name: String?, val paths: List) class PlaylistImporterImpl @Inject constructor( - @ApplicationContext private val contentResolver: ContentResolver, + @ApplicationContext private val context: Context, private val contentPathResolver: ContentPathResolver, private val m3u: M3U ) : PlaylistImporter { override suspend fun import(uri: Uri): ImportedPlaylist? { val workingDirectory = contentPathResolver.resolve(uri) ?: return null - return contentResolver.openInputStream(uri)?.use { + return context.contentResolverSafe.openInputStream(uri)?.use { val paths = m3u.read(it, workingDirectory) ?: return null return ImportedPlaylist(null, paths) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index 6298e98e1..a22c7675e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -18,6 +18,7 @@ package org.oxycblt.auxio.music.fs +import android.content.ContentResolver import android.content.Context import android.os.storage.StorageManager import dagger.Module @@ -41,4 +42,8 @@ class FsModule { @Provides fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) = ContentPathResolver.from(context, volumeManager) + + @Provides + fun contentResolver(@ApplicationContext context: Context): ContentResolver = + context.contentResolverSafe } From 634ff0d8233c4602c9d0984a43b1ea149e178fcf Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 12:13:19 -0700 Subject: [PATCH 27/72] home: add playlist import flow Connect the playlist importing system to the home view's playlist add button. --- .../org/oxycblt/auxio/home/HomeFragment.kt | 61 ++++++--- .../auxio/music/fs/ContentPathResolver.kt | 121 ------------------ .../DocumentPathFactory.kt} | 26 ++-- 3 files changed, 64 insertions(+), 144 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt rename app/src/main/java/org/oxycblt/auxio/music/{dirs/DocumentTreePathFactory.kt => fs/DocumentPathFactory.kt} (84%) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index e49c08a45..8d75a5a1e 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -39,6 +39,8 @@ 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.transition.MaterialSharedAxis +import com.leinardi.android.speeddial.SpeedDialActionItem +import com.leinardi.android.speeddial.SpeedDialView import dagger.hilt.android.AndroidEntryPoint import java.lang.reflect.Field import java.lang.reflect.Method @@ -77,6 +79,7 @@ import org.oxycblt.auxio.util.lazyReflectedMethod import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW 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 @@ -86,13 +89,16 @@ import org.oxycblt.auxio.util.navigateSafe */ @AndroidEntryPoint class HomeFragment : - SelectionFragment(), AppBarLayout.OnOffsetChangedListener { + SelectionFragment(), + AppBarLayout.OnOffsetChangedListener, + SpeedDialView.OnActionSelectedListener { override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() private var storagePermissionLauncher: ActivityResultLauncher? = null + private var filePickerLauncher: ActivityResultLauncher? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -121,6 +127,17 @@ class HomeFragment : musicModel.refresh() } + filePickerLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) { + logW("No URI returned from file picker") + return@registerForActivityResult + } + + logD("Received playlist URI $uri") + musicModel.importPlaylist(uri) + } + // --- UI SETUP --- binding.homeAppbar.addOnOffsetChangedListener(this) binding.homeNormalToolbar.apply { @@ -176,20 +193,7 @@ class HomeFragment : binding.homeNewPlaylistFab.apply { inflate(R.menu.new_playlist_actions) - setOnActionSelectedListener { action -> - when (action.id) { - R.id.action_new_playlist -> { - logD("Creating playlist") - musicModel.createPlaylist() - } - R.id.action_import_playlist -> { - TODO("Not implemented") - } - else -> {} - } - close() - true - } + setOnActionSelectedListener(this@HomeFragment) } hideAllFabs() @@ -206,6 +210,7 @@ class HomeFragment : collectImmediately(listModel.selected, ::updateSelection) collectImmediately(musicModel.indexingState, ::updateIndexerState) collect(musicModel.playlistDecision.flow, ::handleDecision) + collectImmediately(musicModel.importError.flow, ::handleImportError) collect(detailModel.toShow.flow, ::handleShow) } @@ -223,6 +228,7 @@ class HomeFragment : storagePermissionLauncher = null binding.homeAppbar.removeOnOffsetChangedListener(this) binding.homeNormalToolbar.setOnMenuItemClickListener(null) + binding.homeNewPlaylistFab.setOnActionSelectedListener(null) } override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { @@ -281,6 +287,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") + filePickerLauncher?.launch("audio/x-mpegurl") + } + 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) { binding.homePager.adapter = HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner) @@ -464,6 +488,13 @@ class HomeFragment : findNavController().navigateSafe(directions) } + private fun handleImportError(flag: Unit?) { + if (flag != null) { + requireContext().showToast(R.string.err_import_failed) + musicModel.importError.consume() + } + } + private fun updateFab(songs: List, isFastScrolling: Boolean) { updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt deleted file mode 100644 index e72450450..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * ContentPathResolver.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 . - */ - -package org.oxycblt.auxio.music.fs - -import android.content.ContentResolver -import android.content.Context -import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import androidx.core.database.getStringOrNull -import org.oxycblt.auxio.util.logE - -/** - * Resolves a content URI into a [Path] instance. - * - * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Integrate this with [MediaStoreExtractor]. - */ -interface ContentPathResolver { - /** - * Resolve a content [Uri] into it's corresponding [Path]. - * - * @param uri The content [Uri] to resolve. - * @return The corresponding [Path], or null if the [Uri] is invalid. - */ - fun resolve(uri: Uri): Path? - - companion object { - fun from(context: Context, volumeManager: VolumeManager) = - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> - Api29ContentPathResolverImpl(context.contentResolverSafe, volumeManager) - else -> Api21ContentPathResolverImpl(context.contentResolverSafe, volumeManager) - } - } -} - -private class Api21ContentPathResolverImpl( - private val contentResolver: ContentResolver, - private val volumeManager: VolumeManager -) : ContentPathResolver { - override fun resolve(uri: Uri): Path? { - val rawPath = - contentResolver.useQuery(uri, arrayOf(MediaStore.MediaColumns.DATA)) { cursor -> - cursor.moveToFirst() - cursor.getStringOrNull(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)) - } - - if (rawPath == null) { - logE("No data available for uri $uri") - return null - } - - val volumes = volumeManager.getVolumes() - for (volume in volumes) { - val volumePath = (volume.components ?: continue).toString() - val strippedPath = rawPath.removePrefix(volumePath) - if (strippedPath != rawPath) { - return Path(volume, Components.parse(strippedPath)) - } - } - - logE("No volume found for uri $uri") - return null - } -} - -private class Api29ContentPathResolverImpl( - private val contentResolver: ContentResolver, - private val volumeManager: VolumeManager -) : ContentPathResolver { - private data class RawPath(val volumeName: String?, val relativePath: String?) - - override fun resolve(uri: Uri): Path? { - val rawPath = - contentResolver.useQuery( - uri, - arrayOf( - MediaStore.MediaColumns.VOLUME_NAME, MediaStore.MediaColumns.RELATIVE_PATH)) { - cursor -> - cursor.moveToFirst() - RawPath( - cursor.getStringOrNull( - cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.VOLUME_NAME)), - cursor.getStringOrNull( - cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))) - } - - if (rawPath.volumeName == null || rawPath.relativePath == null) { - logE("No data available for uri $uri (raw path obtained: $rawPath)") - return null - } - - // Find the StorageVolume whose MediaStore name corresponds to this song. - // This is combined with the plain relative path column to create the directory. - val volume = volumeManager.getVolumes().find { it.mediaStoreName == rawPath.volumeName } - if (volume != null) { - return Path(volume, Components.parse(rawPath.relativePath)) - } - - logE("No volume found for uri $uri") - return null - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/DocumentTreePathFactory.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt similarity index 84% rename from app/src/main/java/org/oxycblt/auxio/music/dirs/DocumentTreePathFactory.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt index 038e90b19..ebeb348ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/DocumentTreePathFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * DocumentTreePathFactory.kt is part of Auxio. + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.dirs +package org.oxycblt.auxio.music.fs import android.net.Uri import android.provider.DocumentsContract @@ -35,7 +35,15 @@ import org.oxycblt.auxio.music.fs.VolumeManager */ interface DocumentTreePathFactory { /** - * Unpacks a document tree URI into a [Path] instance, using [deserializeDocumentTreePath]. + * 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. @@ -48,7 +56,7 @@ interface DocumentTreePathFactory { * @param path The [Path] instance to serialize. * @return The serialized path. */ - fun serializeDocumentTreePath(path: Path): String + fun toDocumentId(path: Path): String /** * Deserializes a document tree URI format path into a [Path] instance. @@ -56,11 +64,13 @@ interface DocumentTreePathFactory { * @param path The path to deserialize. * @return The [Path] instance, or null if the path could not be deserialized. */ - fun deserializeDocumentTreePath(path: String): Path? + fun fromDocumentId(path: String): Path? } class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : DocumentTreePathFactory { + override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri)) + 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. @@ -68,10 +78,10 @@ class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: DocumentsContract.buildDocumentUriUsingTree( uri, DocumentsContract.getTreeDocumentId(uri)) val treeUri = DocumentsContract.getTreeDocumentId(docUri) - return deserializeDocumentTreePath(treeUri) + return fromDocumentId(treeUri) } - override fun serializeDocumentTreePath(path: Path): String = + 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. @@ -80,7 +90,7 @@ class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: is Volume.External -> "${volume.id}:${path.components}" } - override fun deserializeDocumentTreePath(path: String): Path? { + 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) From c66a9b19b57e4765256af3eeb82197b2e889c94a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 13:28:36 -0700 Subject: [PATCH 28/72] music: more m3u support - Turns out path extraction via MediaStore doesn't work, have to grok the URI format. - Added playlist name extraction - Proactively handling whitespace --- .../org/oxycblt/auxio/music/MusicSettings.kt | 12 ++-- .../org/oxycblt/auxio/music/MusicViewModel.kt | 5 ++ .../auxio/music/dirs/DirectoryModule.kt | 8 +-- .../auxio/music/dirs/MusicDirsDialog.kt | 11 ++-- .../org/oxycblt/auxio/music/external/M3U.kt | 62 ++++++++++++++++--- .../auxio/music/external/PlaylistImporter.kt | 24 +++++-- .../auxio/music/fs/DocumentPathFactory.kt | 13 ++-- .../org/oxycblt/auxio/music/fs/FsModule.kt | 12 ++-- app/src/main/res/values/strings.xml | 1 + 9 files changed, 102 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index 73af74934..e50b0c448 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -24,8 +24,8 @@ import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.dirs.DocumentTreePathFactory import org.oxycblt.auxio.music.dirs.MusicDirectories +import org.oxycblt.auxio.music.fs.DocumentPathFactory import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD @@ -57,10 +57,8 @@ interface MusicSettings : Settings { class MusicSettingsImpl @Inject -constructor( - @ApplicationContext context: Context, - val documentTreePathFactory: DocumentTreePathFactory -) : Settings.Impl(context), MusicSettings { +constructor(@ApplicationContext context: Context, val documentPathFactory: DocumentPathFactory) : + Settings.Impl(context), MusicSettings { private val storageManager = context.getSystemServiceCompat(StorageManager::class) override var musicDirs: MusicDirectories @@ -68,7 +66,7 @@ constructor( val dirs = (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null) ?: emptySet()) - .mapNotNull(documentTreePathFactory::deserializeDocumentTreePath) + .mapNotNull(documentPathFactory::fromDocumentId) return MusicDirectories( dirs, sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false)) @@ -77,7 +75,7 @@ constructor( sharedPreferences.edit { putStringSet( getString(R.string.set_key_music_dirs), - value.dirs.map(documentTreePathFactory::serializeDocumentTreePath).toSet()) + value.dirs.map(documentPathFactory::toDocumentId).toSet()) putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude) apply() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 3b41b445c..452e3954a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -141,6 +141,11 @@ constructor( val deviceLibrary = musicRepository.deviceLibrary ?: return@launch val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) + if (songs.isEmpty()) { + _importError.put(Unit) + return@launch + } + createPlaylist(importedPlaylist.name, songs) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt index f88d71449..eec4918ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt @@ -18,14 +18,8 @@ package org.oxycblt.auxio.music.dirs -import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -@Module -@InstallIn(SingletonComponent::class) -interface DirectoryModule { - @Binds - fun documentTreePathFactory(factory: DocumentTreePathFactoryImpl): DocumentTreePathFactory -} +@Module @InstallIn(SingletonComponent::class) interface DirectoryModule {} diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt index 1db3bb6c7..64c4bdc2e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding 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.util.logD @@ -48,7 +49,7 @@ class MusicDirsDialog : ViewBindingMaterialDialogFragment(), DirectoryAdapter.Listener { private val dirAdapter = DirectoryAdapter(this) private var openDocumentTreeLauncher: ActivityResultLauncher? = null - @Inject lateinit var documentTreePathFactory: DocumentTreePathFactory + @Inject lateinit var documentPathFactory: DocumentPathFactory @Inject lateinit var musicSettings: MusicSettings override fun onCreateBinding(inflater: LayoutInflater) = @@ -101,8 +102,7 @@ class MusicDirsDialog : if (pendingDirs != null) { dirs = MusicDirectories( - pendingDirs.mapNotNull( - documentTreePathFactory::deserializeDocumentTreePath), + pendingDirs.mapNotNull(documentPathFactory::fromDocumentId), savedInstanceState.getBoolean(KEY_PENDING_MODE)) } } @@ -126,8 +126,7 @@ class MusicDirsDialog : override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putStringArrayList( - KEY_PENDING_DIRS, - ArrayList(dirAdapter.dirs.map(documentTreePathFactory::serializeDocumentTreePath))) + KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map(documentPathFactory::toDocumentId))) outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding())) } @@ -155,7 +154,7 @@ class MusicDirsDialog : return } - val dir = documentTreePathFactory.unpackDocumentTreeUri(uri) + val dir = documentPathFactory.unpackDocumentTreeUri(uri) if (dir != null) { dirAdapter.add(dir) diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 19b0aa0ec..424062f26 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -24,39 +24,81 @@ import java.io.InputStreamReader import javax.inject.Inject 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.util.logW +/** + * Minimal M3U file format implementation. + * + * @author Alexander Capehart (OxygenCobalt) + */ interface M3U { - fun read(stream: InputStream, workingDirectory: Path): List? + /** + * 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? } class M3UImpl @Inject constructor() : M3U { - override fun read(stream: InputStream, workingDirectory: Path): List? { + override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? { val reader = BufferedReader(InputStreamReader(stream)) - val media = mutableListOf() + val paths = mutableListOf() + var name: String? = null consumeFile@ while (true) { + var path: String? collectMetadata@ while (true) { - val line = reader.readLine() ?: break@consumeFile - if (!line.startsWith("#")) { + // 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 } } - val path = reader.readLine() if (path == null) { logW("Expected a path, instead got an EOF") break@consumeFile } + // The path may be relative to the directory that the M3U file is contained in, + // so we may need to resolve it into an absolute path before moving ahead. val relativeComponents = Components.parse(path) val absoluteComponents = resolveRelativePath(relativeComponents, workingDirectory.components) - media.add(Path(workingDirectory.volume, absoluteComponents)) + paths.add(Path(workingDirectory.volume, absoluteComponents)) } - return media.ifEmpty { null } + return if (paths.isNotEmpty()) { + ImportedPlaylist(name, paths) + } else { + // Couldn't get anything useful out of this file. + null + } } private fun resolveRelativePath( @@ -66,8 +108,12 @@ class M3UImpl @Inject constructor() : M3U { var components = workingDirectory for (component in relative.components) { when (component) { + // Parent specifier, go "back" one directory (in practice cleave off the last + // component) ".." -> components = components.parent() + // Current directory, the components are already there. "." -> {} + // New directory, add it else -> components = components.child(component) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt b/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt index a9177e8bc..d402e901e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt @@ -22,28 +22,42 @@ import android.content.Context import android.net.Uri import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import org.oxycblt.auxio.music.fs.ContentPathResolver +import org.oxycblt.auxio.music.fs.DocumentPathFactory import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.contentResolverSafe +/** + * Generic playlist file importing abstraction. + * + * @see ImportedPlaylist + * @see M3U + * @author Alexander Capehart (OxygenCobalt) + */ interface PlaylistImporter { suspend fun import(uri: Uri): ImportedPlaylist? } +/** + * 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 PlaylistImporter + * @see M3U + */ data class ImportedPlaylist(val name: String?, val paths: List) class PlaylistImporterImpl @Inject constructor( @ApplicationContext private val context: Context, - private val contentPathResolver: ContentPathResolver, + private val documentPathFactory: DocumentPathFactory, private val m3u: M3U ) : PlaylistImporter { override suspend fun import(uri: Uri): ImportedPlaylist? { - val workingDirectory = contentPathResolver.resolve(uri) ?: return null + val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return null return context.contentResolverSafe.openInputStream(uri)?.use { - val paths = m3u.read(it, workingDirectory) ?: return null - return ImportedPlaylist(null, paths) + return m3u.read(it, filePath.directory) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt index ebeb348ea..9505ac28a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt @@ -22,18 +22,13 @@ import android.net.Uri import android.provider.DocumentsContract import java.io.File import javax.inject.Inject -import org.oxycblt.auxio.music.fs.Components -import org.oxycblt.auxio.music.fs.Path -import org.oxycblt.auxio.music.fs.Volume -import org.oxycblt.auxio.music.fs.VolumeManager /** - * A factory for parsing the reverse-engineered format of the URIs obtained from the document tree - * (i.e directory) folder. + * A factory for parsing the reverse-engineered format of the URIs obtained from document picker. * * @author Alexander Capehart (OxygenCobalt) */ -interface DocumentTreePathFactory { +interface DocumentPathFactory { /** * Unpacks a document URI into a [Path] instance, using [fromDocumentId]. * @@ -67,8 +62,8 @@ interface DocumentTreePathFactory { fun fromDocumentId(path: String): Path? } -class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : - DocumentTreePathFactory { +class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : + DocumentPathFactory { override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri)) override fun unpackDocumentTreeUri(uri: Uri): Path? { diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index a22c7675e..072164323 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.fs import android.content.ContentResolver import android.content.Context import android.os.storage.StorageManager +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -39,11 +40,14 @@ class FsModule { fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) = MediaStoreExtractor.from(context, volumeManager) - @Provides - fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) = - ContentPathResolver.from(context, volumeManager) - @Provides fun contentResolver(@ApplicationContext context: Context): ContentResolver = context.contentResolverSafe } + +@Module +@InstallIn(SingletonComponent::class) +interface FsBindsModule { + @Binds + fun documentPathFactory(documentTreePathFactory: DocumentPathFactoryImpl): DocumentPathFactory +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61ed0e770..5ca564aea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -310,6 +310,7 @@ No music found Music loading failed Auxio needs permission to read your music library + Could not import a playlist from this file No app found that can handle this task No folders From c995eb0d04b54f551ffa785b2324c348e9543afe Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 13:30:46 -0700 Subject: [PATCH 29/72] music: support m3u absolute paths Under the assumption they are in the same volume as the file. It's hacky, but whatever. --- .../java/org/oxycblt/auxio/music/external/M3U.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 424062f26..f656aefd2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -26,6 +26,7 @@ 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.util.logW +import java.io.File /** * Minimal M3U file format implementation. @@ -85,10 +86,19 @@ class M3UImpl @Inject constructor() : M3U { } // The path may be relative to the directory that the M3U file is contained in, + // signified by either the typical ./ or the absence of any separator at all. // so we may need to resolve it into an absolute path before moving ahead. - val relativeComponents = Components.parse(path) - val absoluteComponents = - resolveRelativePath(relativeComponents, workingDirectory.components) + val components = Components.parse(path) + val absoluteComponents = if (path.startsWith(File.separatorChar)) { + // Already an absolute path, do nothing. Theres still some relative-ness here, + // as we assume that the path is still in the same volume as the working directory. + // Unsure if any program goes as far as writing out the full unobfuscated + // absolute path. + components + } else { + // Relative path, resolve it + resolveRelativePath(components, workingDirectory.components) + } paths.add(Path(workingDirectory.volume, absoluteComponents)) } From 5562c184157c38a1ad2aed3d30388f41f6106c4f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 13:39:49 -0700 Subject: [PATCH 30/72] tests: fix Need to mock paths now --- .../java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt index cdbbc6af9..d71961a14 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt @@ -31,6 +31,8 @@ import org.oxycblt.auxio.music.device.ArtistImpl import org.oxycblt.auxio.music.device.DeviceLibraryImpl import org.oxycblt.auxio.music.device.GenreImpl import org.oxycblt.auxio.music.device.SongImpl +import org.oxycblt.auxio.music.fs.Components +import org.oxycblt.auxio.music.fs.Path class DeviceLibraryTest { @@ -42,12 +44,14 @@ class DeviceLibraryTest { mockk { every { uid } returns songUidA every { durationMs } returns 0 + every { path } returns Path(mockk(), Components.parse("./")) every { finalize() } returns this } val songB = mockk { every { uid } returns songUidB every { durationMs } returns 1 + every { path } returns Path(mockk(), Components.parse("./")) every { finalize() } returns this } val deviceLibrary = DeviceLibraryImpl(listOf(songA, songB), listOf(), listOf(), listOf()) @@ -156,11 +160,13 @@ class DeviceLibraryTest { val songA = mockk { every { uid } returns Music.UID.auxio(MusicType.SONGS) + every { path } returns Path(mockk(), Components.parse("./")) every { finalize() } returns this } val songB = mockk { every { uid } returns Music.UID.auxio(MusicType.SONGS) + every { path } returns Path(mockk(), Components.parse("./")) every { finalize() } returns this } val album = From 3d92bdab6f26575acc6e65d5a6011ed3598fd599 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 13:49:43 -0700 Subject: [PATCH 31/72] info: update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dcca4f2e..cb59d4d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ #### What's New - Added ability to rewind/skip tracks by swiping back/forward - Added support for demo release type +- Added playlist importing from M3U files #### What's Changed - Albums linked to an artist only as a collaborator are no longer included @@ -12,6 +13,10 @@ in an artist's album count #### What's Fixed - 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 From 771009d4ff3d50c134d259ec15b1d17745cd4d35 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 22:49:02 -0700 Subject: [PATCH 32/72] music: add m3u exporting backend Add the backend for exporting playlists to m3u files. --- .../org/oxycblt/auxio/music/MusicViewModel.kt | 8 +- .../auxio/music/external/ExternalModule.kt | 2 +- .../org/oxycblt/auxio/music/external/M3U.kt | 99 +++++++++++++++---- .../auxio/music/external/PlaylistImporter.kt | 63 ------------ .../java/org/oxycblt/auxio/music/fs/Fs.kt | 18 +++- 5 files changed, 99 insertions(+), 91 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 452e3954a..e31908e92 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.oxycblt.auxio.list.ListSettings -import org.oxycblt.auxio.music.external.PlaylistImporter +import org.oxycblt.auxio.music.external.ExternalPlaylistManager import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.logD @@ -44,7 +44,7 @@ class MusicViewModel constructor( private val listSettings: ListSettings, private val musicRepository: MusicRepository, - private val playlistImporter: PlaylistImporter + private val externalPlaylistManager: ExternalPlaylistManager ) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { private val _indexingState = MutableStateFlow(null) @@ -128,11 +128,11 @@ constructor( * Import a playlist from a file [Uri]. Errors pushed to [importError]. * * @param uri The [Uri] of the file to import. - * @see PlaylistImporter + * @see ExternalPlaylistManager */ fun importPlaylist(uri: Uri) = viewModelScope.launch(Dispatchers.IO) { - val importedPlaylist = playlistImporter.import(uri) + val importedPlaylist = externalPlaylistManager.import(uri) if (importedPlaylist == null) { _importError.put(Unit) return@launch diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt index a1824fadc..e2f56a21c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt @@ -26,7 +26,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) interface ExternalModule { - @Binds fun playlistImporter(playlistImporter: PlaylistImporterImpl): PlaylistImporter + @Binds fun playlistImporter(playlistImporter: ExternalPlaylistManagerImpl): ExternalPlaylistManager @Binds fun m3u(m3u: M3UImpl): M3U } diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index f656aefd2..1ef194819 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -18,15 +18,21 @@ package org.oxycblt.auxio.music.external +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import org.oxycblt.auxio.music.Playlist import java.io.BufferedReader +import java.io.File import java.io.InputStream import java.io.InputStreamReader import javax.inject.Inject 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.util.logW -import java.io.File +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.util.logE +import java.io.BufferedWriter +import java.io.OutputStream /** * Minimal M3U file format implementation. @@ -44,9 +50,18 @@ interface M3U { * @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. + */ + fun write(playlist: Playlist, outputStream: OutputStream, workingDirectory: Path) } -class M3UImpl @Inject constructor() : M3U { +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() @@ -81,7 +96,7 @@ class M3UImpl @Inject constructor() : M3U { } if (path == null) { - logW("Expected a path, instead got an EOF") + logE("Expected a path, instead got an EOF") break@consumeFile } @@ -89,16 +104,18 @@ class M3UImpl @Inject constructor() : M3U { // signified by either the typical ./ or the absence of any separator at all. // so we may need to resolve it into an absolute path before moving ahead. val components = Components.parse(path) - val absoluteComponents = if (path.startsWith(File.separatorChar)) { - // Already an absolute path, do nothing. Theres still some relative-ness here, - // as we assume that the path is still in the same volume as the working directory. - // Unsure if any program goes as far as writing out the full unobfuscated - // absolute path. - components - } else { - // Relative path, resolve it - resolveRelativePath(components, workingDirectory.components) - } + val absoluteComponents = + if (path.startsWith(File.separatorChar)) { + // Already an absolute path, do nothing. Theres still some relative-ness here, + // as we assume that the path is still in the same volume as the working + // directory. + // Unsure if any program goes as far as writing out the full unobfuscated + // absolute path. + components + } else { + // Relative path, resolve it + components.absoluteTo(workingDirectory.components) + } paths.add(Path(workingDirectory.volume, absoluteComponents)) } @@ -111,22 +128,62 @@ class M3UImpl @Inject constructor() : M3U { } } - private fun resolveRelativePath( - relative: Components, + override fun write( + playlist: Playlist, + outputStream: OutputStream, + workingDirectory: Path + ) { + 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}") + for (song in playlist.songs) { + val relativePath = song.path.components.relativeTo(workingDirectory.components) + 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)}") + writer.writeLine(relativePath.toString()) + } + } + + private fun BufferedWriter.writeLine(line: String) { + write(line) + newLine() + } + + private fun Components.absoluteTo( workingDirectory: Components ): Components { - var components = workingDirectory - for (component in relative.components) { + var absoluteComponents = workingDirectory + for (component in components) { when (component) { // Parent specifier, go "back" one directory (in practice cleave off the last // component) - ".." -> components = components.parent() + ".." -> absoluteComponents = absoluteComponents.parent() // Current directory, the components are already there. "." -> {} // New directory, add it - else -> components = components.child(component) + else -> absoluteComponents = absoluteComponents.child(component) } } - return components + return absoluteComponents } + + private fun Components.relativeTo( + workingDirectory: Components + ): Components { + var relativeComponents = Components.parse(".") + var commonIndex = 0 + while (commonIndex < components.size && + commonIndex < workingDirectory.components.size && + components[commonIndex] == workingDirectory.components[commonIndex]) { + ++commonIndex + relativeComponents = relativeComponents.child("..") + } + return relativeComponents.concat(depth(commonIndex)) + } + } diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt b/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt deleted file mode 100644 index d402e901e..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * PlaylistImporter.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 . - */ - -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.fs.DocumentPathFactory -import org.oxycblt.auxio.music.fs.Path -import org.oxycblt.auxio.music.fs.contentResolverSafe - -/** - * Generic playlist file importing abstraction. - * - * @see ImportedPlaylist - * @see M3U - * @author Alexander Capehart (OxygenCobalt) - */ -interface PlaylistImporter { - suspend fun import(uri: Uri): ImportedPlaylist? -} - -/** - * 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 PlaylistImporter - * @see M3U - */ -data class ImportedPlaylist(val name: String?, val paths: List) - -class PlaylistImporterImpl -@Inject -constructor( - @ApplicationContext private val context: Context, - private val documentPathFactory: DocumentPathFactory, - private val m3u: M3U -) : PlaylistImporter { - override suspend fun import(uri: Uri): ImportedPlaylist? { - val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return null - return context.contentResolverSafe.openInputStream(uri)?.use { - return m3u.read(it, filePath.directory) - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 1f152ed7e..989c72263 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -26,7 +26,6 @@ import android.webkit.MimeTypeMap import java.io.File import javax.inject.Inject import org.oxycblt.auxio.R -import org.oxycblt.auxio.util.logD /** * An abstraction of an android file system path, including the volume and relative path. @@ -117,11 +116,26 @@ value class Components private constructor(val components: List) { */ fun child(name: String) = if (name.isNotEmpty()) { - Components(components + name.trimSlashes()).also { logD(it.components) } + 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 concat(other: Components) = Components(components + other.components) + companion object { /** * Parses a path string into a [Components] instance by the system path separator. From d59230be6d8bca93bf3c97bc8dbf02222de9d7ae Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 20 Dec 2023 22:53:08 -0700 Subject: [PATCH 33/72] music: re-add missing file --- .../music/external/ExternalPlaylistManager.kt | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt new file mode 100644 index 000000000..abb3e672e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt @@ -0,0 +1,90 @@ +/* + * 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 . + */ + +package org.oxycblt.auxio.music.external + +import android.content.Context +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import org.oxycblt.auxio.music.Playlist +import javax.inject.Inject +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 { + suspend fun import(uri: Uri): ImportedPlaylist? + suspend fun export(playlist: Playlist, uri: Uri): 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) + +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): Boolean { + val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return false + 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, filePath.directory) + true + } + } catch (e: Exception) { + logE("Failed to export playlist: $e") + false + } + } +} From c3f67d4dc53e39533ebdea2aa033c2451bfd5eaf Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 21 Dec 2023 20:55:49 -0700 Subject: [PATCH 34/72] music: fix m3u export Wasn't correctly writing and also naively relative-izing paths. Those should be fixed now, I hope. --- .../org/oxycblt/auxio/music/external/M3U.kt | 62 +++++++++++++------ .../java/org/oxycblt/auxio/music/fs/Fs.kt | 8 ++- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 1ef194819..6e99d0925 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -20,19 +20,19 @@ package org.oxycblt.auxio.music.external import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext -import org.oxycblt.auxio.music.Playlist import java.io.BufferedReader +import java.io.BufferedWriter import java.io.File 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 -import java.io.BufferedWriter -import java.io.OutputStream /** * Minimal M3U file format implementation. @@ -53,10 +53,11 @@ interface M3U { /** * 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. + * create relative paths to where the M3U file is assumed to be stored. */ fun write(playlist: Playlist, outputStream: OutputStream, workingDirectory: Path) } @@ -128,17 +129,13 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte } } - override fun write( - playlist: Playlist, - outputStream: OutputStream, - workingDirectory: Path - ) { + override fun write(playlist: Playlist, outputStream: OutputStream, workingDirectory: Path) { 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}") + writer.writeLine("#PLAYLIST:${playlist.name.resolve(context)}") for (song in playlist.songs) { val relativePath = song.path.components.relativeTo(workingDirectory.components) writer.writeLine("#EXTINF:${song.durationMs},${song.name.resolve(context)}") @@ -147,6 +144,7 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte writer.writeLine("#EXTGEN:${song.genres.resolveNames(context)}") writer.writeLine(relativePath.toString()) } + writer.flush() } private fun BufferedWriter.writeLine(line: String) { @@ -154,9 +152,7 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte newLine() } - private fun Components.absoluteTo( - workingDirectory: Components - ): Components { + private fun Components.absoluteTo(workingDirectory: Components): Components { var absoluteComponents = workingDirectory for (component in components) { when (component) { @@ -172,18 +168,44 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte return absoluteComponents } - private fun Components.relativeTo( - workingDirectory: Components - ): Components { - var relativeComponents = Components.parse(".") + 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 - relativeComponents = relativeComponents.child("..") } - return relativeComponents.concat(depth(commonIndex)) - } + var relativeComponents = Components.parse(".") + + // 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 + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 989c72263..78e173f8d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -122,8 +122,9 @@ value class Components private constructor(val components: List) { } /** - * Removes the first [n] elements of the path, effectively resulting in a path that is n - * levels deep. + * 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. */ @@ -131,10 +132,11 @@ value class Components private constructor(val components: List) { /** * Concatenates this [Components] instance with another. + * * @param other The [Components] instance to concatenate with. * @return The new [Components] instance. */ - fun concat(other: Components) = Components(components + other.components) + fun child(other: Components) = Components(components + other.components) companion object { /** From 68e4da5e7e65202592652ffb5ebc390d72dd6de3 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 23 Dec 2023 12:12:51 -0700 Subject: [PATCH 35/72] music: make playlist export configurable Add configuration options for: - Using windows-compatible paths with \ separators and C:\\ volume prefixes - Switching between relative and absolute paths --- .../music/decision/ExportPlaylistDialog.kt | 4 + .../music/external/ExternalPlaylistManager.kt | 42 +++++++- .../org/oxycblt/auxio/music/external/M3U.kt | 100 ++++++++++++++---- .../auxio/music/fs/DocumentPathFactory.kt | 2 +- .../java/org/oxycblt/auxio/music/fs/Fs.kt | 39 ++++++- .../auxio/music/fs/MediaStoreExtractor.kt | 4 +- .../res/layout/dialog_playlist_export.xml | 6 ++ 7 files changed, 163 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt create mode 100644 app/src/main/res/layout/dialog_playlist_export.xml diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt new file mode 100644 index 000000000..f01bf6da0 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt @@ -0,0 +1,4 @@ +package org.oxycblt.auxio.music.decision + +class ExportPlaylistDialog { +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt index abb3e672e..1a7eed2be 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt @@ -21,8 +21,9 @@ package org.oxycblt.auxio.music.external import android.content.Context import android.net.Uri import dagger.hilt.android.qualifiers.ApplicationContext -import org.oxycblt.auxio.music.Playlist 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 @@ -36,10 +37,37 @@ import org.oxycblt.auxio.util.logE * @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? - suspend fun export(playlist: Playlist, uri: Uri): Boolean + + /** + * 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. * @@ -70,8 +98,14 @@ constructor( } } - override suspend fun export(playlist: Playlist, uri: Uri): Boolean { + override suspend fun export(playlist: Playlist, uri: Uri, config: ExportConfig): Boolean { val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return false + val workingDirectory = + if (config.absolute) { + filePath.directory + } else { + Path(filePath.volume, Components.parseUnix("/")) + } return try { val outputStream = context.contentResolverSafe.openOutputStream(uri) if (outputStream == null) { @@ -79,7 +113,7 @@ constructor( return false } outputStream.use { - m3u.write(playlist, it, filePath.directory) + m3u.write(playlist, it, workingDirectory, config) true } } catch (e: Exception) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 6e99d0925..1139cf87b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -22,7 +22,6 @@ import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import java.io.BufferedReader import java.io.BufferedWriter -import java.io.File import java.io.InputStream import java.io.InputStreamReader import java.io.OutputStream @@ -58,8 +57,19 @@ interface M3U { * @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) + 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 { @@ -101,24 +111,40 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte break@consumeFile } - // The path may be relative to the directory that the M3U file is contained in, - // signified by either the typical ./ or the absence of any separator at all. - // so we may need to resolve it into an absolute path before moving ahead. - val components = Components.parse(path) - val absoluteComponents = - if (path.startsWith(File.separatorChar)) { - // Already an absolute path, do nothing. Theres still some relative-ness here, - // as we assume that the path is still in the same volume as the working - // directory. - // Unsure if any program goes as far as writing out the full unobfuscated - // absolute path. - components - } else { - // Relative path, resolve it - components.absoluteTo(workingDirectory.components) + // 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, absoluteComponents)) + paths.add(Path(workingDirectory.volume, components)) } return if (paths.isNotEmpty()) { @@ -129,7 +155,12 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte } } - override fun write(playlist: Playlist, outputStream: OutputStream, workingDirectory: Path) { + 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. @@ -137,12 +168,33 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte writer.writeLine("#EXTENC:UTF-8") writer.writeLine("#PLAYLIST:${playlist.name.resolve(context)}") for (song in playlist.songs) { - val relativePath = song.path.components.relativeTo(workingDirectory.components) 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)}") - writer.writeLine(relativePath.toString()) + + 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() } @@ -179,7 +231,7 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte ++commonIndex } - var relativeComponents = Components.parse(".") + var relativeComponents = Components.parseUnix(".") // TODO: Simplify this logic when { @@ -208,4 +260,8 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte return relativeComponents } + + private companion object { + val WINDOWS_VOLUME_PREFIX_REGEX = Regex("^[A-Za-z]:\\\\") + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt index 9505ac28a..7643603c7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt @@ -100,7 +100,7 @@ class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: Vol volumeManager.getVolumes().find { it is Volume.External && it.id == split[0] } } val relativePath = split.getOrNull(1) ?: return null - return Path(volume ?: return null, Components.parse(relativePath)) + return Path(volume ?: return null, Components.parseUnix(relativePath)) } private companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 78e173f8d..c8b26523c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -98,7 +98,15 @@ value class Components private constructor(val components: List) { val name: String? get() = components.lastOrNull() - override fun toString() = components.joinToString(File.separator) + 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" @@ -140,14 +148,35 @@ value class Components private constructor(val components: List) { companion object { /** - * Parses a path string into a [Components] instance by the system path separator. + * 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 parse(path: String) = + 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) } } @@ -188,7 +217,7 @@ class VolumeManagerImpl @Inject constructor(private val storageManager: StorageM get() = storageVolume.mediaStoreVolumeNameCompat override val components - get() = storageVolume.directoryCompat?.let(Components::parse) + get() = storageVolume.directoryCompat?.let(Components::parseUnix) override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context) } @@ -201,7 +230,7 @@ class VolumeManagerImpl @Inject constructor(private val storageManager: StorageM get() = storageVolume.mediaStoreVolumeNameCompat override val components - get() = storageVolume.directoryCompat?.let(Components::parse) + get() = storageVolume.directoryCompat?.let(Components::parseUnix) override fun resolveName(context: Context) = storageVolume.getDescriptionCompat(context) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 1c0b58a7c..df9a979a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -422,7 +422,7 @@ private class Api21MediaStoreExtractor(context: Context, private val volumeManag val volumePath = (volume.components ?: continue).toString() val strippedPath = rawPath.removePrefix(volumePath) if (strippedPath != rawPath) { - rawSong.directory = Path(volume, Components.parse(strippedPath)) + rawSong.directory = Path(volume, Components.parseUnix(strippedPath)) break } } @@ -497,7 +497,7 @@ private abstract class BaseApi29MediaStoreExtractor(context: Context) : val relativePath = cursor.getString(relativePathIndex) val volume = volumes.find { it.mediaStoreName == volumeName } if (volume != null) { - rawSong.directory = Path(volume, Components.parse(relativePath)) + rawSong.directory = Path(volume, Components.parseUnix(relativePath)) } } } diff --git a/app/src/main/res/layout/dialog_playlist_export.xml b/app/src/main/res/layout/dialog_playlist_export.xml new file mode 100644 index 000000000..cdc89f25a --- /dev/null +++ b/app/src/main/res/layout/dialog_playlist_export.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file From 3f1f2f5c2d8fc282e420441fb7c1278f16f3ce8a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 23 Dec 2023 12:15:35 -0700 Subject: [PATCH 36/72] music: implement exporting frontend Implement the exporting dialog and flow in all places in the app. --- .../auxio/detail/AlbumDetailFragment.kt | 3 +- .../auxio/detail/ArtistDetailFragment.kt | 1 + .../auxio/detail/GenreDetailFragment.kt | 1 + .../auxio/detail/PlaylistDetailFragment.kt | 4 + .../org/oxycblt/auxio/home/HomeFragment.kt | 52 +++++- .../auxio/list/menu/MenuDialogFragmentImpl.kt | 2 + .../org/oxycblt/auxio/music/MusicViewModel.kt | 50 +++++- .../music/decision/ExportPlaylistDialog.kt | 154 +++++++++++++++++- .../music/decision/PlaylistPickerViewModel.kt | 50 ++++++ .../auxio/music/external/ExternalModule.kt | 5 +- .../oxycblt/auxio/search/SearchFragment.kt | 4 + app/src/main/res/drawable/ic_import_24.xml | 2 +- .../res/layout/dialog_playlist_export.xml | 80 ++++++++- app/src/main/res/menu/playlist.xml | 4 + app/src/main/res/navigation/inner.xml | 19 +++ app/src/main/res/values/strings.xml | 11 +- 16 files changed, 421 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 3fd4a6963..16aed1ce6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -274,7 +274,8 @@ class AlbumDetailFragment : } is PlaylistDecision.New, 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) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index b0bc09386..d217806e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -277,6 +277,7 @@ class ArtistDetailFragment : } is PlaylistDecision.New, is PlaylistDecision.Rename, + is PlaylistDecision.Export, is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") } findNavController().navigateSafe(directions) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 522ebbfa6..aaa31f8c1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -270,6 +270,7 @@ class GenreDetailFragment : } is PlaylistDecision.New, is PlaylistDecision.Rename, + is PlaylistDecision.Export, is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") } findNavController().navigateSafe(directions) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 540017724..a312079ef 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -324,6 +324,10 @@ class PlaylistDetailFragment : logD("Renaming ${decision.playlist}") PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid) } + is PlaylistDecision.Export -> { + logD("Exporting ${decision.playlist}") + PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid) + } is PlaylistDecision.Delete -> { logD("Deleting ${decision.playlist}") PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 8d75a5a1e..2ce4dc107 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -68,8 +68,11 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.NoAudioPermissionException import org.oxycblt.auxio.music.NoMusicException 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.PlaylistError import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.external.M3U import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately @@ -98,7 +101,9 @@ class HomeFragment : private val homeModel: HomeViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() private var storagePermissionLauncher: ActivityResultLauncher? = null - private var filePickerLauncher: ActivityResultLauncher? = null + private var getContentLauncher: ActivityResultLauncher? = null + private var createDocumentLauncher: ActivityResultLauncher? = null + private var pendingExportPlaylist: Playlist? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -127,7 +132,7 @@ class HomeFragment : musicModel.refresh() } - filePickerLauncher = + getContentLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri == null) { logW("No URI returned from file picker") @@ -138,6 +143,24 @@ class HomeFragment : musicModel.importPlaylist(uri) } + createDocumentLauncher = + registerForActivityResult(ActivityResultContracts.CreateDocument(M3U.MIME_TYPE)) { uri + -> + if (uri == null) { + logW("No URI returned from file picker") + return@registerForActivityResult + } + + val playlist = pendingExportPlaylist + if (playlist == null) { + logW("No playlist to export") + return@registerForActivityResult + } + + logD("Received playlist URI $uri") + musicModel.exportPlaylist(playlist, uri) + } + // --- UI SETUP --- binding.homeAppbar.addOnOffsetChangedListener(this) binding.homeNormalToolbar.apply { @@ -210,7 +233,7 @@ class HomeFragment : collectImmediately(listModel.selected, ::updateSelection) collectImmediately(musicModel.indexingState, ::updateIndexerState) collect(musicModel.playlistDecision.flow, ::handleDecision) - collectImmediately(musicModel.importError.flow, ::handleImportError) + collectImmediately(musicModel.playlistError.flow, ::handlePlaylistError) collect(detailModel.toShow.flow, ::handleShow) } @@ -295,7 +318,7 @@ class HomeFragment : } R.id.action_import_playlist -> { logD("Importing playlist") - filePickerLauncher?.launch("audio/x-mpegurl") + getContentLauncher?.launch(M3U.MIME_TYPE) } else -> {} } @@ -475,6 +498,10 @@ class HomeFragment : logD("Renaming ${decision.playlist}") HomeFragmentDirections.renamePlaylist(decision.playlist.uid) } + is PlaylistDecision.Export -> { + logD("Exporting ${decision.playlist}") + HomeFragmentDirections.exportPlaylist(decision.playlist.uid) + } is PlaylistDecision.Delete -> { logD("Deleting ${decision.playlist}") HomeFragmentDirections.deletePlaylist(decision.playlist.uid) @@ -486,13 +513,22 @@ class HomeFragment : } } findNavController().navigateSafe(directions) + musicModel.playlistDecision.consume() } - private fun handleImportError(flag: Unit?) { - if (flag != null) { - requireContext().showToast(R.string.err_import_failed) - musicModel.importError.consume() + private fun handlePlaylistError(error: PlaylistError?) { + when (error) { + is PlaylistError.ImportFailed -> { + requireContext().showToast(R.string.err_import_failed) + musicModel.importError.consume() + } + is PlaylistError.ExportFailed -> { + requireContext().showToast(R.string.err_export_failed) + musicModel.importError.consume() + } + null -> {} } + musicModel.playlistError.consume() } private fun updateFab(songs: List, isFastScrolling: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt index a7fadce1f..3df0f5541 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt @@ -288,6 +288,7 @@ class PlaylistMenuDialogFragment : MenuDialogFragment() { R.id.action_play_next, R.id.action_queue_add, R.id.action_playlist_add, + R.id.action_playlist_export, R.id.action_share) } else { setOf() @@ -320,6 +321,7 @@ class PlaylistMenuDialogFragment : MenuDialogFragment() { requireContext().showToast(R.string.lng_queue_added) } R.id.action_rename -> musicModel.renamePlaylist(menu.playlist) + R.id.action_playlist_export -> musicModel.exportPlaylist(menu.playlist) R.id.action_delete -> musicModel.deletePlaylist(menu.playlist) R.id.action_share -> requireContext().share(menu.playlist) else -> error("Unexpected menu item $item") diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index e31908e92..45e8ff499 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch 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.MutableEvent @@ -64,11 +65,20 @@ constructor( val playlistDecision: Event get() = _playlistDecision + private val _playlistError = MutableEvent() + val playlistError: Event + get() = _playlistError + private val _importError = MutableEvent() /** Flag for when playlist importing failed. Consume this and show an error if active. */ val importError: Event get() = _importError + private val _exportError = MutableEvent() + /** Flag for when playlist exporting failed. Consume this and show an error if active. */ + val exportError: Event + get() = _exportError + init { musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -134,7 +144,7 @@ constructor( viewModelScope.launch(Dispatchers.IO) { val importedPlaylist = externalPlaylistManager.import(uri) if (importedPlaylist == null) { - _importError.put(Unit) + _playlistError.put(PlaylistError.ImportFailed) return@launch } @@ -142,13 +152,34 @@ constructor( val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) if (songs.isEmpty()) { - _importError.put(Unit) + _playlistError.put(PlaylistError.ImportFailed) return@launch } - + // TODO Require the user to name it something else if the name is a duplicate of + // a prior playlist createPlaylist(importedPlaylist.name, songs) } + /** + * Export a [Playlist] to a file [Uri]. Errors pushed to [exportError]. + * + * @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)) { + _playlistError.put(PlaylistError.ExportFailed) + } + } + } else { + logD("Launching export dialog") + _playlistDecision.put(PlaylistDecision.Export(playlist)) + } + } + /** * Rename the given playlist. * @@ -280,6 +311,13 @@ sealed interface PlaylistDecision { */ data class Rename(val playlist: Playlist) : PlaylistDecision + /** + * 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]. * @@ -294,3 +332,9 @@ sealed interface PlaylistDecision { */ data class Add(val songs: List) : PlaylistDecision } + +sealed interface PlaylistError { + data object ImportFailed : PlaylistError + + data object ExportFailed : PlaylistError +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt index f01bf6da0..6a5ac5e00 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/ExportPlaylistDialog.kt @@ -1,4 +1,154 @@ +/* + * 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 . + */ + package org.oxycblt.auxio.music.decision -class ExportPlaylistDialog { -} \ No newline at end of file +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() { + private val musicModel: MusicViewModel by activityViewModels() + private val pickerModel: PlaylistPickerViewModel by viewModels() + private var createDocumentLauncher: ActivityResultLauncher? = 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 + logD("change") + 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.-]") + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt index b21d9e991..356948553 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt @@ -31,6 +31,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.external.ExportConfig import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW @@ -53,6 +54,16 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M val currentPlaylistToRename: StateFlow get() = _currentPlaylistToRename + private val _currentPlaylistToExport = MutableStateFlow(null) + /** An existing [Playlist] that is being exported. Null if none yet. */ + val currentPlaylistToExport: StateFlow + get() = _currentPlaylistToExport + + private val _currentExportConfig = MutableStateFlow(DEFAULT_EXPORT_CONFIG) + /** The current [ExportConfig] to use when exporting a playlist. */ + val currentExportConfig: StateFlow + get() = _currentExportConfig + private val _currentPlaylistToDelete = MutableStateFlow(null) /** The current [Playlist] that needs it's deletion confirmed. Null if none yet. */ val currentPlaylistToDelete: StateFlow @@ -110,6 +121,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } logD("Updated chosen name to $chosenName") 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) @@ -169,6 +188,33 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } } + /** + * 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 + } + } + + /** + * 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 [currentPendingPlaylist] from a new [Playlist] [Music.UID]. * @@ -238,6 +284,10 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M PlaylistChoice(it, songs.all(songSet::contains)) } } + + private companion object { + private val DEFAULT_EXPORT_CONFIG = ExportConfig(absolute = false, windowsPaths = false) + } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt index e2f56a21c..af29c3620 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalModule.kt @@ -26,7 +26,10 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) interface ExternalModule { - @Binds fun playlistImporter(playlistImporter: ExternalPlaylistManagerImpl): ExternalPlaylistManager + @Binds + fun externalPlaylistManager( + externalPlaylistManager: ExternalPlaylistManagerImpl + ): ExternalPlaylistManager @Binds fun m3u(m3u: M3UImpl): M3U } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index da74d66a2..c115a156b 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -295,6 +295,10 @@ class SearchFragment : ListFragment() { logD("Deleting ${decision.playlist}") SearchFragmentDirections.deletePlaylist(decision.playlist.uid) } + is PlaylistDecision.Export -> { + logD("Exporting ${decision.playlist}") + SearchFragmentDirections.exportPlaylist(decision.playlist.uid) + } is PlaylistDecision.Add -> { logD("Adding ${decision.songs.size} to a playlist") SearchFragmentDirections.addToPlaylist( diff --git a/app/src/main/res/drawable/ic_import_24.xml b/app/src/main/res/drawable/ic_import_24.xml index f63386171..427745f84 100644 --- a/app/src/main/res/drawable/ic_import_24.xml +++ b/app/src/main/res/drawable/ic_import_24.xml @@ -7,5 +7,5 @@ android:tint="?attr/colorControlNormal"> + 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"/> diff --git a/app/src/main/res/layout/dialog_playlist_export.xml b/app/src/main/res/layout/dialog_playlist_export.xml index cdc89f25a..15f12f8bb 100644 --- a/app/src/main/res/layout/dialog_playlist_export.xml +++ b/app/src/main/res/layout/dialog_playlist_export.xml @@ -1,6 +1,78 @@ - + - \ No newline at end of file + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/playlist.xml b/app/src/main/res/menu/playlist.xml index af3277d8a..a5cdca330 100644 --- a/app/src/main/res/menu/playlist.xml +++ b/app/src/main/res/menu/playlist.xml @@ -24,6 +24,10 @@ android:id="@+id/action_rename" android:title="@string/lbl_rename" android:icon="@drawable/ic_edit_24"/> + + @@ -180,6 +183,9 @@ + @@ -376,6 +382,9 @@ + @@ -416,6 +425,16 @@ app:argType="org.oxycblt.auxio.music.Music$UID" /> + + + + New playlist Empty playlist Imported playlist + Export + Export playlist Rename Rename playlist Delete @@ -157,6 +159,12 @@ Add + Path style + Absolute + Relative + + Use Windows-compatible paths + State saved @@ -310,7 +318,8 @@ No music found Music loading failed Auxio needs permission to read your music library - Could not import a playlist from this file + Unable to import a playlist from this file + Unable to export the playlist to this file No app found that can handle this task No folders From c1fc548e6a867e3ac8d6da04d29be2ad2942fd3b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 23 Dec 2023 12:19:06 -0700 Subject: [PATCH 37/72] tests: fix tests --- .../org/oxycblt/auxio/music/user/DeviceLibraryTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt index d71961a14..e89c8d241 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt @@ -44,14 +44,14 @@ class DeviceLibraryTest { mockk { every { uid } returns songUidA every { durationMs } returns 0 - every { path } returns Path(mockk(), Components.parse("./")) + every { path } returns Path(mockk(), Components.parseUnix("./")) every { finalize() } returns this } val songB = mockk { every { uid } returns songUidB every { durationMs } returns 1 - every { path } returns Path(mockk(), Components.parse("./")) + every { path } returns Path(mockk(), Components.parseUnix("./")) every { finalize() } returns this } val deviceLibrary = DeviceLibraryImpl(listOf(songA, songB), listOf(), listOf(), listOf()) @@ -160,13 +160,13 @@ class DeviceLibraryTest { val songA = mockk { every { uid } returns Music.UID.auxio(MusicType.SONGS) - every { path } returns Path(mockk(), Components.parse("./")) + every { path } returns Path(mockk(), Components.parseUnix("./")) every { finalize() } returns this } val songB = mockk { every { uid } returns Music.UID.auxio(MusicType.SONGS) - every { path } returns Path(mockk(), Components.parse("./")) + every { path } returns Path(mockk(), Components.parseUnix("./")) every { finalize() } returns this } val album = From 61cb520ab8ec7a0c753e077c69d2c374c7bec693 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 23 Dec 2023 12:29:05 -0700 Subject: [PATCH 38/72] info: update changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb59d4d73..224b91250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,10 @@ #### What's New - Added ability to rewind/skip tracks by swiping back/forward - Added support for demo release type -- Added playlist importing from M3U files +- 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 From 4ad2fe1a58bd999bd89728a1f8c0a41d6e82fe4d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 23 Dec 2023 12:29:27 -0700 Subject: [PATCH 39/72] info: fix readme version --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4c867895b..a9a0c1ef4 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases From c9b1ab90689895ee62a619f54e2d3952eec3da65 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 23 Dec 2023 20:01:20 -0700 Subject: [PATCH 40/72] all: reformat xml --- app/src/main/res/drawable/ic_add_24.xml | 10 ++-- app/src/main/res/drawable/ic_copy_24.xml | 10 ++-- app/src/main/res/drawable/ic_details_24.xml | 10 ++-- app/src/main/res/drawable/ic_edit_24.xml | 10 ++-- app/src/main/res/drawable/ic_import_24.xml | 10 ++-- app/src/main/res/drawable/ic_play_next_24.xml | 10 ++-- app/src/main/res/drawable/ic_playlist_24.xml | 10 ++-- .../main/res/drawable/ic_playlist_add_24.xml | 10 ++-- app/src/main/res/drawable/ic_queue_add_24.xml | 10 ++-- app/src/main/res/drawable/ic_save_24.xml | 10 ++-- app/src/main/res/drawable/ic_share_24.xml | 10 ++-- .../main/res/drawable/ic_shuffle_off_24.xml | 10 ++-- .../main/res/drawable/ic_shuffle_on_24.xml | 10 ++-- .../res/drawable/ui_selection_badge_bg.xml | 3 +- .../res/layout-h600dp/item_detail_header.xml | 7 +-- .../res/layout-land/item_detail_header.xml | 6 +-- .../res/layout-sw600dp/item_detail_header.xml | 2 +- .../res/layout-sw840dp/item_detail_header.xml | 4 +- .../res/layout-w600dp-land/fragment_main.xml | 2 +- .../res/layout/design_bottom_sheet_dialog.xml | 46 +++++++++---------- .../res/layout/dialog_delete_playlist.xml | 8 ++-- .../main/res/layout/dialog_error_details.xml | 23 +++++----- app/src/main/res/layout/dialog_music_dirs.xml | 20 ++++---- .../res/layout/dialog_playlist_export.xml | 6 +-- .../main/res/layout/dialog_playlist_name.xml | 4 +- app/src/main/res/layout/dialog_sort.xml | 6 +-- app/src/main/res/layout/fragment_about.xml | 8 ++-- app/src/main/res/layout/fragment_detail.xml | 8 ++-- app/src/main/res/layout/fragment_home.xml | 36 +++++++-------- app/src/main/res/layout/fragment_main.xml | 6 +-- app/src/main/res/layout/fragment_search.xml | 4 +- app/src/main/res/layout/item_album_song.xml | 4 +- .../main/res/layout/item_detail_header.xml | 4 +- app/src/main/res/layout/item_disc_header.xml | 6 +-- app/src/main/res/layout/item_edit_header.xml | 6 +-- app/src/main/res/layout/item_header.xml | 10 ++-- app/src/main/res/layout/item_menu_option.xml | 2 +- app/src/main/res/layout/item_music_dir.xml | 6 +-- .../res/layout/item_new_playlist_choice.xml | 14 +++--- .../main/res/layout/item_song_property.xml | 10 ++-- app/src/main/res/layout/item_sort_header.xml | 6 +-- app/src/main/res/layout/item_sort_mode.xml | 2 +- .../res/layout/view_preference_switch.xml | 2 +- app/src/main/res/menu/album.xml | 32 ++++++------- app/src/main/res/menu/album_song.xml | 32 ++++++------- app/src/main/res/menu/artist_album.xml | 28 +++++------ app/src/main/res/menu/artist_song.xml | 32 ++++++------- app/src/main/res/menu/detail_album.xml | 28 +++++------ app/src/main/res/menu/detail_parent.xml | 24 +++++----- app/src/main/res/menu/detail_playlist.xml | 28 +++++------ app/src/main/res/menu/parent.xml | 28 +++++------ app/src/main/res/menu/playback_song.xml | 12 ++--- app/src/main/res/menu/playlist.xml | 38 +++++++-------- app/src/main/res/menu/playlist_song.xml | 32 ++++++------- app/src/main/res/menu/selection.xml | 24 +++++----- app/src/main/res/menu/song.xml | 36 +++++++-------- app/src/main/res/menu/toolbar_edit.xml | 4 +- app/src/main/res/menu/toolbar_selection.xml | 8 ++-- app/src/main/res/xml/backup_descriptor.xml | 4 +- .../main/res/xml/data_extraction_rules.xml | 8 +++- 60 files changed, 400 insertions(+), 399 deletions(-) diff --git a/app/src/main/res/drawable/ic_add_24.xml b/app/src/main/res/drawable/ic_add_24.xml index c056f550e..91ccc34d8 100644 --- a/app/src/main/res/drawable/ic_add_24.xml +++ b/app/src/main/res/drawable/ic_add_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_copy_24.xml b/app/src/main/res/drawable/ic_copy_24.xml index 65bb96df5..35480bed7 100644 --- a/app/src/main/res/drawable/ic_copy_24.xml +++ b/app/src/main/res/drawable/ic_copy_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ic_details_24.xml b/app/src/main/res/drawable/ic_details_24.xml index 525ec5618..ea9ffe732 100644 --- a/app/src/main/res/drawable/ic_details_24.xml +++ b/app/src/main/res/drawable/ic_details_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ic_edit_24.xml b/app/src/main/res/drawable/ic_edit_24.xml index 9ce54759b..06285a565 100644 --- a/app/src/main/res/drawable/ic_edit_24.xml +++ b/app/src/main/res/drawable/ic_edit_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ic_import_24.xml b/app/src/main/res/drawable/ic_import_24.xml index 427745f84..9cfbef4c0 100644 --- a/app/src/main/res/drawable/ic_import_24.xml +++ b/app/src/main/res/drawable/ic_import_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ic_play_next_24.xml b/app/src/main/res/drawable/ic_play_next_24.xml index df1a8dd79..b507eea91 100644 --- a/app/src/main/res/drawable/ic_play_next_24.xml +++ b/app/src/main/res/drawable/ic_play_next_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ic_playlist_24.xml b/app/src/main/res/drawable/ic_playlist_24.xml index d92e150b1..e24a5e217 100644 --- a/app/src/main/res/drawable/ic_playlist_24.xml +++ b/app/src/main/res/drawable/ic_playlist_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_playlist_add_24.xml b/app/src/main/res/drawable/ic_playlist_add_24.xml index 6d0cfca3f..fba53c9fd 100644 --- a/app/src/main/res/drawable/ic_playlist_add_24.xml +++ b/app/src/main/res/drawable/ic_playlist_add_24.xml @@ -2,11 +2,11 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ic_queue_add_24.xml b/app/src/main/res/drawable/ic_queue_add_24.xml index a9e7e0ac3..d0ea17445 100644 --- a/app/src/main/res/drawable/ic_queue_add_24.xml +++ b/app/src/main/res/drawable/ic_queue_add_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ic_save_24.xml b/app/src/main/res/drawable/ic_save_24.xml index 4fc73a9f3..2fa6cd2e4 100644 --- a/app/src/main/res/drawable/ic_save_24.xml +++ b/app/src/main/res/drawable/ic_save_24.xml @@ -2,11 +2,11 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ic_share_24.xml b/app/src/main/res/drawable/ic_share_24.xml index 17485830b..816d28711 100644 --- a/app/src/main/res/drawable/ic_share_24.xml +++ b/app/src/main/res/drawable/ic_share_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ic_shuffle_off_24.xml b/app/src/main/res/drawable/ic_shuffle_off_24.xml index d73a3c489..28a688e1d 100644 --- a/app/src/main/res/drawable/ic_shuffle_off_24.xml +++ b/app/src/main/res/drawable/ic_shuffle_off_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ic_shuffle_on_24.xml b/app/src/main/res/drawable/ic_shuffle_on_24.xml index 1e4330f55..52d82252f 100644 --- a/app/src/main/res/drawable/ic_shuffle_on_24.xml +++ b/app/src/main/res/drawable/ic_shuffle_on_24.xml @@ -2,10 +2,10 @@ - + android:viewportHeight="960"> + diff --git a/app/src/main/res/drawable/ui_selection_badge_bg.xml b/app/src/main/res/drawable/ui_selection_badge_bg.xml index d6ae300cc..88a78c3bb 100644 --- a/app/src/main/res/drawable/ui_selection_badge_bg.xml +++ b/app/src/main/res/drawable/ui_selection_badge_bg.xml @@ -1,8 +1,7 @@ - + diff --git a/app/src/main/res/layout-h600dp/item_detail_header.xml b/app/src/main/res/layout-h600dp/item_detail_header.xml index e94d8538f..cdaf0e484 100644 --- a/app/src/main/res/layout-h600dp/item_detail_header.xml +++ b/app/src/main/res/layout-h600dp/item_detail_header.xml @@ -4,18 +4,19 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:paddingTop="@dimen/spacing_medium" android:paddingStart="@dimen/spacing_medium" + android:paddingTop="@dimen/spacing_medium" android:paddingEnd="@dimen/spacing_medium" android:paddingBottom="@dimen/spacing_mid_medium"> + diff --git a/app/src/main/res/layout-sw840dp/item_detail_header.xml b/app/src/main/res/layout-sw840dp/item_detail_header.xml index 87dcf941e..d66af44bb 100644 --- a/app/src/main/res/layout-sw840dp/item_detail_header.xml +++ b/app/src/main/res/layout-sw840dp/item_detail_header.xml @@ -9,10 +9,10 @@ - - - - - + android:fitsSystemWindows="true"> - + - + + + diff --git a/app/src/main/res/layout/dialog_delete_playlist.xml b/app/src/main/res/layout/dialog_delete_playlist.xml index 4987c3290..1fba917ff 100644 --- a/app/src/main/res/layout/dialog_delete_playlist.xml +++ b/app/src/main/res/layout/dialog_delete_playlist.xml @@ -1,11 +1,11 @@ \ No newline at end of file + tools:text="Delete Playlist 16? This cannot be undone." /> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_error_details.xml b/app/src/main/res/layout/dialog_error_details.xml index 729c17d0b..e19d930ab 100644 --- a/app/src/main/res/layout/dialog_error_details.xml +++ b/app/src/main/res/layout/dialog_error_details.xml @@ -1,24 +1,23 @@ - + app:layout_constraintTop_toTopOf="parent"> @@ -56,10 +55,10 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|end" - app:icon="@drawable/ic_copy_24" android:layout_margin="@dimen/spacing_small" + android:src="@drawable/ic_code_24" app:backgroundTint="?attr/colorPrimaryContainer" - android:src="@drawable/ic_code_24" /> + app:icon="@drawable/ic_copy_24" /> diff --git a/app/src/main/res/layout/dialog_music_dirs.xml b/app/src/main/res/layout/dialog_music_dirs.xml index fa48b8a9c..0885dbc22 100644 --- a/app/src/main/res/layout/dialog_music_dirs.xml +++ b/app/src/main/res/layout/dialog_music_dirs.xml @@ -30,8 +30,8 @@ android:layout_marginTop="@dimen/spacing_tiny" android:layout_marginEnd="@dimen/spacing_large" android:gravity="center" - app:layout_constraintTop_toBottomOf="@+id/dirs_mode_header" app:checkedButton="@+id/dirs_mode_exclude" + app:layout_constraintTop_toBottomOf="@+id/dirs_mode_header" app:selectionRequired="true" app:singleSelection="true"> @@ -41,8 +41,8 @@ android:layout_width="0dp" android:layout_height="wrap_content" 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" /> + app:layout_constraintTop_toBottomOf="@+id/dirs_mode_desc" /> + android:contentDescription="@string/lbl_add" + app:icon="@drawable/ic_add_24" + app:layout_constraintEnd_toEndOf="@+id/dirs_list_header" + app:layout_constraintTop_toBottomOf="@+id/dirs_list_header_divider" /> + app:layout_constraintTop_toTopOf="@+id/dirs_recycler" /> diff --git a/app/src/main/res/layout/dialog_playlist_export.xml b/app/src/main/res/layout/dialog_playlist_export.xml index 15f12f8bb..6809ad38d 100644 --- a/app/src/main/res/layout/dialog_playlist_export.xml +++ b/app/src/main/res/layout/dialog_playlist_export.xml @@ -45,7 +45,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="@string/lbl_path_style_absolute"/> + android:text="@string/lbl_path_style_absolute" /> @@ -69,10 +69,10 @@ 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" - android:text="@string/lbl_windows_paths" /> + tools:ignore="RtlSymmetry,contentDescription" /> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_playlist_name.xml b/app/src/main/res/layout/dialog_playlist_name.xml index 5d237fd74..f181cd72e 100644 --- a/app/src/main/res/layout/dialog_playlist_name.xml +++ b/app/src/main/res/layout/dialog_playlist_name.xml @@ -1,12 +1,12 @@ + android:layout_height="match_parent" + android:paddingBottom="@dimen/spacing_tiny"> + android:orientation="vertical" + android:paddingBottom="@dimen/spacing_tiny"> + app:menu="@menu/toolbar_selection" + app:navigationIcon="@drawable/ic_close_24" /> + app:menu="@menu/toolbar_edit" + app:navigationIcon="@drawable/ic_close_24" /> diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index d24c1a2d3..78100da62 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -32,8 +32,8 @@ android:layout_height="wrap_content" android:clickable="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" /> @@ -71,8 +71,8 @@ android:layout_height="wrap_content" android:layout_gravity="center" android:layout_margin="@dimen/spacing_medium" - android:visibility="invisible" - android:fitsSystemWindows="true"> + android:fitsSystemWindows="true" + android:visibility="invisible"> @@ -149,42 +149,40 @@ + android:clipChildren="false" + android:clipToPadding="false" + app:layout_anchor="@id/home_content"> - + - - - - diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 98328654d..986aab759 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -13,9 +13,9 @@ android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="match_parent" + app:defaultNavHost="true" app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior" app:navGraph="@navigation/inner" - app:defaultNavHost="true" tools:layout="@layout/fragment_home" /> + android:layout_height="@dimen/size_bottom_sheet_bar" + android:contentDescription="@string/desc_queue_bar"> + app:menu="@menu/toolbar_selection" + app:navigationIcon="@drawable/ic_close_24" /> diff --git a/app/src/main/res/layout/item_album_song.xml b/app/src/main/res/layout/item_album_song.xml index 3d495dd40..0e1fc4254 100644 --- a/app/src/main/res/layout/item_album_song.xml +++ b/app/src/main/res/layout/item_album_song.xml @@ -26,9 +26,9 @@ android:id="@+id/song_track_placeholder" android:layout_width="match_parent" android:layout_height="match_parent" - android:src="@drawable/ic_song_24" - android:scaleType="center" android:contentDescription="@string/def_track" + android:scaleType="center" + android:src="@drawable/ic_song_24" android:visibility="invisible" app:tint="@color/sel_on_cover_bg" tools:ignore="ContentDescription" /> diff --git a/app/src/main/res/layout/item_detail_header.xml b/app/src/main/res/layout/item_detail_header.xml index 56c756f38..0ea6a4111 100644 --- a/app/src/main/res/layout/item_detail_header.xml +++ b/app/src/main/res/layout/item_detail_header.xml @@ -5,16 +5,16 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:paddingTop="@dimen/spacing_medium" android:paddingStart="@dimen/spacing_medium" + android:paddingTop="@dimen/spacing_medium" android:paddingEnd="@dimen/spacing_medium" android:paddingBottom="@dimen/spacing_mid_medium"> @@ -54,9 +54,9 @@ android:layout_marginEnd="@dimen/spacing_mid_medium" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" - tools:visibility="gone" app:layout_constraintStart_toEndOf="@+id/disc_cover" app:layout_constraintTop_toBottomOf="@+id/disc_number" - tools:text="Part 1" /> + tools:text="Part 1" + tools:visibility="gone" /> diff --git a/app/src/main/res/layout/item_edit_header.xml b/app/src/main/res/layout/item_edit_header.xml index e3bbb9009..8894c79b0 100644 --- a/app/src/main/res/layout/item_edit_header.xml +++ b/app/src/main/res/layout/item_edit_header.xml @@ -3,17 +3,17 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" + android:layout_height="wrap_content" android:background="?attr/colorSurface" - android:orientation="horizontal" - android:layout_height="wrap_content"> + android:orientation="horizontal"> diff --git a/app/src/main/res/layout/item_header.xml b/app/src/main/res/layout/item_header.xml index 4adf76f0c..66d4b8e45 100644 --- a/app/src/main/res/layout/item_header.xml +++ b/app/src/main/res/layout/item_header.xml @@ -1,9 +1,9 @@ \ No newline at end of file + tools:text="Songs" /> \ No newline at end of file diff --git a/app/src/main/res/layout/item_menu_option.xml b/app/src/main/res/layout/item_menu_option.xml index 894882598..ad603d078 100644 --- a/app/src/main/res/layout/item_menu_option.xml +++ b/app/src/main/res/layout/item_menu_option.xml @@ -3,13 +3,13 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@android:id/title" + style="@style/Widget.Auxio.TextView.Icon.Clickable" android:layout_width="match_parent" android:layout_height="wrap_content" android:clickable="true" android:focusable="true" android:padding="@dimen/spacing_medium" android:textColor="@color/sel_selectable_text_primary" - style="@style/Widget.Auxio.TextView.Icon.Clickable" app:drawableStartCompat="@drawable/ic_edit_24" app:drawableTint="@color/sel_activatable_icon" tools:text="Songs" /> \ No newline at end of file diff --git a/app/src/main/res/layout/item_music_dir.xml b/app/src/main/res/layout/item_music_dir.xml index 48dbddfd8..ae1082de6 100644 --- a/app/src/main/res/layout/item_music_dir.xml +++ b/app/src/main/res/layout/item_music_dir.xml @@ -4,10 +4,10 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingTop="@dimen/spacing_small" - android:paddingBottom="@dimen/spacing_small" android:gravity="center" - android:orientation="horizontal"> + android:orientation="horizontal" + android:paddingTop="@dimen/spacing_small" + android:paddingBottom="@dimen/spacing_small"> + app:layout_constraintTop_toTopOf="parent"> @@ -37,12 +37,12 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginEnd="@dimen/spacing_mid_medium" + android:text="@string/lbl_new_playlist" android:textColor="@color/sel_selectable_text_primary" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@+id/picker_image" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_chainStyle="packed" - android:text="@string/lbl_new_playlist" /> + app:layout_constraintVertical_chainStyle="packed" /> diff --git a/app/src/main/res/layout/item_song_property.xml b/app/src/main/res/layout/item_song_property.xml index bc24a3a3e..893eab015 100644 --- a/app/src/main/res/layout/item_song_property.xml +++ b/app/src/main/res/layout/item_song_property.xml @@ -1,16 +1,16 @@ + app:expandedHintEnabled="false" + tools:hint="@string/lbl_path"> + android:orientation="horizontal"> diff --git a/app/src/main/res/layout/item_sort_mode.xml b/app/src/main/res/layout/item_sort_mode.xml index 7d8129737..79c9d483f 100644 --- a/app/src/main/res/layout/item_sort_mode.xml +++ b/app/src/main/res/layout/item_sort_mode.xml @@ -17,8 +17,8 @@ android:layout_marginStart="@dimen/spacing_mid_medium" android:layout_marginEnd="@dimen/spacing_mid_medium" android:clickable="false" - android:focusable="false" android:drawableTint="?attr/colorControlNormal" + android:focusable="false" android:paddingStart="@dimen/spacing_medium" android:textAlignment="viewStart" android:textAppearance="@style/TextAppearance.Auxio.BodyLarge" diff --git a/app/src/main/res/layout/view_preference_switch.xml b/app/src/main/res/layout/view_preference_switch.xml index 51b1df5ad..ba3fd967a 100644 --- a/app/src/main/res/layout/view_preference_switch.xml +++ b/app/src/main/res/layout/view_preference_switch.xml @@ -2,4 +2,4 @@ \ No newline at end of file + android:layout_height="wrap_content" /> \ No newline at end of file diff --git a/app/src/main/res/menu/album.xml b/app/src/main/res/menu/album.xml index f560baefc..cc3f9e24c 100644 --- a/app/src/main/res/menu/album.xml +++ b/app/src/main/res/menu/album.xml @@ -2,34 +2,34 @@

+ android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_details_24" + android:title="@string/lbl_parent_detail" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_playlist_add_24" + android:title="@string/lbl_playlist_add" /> + android:icon="@drawable/ic_artist_24" + android:title="@string/lbl_artist_details" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/album_song.xml b/app/src/main/res/menu/album_song.xml index fdbc3fc5f..3b68c0766 100644 --- a/app/src/main/res/menu/album_song.xml +++ b/app/src/main/res/menu/album_song.xml @@ -2,34 +2,34 @@ + android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_playlist_add_24" + android:title="@string/lbl_playlist_add" /> + android:icon="@drawable/ic_artist_24" + android:title="@string/lbl_artist_details" /> + android:icon="@drawable/ic_details_24" + android:title="@string/lbl_song_detail" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/artist_album.xml b/app/src/main/res/menu/artist_album.xml index 8c159d9c6..3ec1b90eb 100644 --- a/app/src/main/res/menu/artist_album.xml +++ b/app/src/main/res/menu/artist_album.xml @@ -2,30 +2,30 @@ + android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_details_24" + android:title="@string/lbl_parent_detail" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_playlist_add_24" + android:title="@string/lbl_playlist_add" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/artist_song.xml b/app/src/main/res/menu/artist_song.xml index 803a12785..8a6b33c0b 100644 --- a/app/src/main/res/menu/artist_song.xml +++ b/app/src/main/res/menu/artist_song.xml @@ -2,34 +2,34 @@ + android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_playlist_add_24" + android:title="@string/lbl_playlist_add" /> + android:icon="@drawable/ic_album_24" + android:title="@string/lbl_album_details" /> + android:icon="@drawable/ic_details_24" + android:title="@string/lbl_song_detail" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/detail_album.xml b/app/src/main/res/menu/detail_album.xml index 742abdb11..4dd418008 100644 --- a/app/src/main/res/menu/detail_album.xml +++ b/app/src/main/res/menu/detail_album.xml @@ -2,30 +2,30 @@ + android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_playlist_add_24" + android:title="@string/lbl_playlist_add" /> + android:icon="@drawable/ic_artist_24" + android:title="@string/lbl_artist_details" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/detail_parent.xml b/app/src/main/res/menu/detail_parent.xml index a6a1b6d09..99f35f8ba 100644 --- a/app/src/main/res/menu/detail_parent.xml +++ b/app/src/main/res/menu/detail_parent.xml @@ -2,26 +2,26 @@ + android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_playlist_add_24" + android:title="@string/lbl_playlist_add" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/detail_playlist.xml b/app/src/main/res/menu/detail_playlist.xml index 178588c3b..cea8e98bd 100644 --- a/app/src/main/res/menu/detail_playlist.xml +++ b/app/src/main/res/menu/detail_playlist.xml @@ -2,30 +2,30 @@ + android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_edit_24" + android:title="@string/lbl_rename" /> + android:icon="@drawable/ic_delete_24" + android:title="@string/lbl_delete" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/parent.xml b/app/src/main/res/menu/parent.xml index cae1e0c53..b431eb7b5 100644 --- a/app/src/main/res/menu/parent.xml +++ b/app/src/main/res/menu/parent.xml @@ -2,31 +2,31 @@ + android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_details_24" + android:title="@string/lbl_parent_detail" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_playlist_add_24" + android:title="@string/lbl_playlist_add" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/playback_song.xml b/app/src/main/res/menu/playback_song.xml index 2cfc524b2..87061766b 100644 --- a/app/src/main/res/menu/playback_song.xml +++ b/app/src/main/res/menu/playback_song.xml @@ -7,20 +7,20 @@ android:title="@string/lbl_playlist_add" /> + android:icon="@drawable/ic_artist_24" + android:title="@string/lbl_artist_details" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/playlist.xml b/app/src/main/res/menu/playlist.xml index a5cdca330..afa071eab 100644 --- a/app/src/main/res/menu/playlist.xml +++ b/app/src/main/res/menu/playlist.xml @@ -2,38 +2,38 @@ + android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_details_24" + android:title="@string/lbl_parent_detail" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_edit_24" + android:title="@string/lbl_rename" /> + android:id="@+id/action_export" + android:icon="@drawable/ic_save_24" + android:title="@string/lbl_export" /> + android:icon="@drawable/ic_delete_24" + android:title="@string/lbl_delete" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/playlist_song.xml b/app/src/main/res/menu/playlist_song.xml index 934d8b514..c60b57f89 100644 --- a/app/src/main/res/menu/playlist_song.xml +++ b/app/src/main/res/menu/playlist_song.xml @@ -2,34 +2,34 @@ + android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_details_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_artist_24" + android:title="@string/lbl_artist_details" /> + android:icon="@drawable/ic_album_24" + android:title="@string/lbl_album_details" /> + android:icon="@drawable/ic_details_24" + android:title="@string/lbl_song_detail" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/selection.xml b/app/src/main/res/menu/selection.xml index 1d4f3d94d..61749966e 100644 --- a/app/src/main/res/menu/selection.xml +++ b/app/src/main/res/menu/selection.xml @@ -3,28 +3,28 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> + android:title="@string/lbl_play" + app:showAsAction="never" /> + android:title="@string/lbl_shuffle" + app:showAsAction="never" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_playlist_add_24" + android:title="@string/lbl_playlist_add" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/song.xml b/app/src/main/res/menu/song.xml index d82272d49..fa9f28538 100644 --- a/app/src/main/res/menu/song.xml +++ b/app/src/main/res/menu/song.xml @@ -2,38 +2,38 @@ + android:icon="@drawable/ic_play_24" + android:title="@string/lbl_play" /> + android:icon="@drawable/ic_shuffle_off_24" + android:title="@string/lbl_shuffle" /> + android:icon="@drawable/ic_play_next_24" + android:title="@string/lbl_play_next" /> + android:icon="@drawable/ic_queue_add_24" + android:title="@string/lbl_queue_add" /> + android:icon="@drawable/ic_playlist_add_24" + android:title="@string/lbl_playlist_add" /> + android:icon="@drawable/ic_artist_24" + android:title="@string/lbl_artist_details" /> + android:icon="@drawable/ic_album_24" + android:title="@string/lbl_album_details" /> + android:icon="@drawable/ic_details_24" + android:title="@string/lbl_song_detail" /> + android:icon="@drawable/ic_share_24" + android:title="@string/lbl_share" /> \ No newline at end of file diff --git a/app/src/main/res/menu/toolbar_edit.xml b/app/src/main/res/menu/toolbar_edit.xml index 10ac3d9ef..c3436fee7 100644 --- a/app/src/main/res/menu/toolbar_edit.xml +++ b/app/src/main/res/menu/toolbar_edit.xml @@ -3,7 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> + app:showAsAction="always" /> \ No newline at end of file diff --git a/app/src/main/res/menu/toolbar_selection.xml b/app/src/main/res/menu/toolbar_selection.xml index e1cf43ef0..dadf1e709 100644 --- a/app/src/main/res/menu/toolbar_selection.xml +++ b/app/src/main/res/menu/toolbar_selection.xml @@ -3,14 +3,14 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> + android:title="@string/lbl_play_next" + app:showAsAction="ifRoom" /> + android:title="@string/lbl_playlist_add" + app:showAsAction="ifRoom" /> - + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index a95e572f9..32c6c4b64 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -2,9 +2,13 @@ - + - + \ No newline at end of file From 21970349ccceb2a59000107e9a95cf0a59163e22 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 23 Dec 2023 20:47:21 -0700 Subject: [PATCH 41/72] music: add ability to import into playlists Add a menu option that allows you to import a playlist file into an existing playlist. This is useful for keeping Auxio playlists up to date with a remote source. --- .../auxio/detail/AlbumDetailFragment.kt | 1 + .../auxio/detail/ArtistDetailFragment.kt | 1 + .../auxio/detail/GenreDetailFragment.kt | 1 + .../auxio/detail/PlaylistDetailFragment.kt | 27 ++++++++++ .../org/oxycblt/auxio/home/HomeFragment.kt | 41 +++++--------- .../auxio/list/menu/MenuDialogFragmentImpl.kt | 5 +- .../org/oxycblt/auxio/music/MusicViewModel.kt | 53 +++++++++++++------ .../oxycblt/auxio/search/SearchFragment.kt | 29 ++++++++++ app/src/main/res/menu/playlist.xml | 4 ++ app/src/main/res/values/strings.xml | 1 + 10 files changed, 118 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 16aed1ce6..b2bef79d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -273,6 +273,7 @@ class AlbumDetailFragment : decision.songs.map { it.uid }.toTypedArray()) } is PlaylistDecision.New, + is PlaylistDecision.Import, is PlaylistDecision.Rename, is PlaylistDecision.Delete, is PlaylistDecision.Export -> error("Unexpected playlist decision $decision") diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index d217806e7..f45fa5d34 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -276,6 +276,7 @@ class ArtistDetailFragment : decision.songs.map { it.uid }.toTypedArray()) } is PlaylistDecision.New, + is PlaylistDecision.Import, is PlaylistDecision.Rename, is PlaylistDecision.Export, is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index aaa31f8c1..b5f5550fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -269,6 +269,7 @@ class GenreDetailFragment : decision.songs.map { it.uid }.toTypedArray()) } is PlaylistDecision.New, + is PlaylistDecision.Import, is PlaylistDecision.Rename, is PlaylistDecision.Export, is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index a312079ef..aa9039881 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -21,6 +21,8 @@ package org.oxycblt.auxio.detail import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -48,12 +50,14 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.external.M3U import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.setFullWidthLookup @@ -80,6 +84,8 @@ class PlaylistDetailFragment : private val playlistListAdapter = PlaylistDetailListAdapter(this) private var touchHelper: ItemTouchHelper? = null private var editNavigationListener: DialogAwareNavigationListener? = null + private var getContentLauncher: ActivityResultLauncher? = null + private var pendingImportTarget: Playlist? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -99,6 +105,17 @@ class PlaylistDetailFragment : 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 --- binding.detailNormalToolbar.apply { setNavigationOnClickListener { findNavController().navigateUp() } @@ -320,6 +337,16 @@ class PlaylistDetailFragment : if (decision == null) return val directions = 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 -> { logD("Renaming ${decision.playlist}") PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 2ce4dc107..cb88c6241 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -102,8 +102,7 @@ class HomeFragment : private val detailModel: DetailViewModel by activityViewModels() private var storagePermissionLauncher: ActivityResultLauncher? = null private var getContentLauncher: ActivityResultLauncher? = null - private var createDocumentLauncher: ActivityResultLauncher? = null - private var pendingExportPlaylist: Playlist? = null + private var pendingImportTarget: Playlist? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -140,25 +139,7 @@ class HomeFragment : } logD("Received playlist URI $uri") - musicModel.importPlaylist(uri) - } - - createDocumentLauncher = - registerForActivityResult(ActivityResultContracts.CreateDocument(M3U.MIME_TYPE)) { uri - -> - if (uri == null) { - logW("No URI returned from file picker") - return@registerForActivityResult - } - - val playlist = pendingExportPlaylist - if (playlist == null) { - logW("No playlist to export") - return@registerForActivityResult - } - - logD("Received playlist URI $uri") - musicModel.exportPlaylist(playlist, uri) + musicModel.importPlaylist(uri, pendingImportTarget) } // --- UI SETUP --- @@ -209,10 +190,7 @@ class HomeFragment : // re-creating the ViewPager. setupPager(binding) - binding.homeShuffleFab.setOnClickListener { - logD("Shuffling") - playbackModel.shuffleAll() - } + binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() } binding.homeNewPlaylistFab.apply { inflate(R.menu.new_playlist_actions) @@ -318,7 +296,7 @@ class HomeFragment : } R.id.action_import_playlist -> { logD("Importing playlist") - getContentLauncher?.launch(M3U.MIME_TYPE) + musicModel.importPlaylist() } else -> {} } @@ -494,6 +472,16 @@ class HomeFragment : logD("Creating new playlist") HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray()) } + 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 -> { logD("Renaming ${decision.playlist}") HomeFragmentDirections.renamePlaylist(decision.playlist.uid) @@ -513,7 +501,6 @@ class HomeFragment : } } findNavController().navigateSafe(directions) - musicModel.playlistDecision.consume() } private fun handlePlaylistError(error: PlaylistError?) { diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt index 3df0f5541..238d315da 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt @@ -288,7 +288,7 @@ class PlaylistMenuDialogFragment : MenuDialogFragment() { R.id.action_play_next, R.id.action_queue_add, R.id.action_playlist_add, - R.id.action_playlist_export, + R.id.action_export, R.id.action_share) } else { setOf() @@ -321,7 +321,8 @@ class PlaylistMenuDialogFragment : MenuDialogFragment() { requireContext().showToast(R.string.lng_queue_added) } R.id.action_rename -> musicModel.renamePlaylist(menu.playlist) - R.id.action_playlist_export -> musicModel.exportPlaylist(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_share -> requireContext().share(menu.playlist) else -> error("Unexpected menu item $item") diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 45e8ff499..c440d6e9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -137,28 +137,41 @@ constructor( /** * Import a playlist from a file [Uri]. Errors pushed to [importError]. * - * @param uri The [Uri] of the file to import. + * @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) = - viewModelScope.launch(Dispatchers.IO) { - val importedPlaylist = externalPlaylistManager.import(uri) - if (importedPlaylist == null) { - _playlistError.put(PlaylistError.ImportFailed) - return@launch - } + fun importPlaylist(uri: Uri? = null, target: Playlist? = null) { + if (uri != null) { + viewModelScope.launch(Dispatchers.IO) { + val importedPlaylist = externalPlaylistManager.import(uri) + if (importedPlaylist == null) { + _playlistError.put(PlaylistError.ImportFailed) + return@launch + } - val deviceLibrary = musicRepository.deviceLibrary ?: return@launch - val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) + val deviceLibrary = musicRepository.deviceLibrary ?: return@launch + val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) - if (songs.isEmpty()) { - _playlistError.put(PlaylistError.ImportFailed) - return@launch + if (songs.isEmpty()) { + _playlistError.put(PlaylistError.ImportFailed) + return@launch + } + // TODO Require the user to name it something else if the name is a duplicate of + // a prior playlist + if (target !== null) { + musicRepository.rewritePlaylist(target, songs) + } else { + createPlaylist(importedPlaylist.name, songs) + } } - // TODO Require the user to name it something else if the name is a duplicate of - // a prior playlist - createPlaylist(importedPlaylist.name, songs) + } else { + logD("Launching import picker") + _playlistDecision.put(PlaylistDecision.Import(target)) } + } /** * Export a [Playlist] to a file [Uri]. Errors pushed to [exportError]. @@ -304,6 +317,14 @@ sealed interface PlaylistDecision { */ data class New(val songs: List) : PlaylistDecision + /** + * 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]. * diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index c115a156b..e99fd3bfd 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -23,6 +23,8 @@ import android.view.LayoutInflater import android.view.MenuItem import android.view.View 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.postDelayed import androidx.core.widget.addTextChangedListener @@ -51,6 +53,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.external.M3U import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect @@ -58,6 +61,7 @@ import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.setFullWidthLookup @@ -77,6 +81,8 @@ class SearchFragment : ListFragment() { override val playbackModel: PlaybackViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() private val searchAdapter = SearchAdapter(this) + private var getContentLauncher: ActivityResultLauncher? = null + private var pendingImportTarget: Playlist? = null private var imm: InputMethodManager? = null private var launchedKeyboard = false @@ -98,6 +104,19 @@ class SearchFragment : ListFragment() { 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 { // Initialize the current filtering mode. menu.findItem(searchModel.getFilterOptionId()).isChecked = true @@ -287,6 +306,16 @@ class SearchFragment : ListFragment() { if (decision == null) return val directions = 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 -> { logD("Renaming ${decision.playlist}") SearchFragmentDirections.renamePlaylist(decision.playlist.uid) diff --git a/app/src/main/res/menu/playlist.xml b/app/src/main/res/menu/playlist.xml index afa071eab..c56c2f799 100644 --- a/app/src/main/res/menu/playlist.xml +++ b/app/src/main/res/menu/playlist.xml @@ -24,6 +24,10 @@ android:id="@+id/action_rename" android:icon="@drawable/ic_edit_24" android:title="@string/lbl_rename" /> + New playlist Empty playlist Imported playlist + Import Export Export playlist Rename From c5a3f72b9935097b902419633a57d1ae4120c271 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 23 Dec 2023 22:00:38 -0700 Subject: [PATCH 42/72] music: add more playlist messages Add more types of playlist messages corresponding to other actions, so they can be indicated in the UI only when the process is complete. This is somewhat incomplete. It does not include indicating errors for other playlist operations (Which I want to do), and neither does it handle situations in which some playlist operations and up reducing to others (i.e import -> create). I need to do that later. --- .../auxio/detail/AlbumDetailFragment.kt | 9 ++ .../auxio/detail/ArtistDetailFragment.kt | 9 ++ .../auxio/detail/GenreDetailFragment.kt | 13 ++- .../auxio/detail/PlaylistDetailFragment.kt | 13 ++- .../org/oxycblt/auxio/home/HomeFragment.kt | 21 ++--- .../org/oxycblt/auxio/music/MusicViewModel.kt | 93 ++++++++++++++----- .../music/decision/AddToPlaylistDialog.kt | 2 - .../music/decision/DeletePlaylistDialog.kt | 2 - .../music/decision/ExportPlaylistDialog.kt | 1 - .../auxio/music/decision/NewPlaylistDialog.kt | 2 - .../music/decision/RenamePlaylistDialog.kt | 2 - .../oxycblt/auxio/search/SearchFragment.kt | 13 ++- app/src/main/res/values/strings.xml | 2 + 13 files changed, 128 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index b2bef79d1..e041909cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -44,6 +44,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.PlaylistDecision +import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Disc 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.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.setFullWidthLookup +import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -126,6 +128,7 @@ class AlbumDetailFragment : collect(listModel.menu.flow, ::handleMenu) collectImmediately(listModel.selected, ::updateSelection) collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision) + collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) @@ -281,6 +284,12 @@ class AlbumDetailFragment : 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) { albumListAdapter.setPlaying( song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index f45fa5d34..bdd5b04af 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -45,6 +45,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.PlaylistDecision +import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackDecision 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.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.setFullWidthLookup +import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -128,6 +130,7 @@ class ArtistDetailFragment : collect(listModel.menu.flow, ::handleMenu) collectImmediately(listModel.selected, ::updateSelection) collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision) + collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) @@ -284,6 +287,12 @@ class ArtistDetailFragment : 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) { val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value) val playingItem = diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index b5f5550fd..79ad38f23 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -45,6 +45,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.PlaylistDecision +import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackDecision 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.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.setFullWidthLookup +import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -125,7 +127,8 @@ class GenreDetailFragment : collect(detailModel.toShow.flow, ::handleShow) collect(listModel.menu.flow, ::handleMenu) collectImmediately(listModel.selected, ::updateSelection) - collect(musicModel.playlistDecision.flow, ::handleDecision) + collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision) + collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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 val directions = when (decision) { @@ -277,6 +280,12 @@ class GenreDetailFragment : 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) { val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value) val playingItem = diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index aa9039881..40a03c6e8 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -49,6 +49,7 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.PlaylistDecision +import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.external.M3U import org.oxycblt.auxio.playback.PlaybackDecision @@ -61,6 +62,7 @@ import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.setFullWidthLookup +import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -159,7 +161,8 @@ class PlaylistDetailFragment : collect(detailModel.toShow.flow, ::handleShow) collect(listModel.menu.flow, ::handleMenu) collectImmediately(listModel.selected, ::updateSelection) - collect(musicModel.playlistDecision.flow, ::handleDecision) + collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision) + collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) @@ -333,7 +336,7 @@ class PlaylistDetailFragment : updateMultiToolbar() } - private fun handleDecision(decision: PlaylistDecision?) { + private fun handlePlaylistDecision(decision: PlaylistDecision?) { if (decision == null) return val directions = when (decision) { @@ -369,6 +372,12 @@ class PlaylistDetailFragment : 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) { // Prefer songs that are playing from this playlist. playlistListAdapter.setPlaying( diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index cb88c6241..56047aa1d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -70,7 +70,7 @@ import org.oxycblt.auxio.music.NoMusicException 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.PlaylistError +import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.external.M3U import org.oxycblt.auxio.playback.PlaybackViewModel @@ -211,7 +211,7 @@ class HomeFragment : collectImmediately(listModel.selected, ::updateSelection) collectImmediately(musicModel.indexingState, ::updateIndexerState) collect(musicModel.playlistDecision.flow, ::handleDecision) - collectImmediately(musicModel.playlistError.flow, ::handlePlaylistError) + collectImmediately(musicModel.playlistMessage.flow, ::handlePlaylistMessage) collect(detailModel.toShow.flow, ::handleShow) } @@ -503,19 +503,10 @@ class HomeFragment : findNavController().navigateSafe(directions) } - private fun handlePlaylistError(error: PlaylistError?) { - when (error) { - is PlaylistError.ImportFailed -> { - requireContext().showToast(R.string.err_import_failed) - musicModel.importError.consume() - } - is PlaylistError.ExportFailed -> { - requireContext().showToast(R.string.err_export_failed) - musicModel.importError.consume() - } - null -> {} - } - musicModel.playlistError.consume() + private fun handlePlaylistMessage(message: PlaylistMessage?) { + if (message == null) return + requireContext().showToast(message.stringRes) + musicModel.playlistMessage.consume() } private fun updateFab(songs: List, isFastScrolling: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index c440d6e9c..175959225 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import org.oxycblt.auxio.R import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.music.external.ExportConfig import org.oxycblt.auxio.music.external.ExternalPlaylistManager @@ -65,19 +66,9 @@ constructor( val playlistDecision: Event get() = _playlistDecision - private val _playlistError = MutableEvent() - val playlistError: Event - get() = _playlistError - - private val _importError = MutableEvent() - /** Flag for when playlist importing failed. Consume this and show an error if active. */ - val importError: Event - get() = _importError - - private val _exportError = MutableEvent() - /** Flag for when playlist exporting failed. Consume this and show an error if active. */ - val exportError: Event - get() = _exportError + private val _playlistMessage = MutableEvent() + val playlistMessage: Event + get() = _playlistMessage init { musicRepository.addUpdateListener(this) @@ -127,7 +118,10 @@ constructor( fun createPlaylist(name: String? = null, songs: List = listOf()) { if (name != null) { logD("Creating $name with ${songs.size} songs]") - viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) } + viewModelScope.launch(Dispatchers.IO) { + musicRepository.createPlaylist(name, songs) + _playlistMessage.put(PlaylistMessage.NewPlaylistSuccess) + } } else { logD("Launching creation dialog for ${songs.size} songs") _playlistDecision.put(PlaylistDecision.New(songs)) @@ -148,7 +142,7 @@ constructor( viewModelScope.launch(Dispatchers.IO) { val importedPlaylist = externalPlaylistManager.import(uri) if (importedPlaylist == null) { - _playlistError.put(PlaylistError.ImportFailed) + _playlistMessage.put(PlaylistMessage.ImportFailed) return@launch } @@ -156,14 +150,16 @@ constructor( val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) if (songs.isEmpty()) { - _playlistError.put(PlaylistError.ImportFailed) + _playlistMessage.put(PlaylistMessage.ImportFailed) return@launch } // TODO Require the user to name it something else if the name is a duplicate of // a prior playlist if (target !== null) { musicRepository.rewritePlaylist(target, songs) + _playlistMessage.put(PlaylistMessage.ImportSuccess) } else { + // TODO: Have to properly propagate the "Playlist Created" message createPlaylist(importedPlaylist.name, songs) } } @@ -183,8 +179,10 @@ constructor( if (uri != null && config != null) { logD("Exporting playlist to $uri") viewModelScope.launch(Dispatchers.IO) { - if (!externalPlaylistManager.export(playlist, uri, config)) { - _playlistError.put(PlaylistError.ExportFailed) + if (externalPlaylistManager.export(playlist, uri, config)) { + _playlistMessage.put(PlaylistMessage.ExportSuccess) + } else { + _playlistMessage.put(PlaylistMessage.ExportFailed) } } } else { @@ -202,7 +200,10 @@ constructor( fun renamePlaylist(playlist: Playlist, name: String? = null) { if (name != null) { logD("Renaming $playlist to $name") - viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) } + viewModelScope.launch(Dispatchers.IO) { + musicRepository.renamePlaylist(playlist, name) + _playlistMessage.put(PlaylistMessage.RenameSuccess) + } } else { logD("Launching rename dialog for $playlist") _playlistDecision.put(PlaylistDecision.Rename(playlist)) @@ -219,7 +220,10 @@ constructor( fun deletePlaylist(playlist: Playlist, rude: Boolean = false) { if (rude) { logD("Deleting $playlist") - viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) } + viewModelScope.launch(Dispatchers.IO) { + musicRepository.deletePlaylist(playlist) + _playlistMessage.put(PlaylistMessage.DeleteSuccess) + } } else { logD("Launching deletion dialog for $playlist") _playlistDecision.put(PlaylistDecision.Delete(playlist)) @@ -279,7 +283,10 @@ constructor( fun addToPlaylist(songs: List, playlist: Playlist? = null) { if (playlist != null) { 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 { logD("Launching addition dialog for songs=${songs.size}") _playlistDecision.put(PlaylistDecision.Add(songs)) @@ -354,8 +361,46 @@ sealed interface PlaylistDecision { data class Add(val songs: List) : PlaylistDecision } -sealed interface PlaylistError { - data object ImportFailed : PlaylistError +sealed interface PlaylistMessage { + val stringRes: Int - data object ExportFailed : PlaylistError + 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 + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt index 51dcfcf6c..e84deb420 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt @@ -37,7 +37,6 @@ import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD 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. @@ -86,7 +85,6 @@ class AddToPlaylistDialog : override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) { musicModel.addToPlaylist(pickerModel.currentSongsToAdd.value ?: return, item.playlist) - requireContext().showToast(R.string.lng_playlist_added) findNavController().navigateUp() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/DeletePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/DeletePlaylistDialog.kt index a1683c6b0..0e97dea4b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/DeletePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/DeletePlaylistDialog.kt @@ -33,7 +33,6 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -56,7 +55,6 @@ class DeletePlaylistDialog : ViewBindingMaterialDialogFragment val current = pickerModel.currentExportConfig.value - logD("change") pickerModel.setExportConfig(current.copy(windowsPaths = !current.windowsPaths)) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt index 46a2b3d0a..6cefe9730 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt @@ -33,7 +33,6 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -62,7 +61,6 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment() { collectImmediately(searchModel.searchResults, ::updateSearchResults) collectImmediately(listModel.selected, ::updateSelection) collect(listModel.menu.flow, ::handleMenu) - collect(musicModel.playlistDecision.flow, ::handleDecision) + collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision) + collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) @@ -302,7 +305,7 @@ class SearchFragment : ListFragment() { } } - private fun handleDecision(decision: PlaylistDecision?) { + private fun handlePlaylistDecision(decision: PlaylistDecision?) { if (decision == null) return val directions = when (decision) { @@ -340,6 +343,12 @@ class SearchFragment : ListFragment() { 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) { searchAdapter.setPlaying(parent ?: song, isPlaying) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e77064f41..2474e88de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -196,7 +196,9 @@ Monitoring your music library for changes… Added to queue Playlist created + Playlist imported Playlist renamed + Playlist exported Playlist deleted Added to playlist Developed by Alexander Capehart From 480b1b28e5b2d8c40686641195afc12692681121 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 24 Dec 2023 11:32:47 -0700 Subject: [PATCH 43/72] music: display correct message on new playlist The context of the "New Playlist" dialog can differ depending on the action performed, such as adding to a playlist or importing a playlist. We need to make sure we're still showing the right message once this is done. --- .../org/oxycblt/auxio/home/HomeFragment.kt | 3 +- .../org/oxycblt/auxio/music/MusicViewModel.kt | 32 ++++++++++++++++--- .../music/decision/AddToPlaylistDialog.kt | 4 ++- .../auxio/music/decision/NewPlaylistDialog.kt | 4 +-- .../music/decision/PlaylistPickerViewModel.kt | 20 +++++++++--- app/src/main/res/navigation/inner.xml | 3 ++ 6 files changed, 53 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 56047aa1d..24b69eff9 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -470,7 +470,8 @@ class HomeFragment : when (decision) { is PlaylistDecision.New -> { logD("Creating new playlist") - HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray()) + HomeFragmentDirections.newPlaylist( + decision.songs.map { it.uid }.toTypedArray(), decision.reason) } is PlaylistDecision.Import -> { logD("Importing playlist") diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 175959225..589d9a496 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -34,6 +34,7 @@ import org.oxycblt.auxio.music.external.ExternalPlaylistManager import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE /** * A [ViewModel] providing data specific to the music loading process. @@ -114,17 +115,29 @@ constructor( * * @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 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 = listOf()) { + fun createPlaylist( + name: String? = null, + songs: List = listOf(), + reason: PlaylistDecision.New.Reason = PlaylistDecision.New.Reason.NEW + ) { if (name != null) { logD("Creating $name with ${songs.size} songs]") viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) - _playlistMessage.put(PlaylistMessage.NewPlaylistSuccess) + 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 { logD("Launching creation dialog for ${songs.size} songs") - _playlistDecision.put(PlaylistDecision.New(songs)) + _playlistDecision.put(PlaylistDecision.New(songs, reason)) } } @@ -142,6 +155,7 @@ constructor( viewModelScope.launch(Dispatchers.IO) { val importedPlaylist = externalPlaylistManager.import(uri) if (importedPlaylist == null) { + logE("Could not import playlist") _playlistMessage.put(PlaylistMessage.ImportFailed) return@launch } @@ -150,6 +164,7 @@ constructor( val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) if (songs.isEmpty()) { + logE("No songs found") _playlistMessage.put(PlaylistMessage.ImportFailed) return@launch } @@ -160,7 +175,7 @@ constructor( _playlistMessage.put(PlaylistMessage.ImportSuccess) } else { // TODO: Have to properly propagate the "Playlist Created" message - createPlaylist(importedPlaylist.name, songs) + createPlaylist(importedPlaylist.name, songs, PlaylistDecision.New.Reason.IMPORT) } } } else { @@ -321,8 +336,15 @@ sealed interface PlaylistDecision { * 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 context The context in which this decision is being fulfilled. */ - data class New(val songs: List) : PlaylistDecision + data class New(val songs: List, val reason: Reason) : PlaylistDecision { + enum class Reason { + NEW, + ADD, + IMPORT + } + } /** * Navigate to a file picker to import a playlist from. diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt index e84deb420..66a1d5507 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately @@ -98,7 +99,8 @@ class AddToPlaylistDialog : val songs = pickerModel.currentSongsToAdd.value ?: return findNavController() .navigateSafe( - AddToPlaylistDialogDirections.newPlaylist(songs.map { it.uid }.toTypedArray())) + AddToPlaylistDialogDirections.newPlaylist( + songs.map { it.uid }.toTypedArray(), PlaylistDecision.New.Reason.ADD)) } private fun updatePendingSongs(songs: List?) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt index 6cefe9730..9d8a44742 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt @@ -60,7 +60,7 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment throw IllegalStateException() } // TODO: Navigate to playlist if there are songs in it - musicModel.createPlaylist(name, pendingPlaylist.songs) + musicModel.createPlaylist(name, pendingPlaylist.songs, pendingPlaylist.reason) findNavController().apply { navigateUp() // Do an additional navigation away from the playlist addition dialog, if @@ -82,7 +82,7 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment PendingPlaylist( pendingPlaylist.preferredName, - pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) + pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }, + pendingPlaylist.reason) } logD("Updated pending playlist: ${_currentPendingPlaylist.value?.preferredName}") @@ -143,8 +145,13 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * * @param context [Context] required to generate a playlist name. * @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) { + fun setPendingPlaylist( + context: Context, + songUids: Array, + reason: PlaylistDecision.New.Reason + ) { logD("Opening ${songUids.size} songs to create a playlist from") val userLibrary = musicRepository.userLibrary ?: return val songs = @@ -168,7 +175,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M _currentPendingPlaylist.value = if (possibleName != null && songs != null) { - PendingPlaylist(possibleName, songs) + PendingPlaylist(possibleName, songs, reason) } else { logW("Given song UIDs to create were invalid") null @@ -295,9 +302,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * * @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 reason The reason the playlist is being created. * @author Alexander Capehart (OxygenCobalt) */ -data class PendingPlaylist(val preferredName: String, val songs: List) +data class PendingPlaylist( + val preferredName: String, + val songs: List, + val reason: PlaylistDecision.New.Reason +) /** * Represents the (processed) user input from the playlist naming dialogs. diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index 64b8ff95b..95c6a7770 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -413,6 +413,9 @@ + Date: Sun, 24 Dec 2023 11:35:10 -0700 Subject: [PATCH 44/72] music: fix m3u windows path importing Forgot that Regex always matches the entire string, so I have to tack on a wildcard. --- app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 1139cf87b..53767789a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -262,6 +262,6 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte } private companion object { - val WINDOWS_VOLUME_PREFIX_REGEX = Regex("^[A-Za-z]:\\\\") + val WINDOWS_VOLUME_PREFIX_REGEX = Regex("^[A-Za-z]:\\\\*") } } From c7f8b3ca6d313b206fb22f3ca493177a16660197 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 31 Dec 2023 23:34:00 -0700 Subject: [PATCH 45/72] music: refactor mediastoreextractor internals Split the version-specific components into "Interpreters" that are then composed into MediaStoreExtractor. This is both a nicer design and also allows me to resolve an evil Huawei bug that prevents me from using the new path fields. Resolves #592 --- .../auxio/music/fs/MediaStoreExtractor.kt | 570 ++++++++---------- 1 file changed, 262 insertions(+), 308 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index df9a979a2..137692389 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -22,7 +22,6 @@ import android.content.Context import android.database.Cursor import android.os.Build import android.provider.MediaStore -import androidx.annotation.RequiresApi import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull import java.io.File @@ -95,69 +94,77 @@ interface MediaStoreExtractor { * @param volumeManager [VolumeManager] required. * @return A new [MediaStoreExtractor] that will work best on the device's API level. */ - fun from(context: Context, volumeManager: VolumeManager): MediaStoreExtractor = - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> - Api30MediaStoreExtractor(context, volumeManager) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> - Api29MediaStoreExtractor(context, volumeManager) - else -> Api21MediaStoreExtractor(context, volumeManager) - } + fun from(context: Context, volumeManager: VolumeManager): MediaStoreExtractor { + val pathInterpreter = + 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 -> + DataPathInterpreter.Factory(volumeManager) + else -> VolumePathInterpreter.Factory(volumeManager) + } + + val volumeInterpreter = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30TagInterpreter.Factory() + else -> Api21TagInterpreter.Factory() + } + + return MediaStoreExtractorImpl(context, pathInterpreter, volumeInterpreter) + } } } -private abstract class BaseMediaStoreExtractor(protected val context: Context) : - MediaStoreExtractor { - final override suspend fun query( +private class MediaStoreExtractorImpl( + private val context: Context, + private val pathInterpreterFactory: PathInterpreterFactory, + private val tagInterpreterFactory: TagInterpreterFactory +) : MediaStoreExtractor { + override suspend fun query( constraints: MediaStoreExtractor.Constraints ): MediaStoreExtractor.Query { val start = System.currentTimeMillis() - val args = mutableListOf() - var selector = BASE_SELECTOR + val projection = + BASE_PROJECTION + pathInterpreterFactory.projection + tagInterpreterFactory.projection + var uniSelector = BASE_SELECTOR + var uniArgs = listOf() // Filter out audio that is not music, if enabled. if (constraints.excludeNonMusic) { 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. if (constraints.musicDirs.dirs.isNotEmpty()) { - selector += " AND " - if (!constraints.musicDirs.shouldInclude) { - logD("Excluding directories in selector") - // Without a NOT, the query will be restricted to the specified paths, resulting - // in the "Include" mode. With a NOT, the specified paths will not be included, - // resulting in the "Exclude" mode. - selector += "NOT " - } - selector += " (" - - // Specifying the paths to filter is version-specific, delegate to the concrete - // implementations. - for (i in constraints.musicDirs.dirs.indices) { - if (addDirToSelector(constraints.musicDirs.dirs[i], args)) { - selector += - if (i < constraints.musicDirs.dirs.lastIndex) { - "$dirSelectorTemplate OR " - } else { - dirSelectorTemplate - } + val pathSelector = pathInterpreterFactory.createSelector(constraints.musicDirs.dirs) + if (pathSelector != null) { + logD("Must select for directories") + uniSelector += " AND " + if (!constraints.musicDirs.shouldInclude) { + logD("Excluding directories in selector") + // Without a NOT, the query will be restricted to the specified paths, resulting + // in the "Include" mode. With a NOT, the specified paths will not be included, + // resulting in the "Exclude" mode. + uniSelector += "NOT " } + uniSelector += " (${pathSelector.template})" + uniArgs = pathSelector.args } - - selector += ')' } // 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 = context.contentResolverSafe.safeQuery( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, - selector, - args.toTypedArray()) + uniSelector, + uniArgs.toTypedArray()) logD("Successfully queried for ${cursor.count} songs") val genreNamesMap = mutableMapOf() @@ -195,10 +202,14 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) : logD("Read ${genreNamesMap.values.distinct().size} genres from MediaStore") logD("Finished initialization in ${System.currentTimeMillis() - start}ms") - return wrapQuery(cursor, genreNamesMap) + return QueryImpl( + cursor, + pathInterpreterFactory.wrap(cursor), + tagInterpreterFactory.wrap(cursor), + genreNamesMap) } - final override suspend fun consume( + override suspend fun consume( query: MediaStoreExtractor.Query, cache: Cache?, incompleteSongs: Channel, @@ -220,54 +231,10 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) : query.close() } - /** - * The database columns available to all android versions supported by Auxio. Concrete - * implementations can extend this projection to add version-specific columns. - */ - protected open val projection: Array - 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 [SystemPath] to the given list of projection selector arguments. - * - * @param path The [SystemPath] to add. - * @param args The destination list to append selector arguments to that are analogous to the - * given [SystemPath]. - * @return true if the [SystemPath] was added, false otherwise. - * @see dirSelectorTemplate - */ - protected abstract fun addDirToSelector(path: Path, args: MutableList): Boolean - - protected abstract fun wrapQuery( - cursor: Cursor, - genreNamesMap: Map - ): MediaStoreExtractor.Query - - abstract class Query( - protected val cursor: Cursor, + class QueryImpl( + private val cursor: Cursor, + private val pathInterpreter: PathInterpreter, + private val tagInterpreter: TagInterpreter, private val genreNamesMap: Map ) : MediaStoreExtractor.Query { private val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) @@ -290,11 +257,11 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) : private val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.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) { rawSong.mediaStoreId = cursor.getLong(idIndex) @@ -305,6 +272,7 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) : rawSong.fileName = cursor.getStringOrNull(displayNameIndex) rawSong.extensionMimeType = cursor.getString(mimeTypeIndex) rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex) + pathInterpreter.populate(rawSong) } override fun populateTags(rawSong: RawSong) { @@ -335,16 +303,12 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) : cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) } // Get the genre value we had to query for in initialization genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) } + // Get version/device-specific tags + tagInterpreter.populate(rawSong) } } 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 * 30 it was an undocumented extension for Google Play Music. This column will work on all @@ -358,254 +322,244 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) : * until API 29. This will work on all versions that Auxio supports. */ @Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL + + /** + * 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 base projection that works across all versions of android. */ + private val BASE_PROJECTION = + 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) } } -// 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. +interface Interpreter { + fun populate(rawSong: RawSong) +} -private class Api21MediaStoreExtractor(context: Context, private val volumeManager: VolumeManager) : - BaseMediaStoreExtractor(context) { - override val projection: Array - get() = - super.projection + - arrayOf( - MediaStore.Audio.AudioColumns.TRACK, - // Below API 29, we are restricted to the absolute path (Called DATA by - // MediaStore) when working with audio files. - MediaStore.Audio.AudioColumns.DATA) +interface InterpreterFactory { + val projection: Array - // The selector should be configured to convert the given directories instances to their - // absolute paths and then compare them to DATA. + fun wrap(cursor: Cursor): Interpreter +} - override val dirSelectorTemplate: String - get() = "${MediaStore.Audio.Media.DATA} LIKE ?" +interface PathInterpreterFactory : InterpreterFactory { + override fun wrap(cursor: Cursor): PathInterpreter - override fun addDirToSelector(path: Path, args: MutableList): Boolean { - // "%" signifies to accept any DATA value that begins with the Directory's path, - // thus recursively filtering all files in the directory. - args.add("${path.volume.components ?: return false}${path.components}%") - return true + fun createSelector(paths: List): Selector? + + data class Selector(val template: String, val args: List) +} + +interface TagInterpreterFactory : InterpreterFactory { + override fun wrap(cursor: Cursor): TagInterpreter +} + +sealed interface PathInterpreter : Interpreter + +class DataPathInterpreter(private val cursor: Cursor, private val volumeManager: VolumeManager) : + PathInterpreter { + private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA) + private val volumes = volumeManager.getVolumes() + + override fun populate(rawSong: 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.components ?: continue).toString() + val strippedPath = rawPath.removePrefix(volumePath) + if (strippedPath != rawPath) { + rawSong.directory = Path(volume, Components.parseUnix(strippedPath)) + break + } + } } - override fun wrapQuery( - cursor: Cursor, - genreNamesMap: Map, - ): MediaStoreExtractor.Query = Query(cursor, genreNamesMap, volumeManager) + class Factory(private val volumeManager: VolumeManager) : PathInterpreterFactory { + override val projection: Array + get() = arrayOf(MediaStore.Audio.AudioColumns.DATA) - private class Query( - cursor: Cursor, - genreNamesMap: Map, - volumeManager: VolumeManager - ) : 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 = volumeManager.getVolumes() - - 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 } + override fun createSelector(paths: List): PathInterpreterFactory.Selector? { + val args = mutableListOf() + 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}%") } - // 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.components ?: continue).toString() - val strippedPath = rawPath.removePrefix(volumePath) - if (strippedPath != rawPath) { - rawSong.directory = Path(volume, Components.parseUnix(strippedPath)) - break - } + if (template.isEmpty()) { + return null } + + return PathInterpreterFactory.Selector(template, args) } - 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 } - } - } + override fun wrap(cursor: Cursor): PathInterpreter = + DataPathInterpreter(cursor, volumeManager) } } -/** - * A [BaseMediaStoreExtractor] that implements common behavior supported from API 29 onwards. - * - * @param context [Context] required to query the media database. - * @author Alexander Capehart (OxygenCobalt) - */ -@RequiresApi(Build.VERSION_CODES.Q) -private abstract class BaseApi29MediaStoreExtractor(context: Context) : - BaseMediaStoreExtractor(context) { - override val projection: Array - get() = - super.projection + +class VolumePathInterpreter(private val cursor: Cursor, private val volumeManager: VolumeManager) : + PathInterpreter { + 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 populate(rawSong: 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.mediaStoreName == volumeName } + if (volume != null) { + rawSong.directory = Path(volume, Components.parseUnix(relativePath)) + } + } + + class Factory(private val volumeManager: VolumeManager) : PathInterpreterFactory { + override val projection: Array + get() = 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 - // of the given directories, albeit with some conversion to the analogous MediaStore - // column values. + // 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 val dirSelectorTemplate: String - get() = - "(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " + - "AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)" - - override fun addDirToSelector(path: Path, args: MutableList): Boolean { - // MediaStore uses a different naming scheme for it's volume column convert this - // directory's volume to it. - args.add(path.volume.mediaStoreName ?: 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("${path.components}%") - return true - } - - abstract class Query( - cursor: Cursor, - genreNamesMap: Map, - private val volumeManager: VolumeManager - ) : 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 = volumeManager.getVolumes() - - 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.mediaStoreName == volumeName } - if (volume != null) { - rawSong.directory = Path(volume, Components.parseUnix(relativePath)) + override fun createSelector(paths: List): PathInterpreterFactory.Selector? { + val args = mutableListOf() + 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 PathInterpreterFactory.Selector(template, args) } + + override fun wrap(cursor: Cursor): PathInterpreter = + VolumePathInterpreter(cursor, volumeManager) } } -/** - * A [BaseMediaStoreExtractor] that completes the music loading process in a way compatible with at - * 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, private val volumeManager: VolumeManager) : - BaseApi29MediaStoreExtractor(context) { +sealed interface TagInterpreter : Interpreter - override val projection: Array - get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) +class Api21TagInterpreter(private val cursor: Cursor) : TagInterpreter { + private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) - override fun wrapQuery( - cursor: Cursor, - genreNamesMap: Map - ): MediaStoreExtractor.Query = Query(cursor, genreNamesMap, volumeManager) - - private class Query( - cursor: Cursor, - genreNamesMap: Map, - volumeManager: VolumeManager - ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, volumeManager) { - 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 } - } + override fun populate(rawSong: 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 } } } + + class Factory : TagInterpreterFactory { + override val projection: Array + 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) } -/** - * A [BaseMediaStoreExtractor] that completes the music loading process in a way compatible from API - * 30 onwards. - * - * @param context [Context] required to query the media database. - * @author Alexander Capehart (OxygenCobalt) - */ -@RequiresApi(Build.VERSION_CODES.R) -private class Api30MediaStoreExtractor(context: Context, private val volumeManager: VolumeManager) : - BaseApi29MediaStoreExtractor(context) { - override val projection: Array - get() = - super.projection + +class Api30TagInterpreter(private val cursor: Cursor) : TagInterpreter { + private val trackIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER) + private val discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER) + + override fun populate(rawSong: 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 } + } + + class Factory : TagInterpreterFactory { + override val projection: Array + get() = 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.DISC_NUMBER) - override fun wrapQuery( - cursor: Cursor, - genreNamesMap: Map - ): MediaStoreExtractor.Query = Query(cursor, genreNamesMap, volumeManager) - - private class Query( - cursor: Cursor, - genreNamesMap: Map, - volumeManager: VolumeManager - ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, volumeManager) { - 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 } - } + override fun wrap(cursor: Cursor): TagInterpreter = Api30TagInterpreter(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) From 2af8d8b4bd28752abe252bf01269e8d0e1899b2d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 10:06:19 -0700 Subject: [PATCH 46/72] music: remove old deadlock check No longer needed and likely causing bugs --- .../main/java/org/oxycblt/auxio/music/MusicRepository.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index b223e28c8..90525f223 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -488,12 +488,6 @@ constructor( emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) } - // This shouldn't occur, but keep them around just in case there's a regression. - // Note that DeviceLibrary might still actually be doing work (specifically parent - // processing), so we don't check if it's deadlocked. - 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 // that the short-circuit occurs so quickly as to break the UI. // TODO: Do not error, instead just wipe the entire library. From ec8e598d3bedfa7051e12ab1b60a4bb98c0be9bf Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 18:18:24 +0000 Subject: [PATCH 47/72] Create FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..e32f32ffc --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [OxygenCobalt] From 6b9f6862affc45abf41afd541b559c1c279f9177 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 11:47:21 -0700 Subject: [PATCH 48/72] music: correctly parse data with new paths Accidental regression appeared due to inconsistent trailing slashes, so we need to reimplement this using the path datatype itself. --- app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt | 12 ++++++++++++ .../oxycblt/auxio/music/fs/MediaStoreExtractor.kt | 9 ++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index c8b26523c..2639ec207 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -146,6 +146,18 @@ value class Components private constructor(val components: List) { */ 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 (/). diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 137692389..ccb5ab738 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -390,12 +390,11 @@ class DataPathInterpreter(private val cursor: Cursor, private val volumeManager: // 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) + val rawPath = Components.parseUnix(data) for (volume in volumes) { - val volumePath = (volume.components ?: continue).toString() - val strippedPath = rawPath.removePrefix(volumePath) - if (strippedPath != rawPath) { - rawSong.directory = Path(volume, Components.parseUnix(strippedPath)) + val volumePath = volume.components ?: continue + if (volumePath.contains(rawPath)) { + rawSong.directory = Path(volume, rawPath.depth(volumePath.components.size)) break } } From ed519eeccce9199f8b1e2c846f94d4dd53831b02 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 11:59:51 -0700 Subject: [PATCH 49/72] music: refine new mediastoreextractor impl - Make the interpreters use a more conventional naming structure - Remove the redundant file name extraction that is largely an artifact of older versions --- .../auxio/music/device/DeviceMusicImpl.kt | 24 ++-- .../oxycblt/auxio/music/device/RawMusic.kt | 4 +- .../auxio/music/fs/MediaStoreExtractor.kt | 120 +++++++++--------- 3 files changed, 70 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index e96768db9..7b16070cc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -74,37 +74,31 @@ class SongImpl( } override val name = nameFactory.parse( - requireNotNull(rawSong.name) { "Invalid raw ${rawSong.fileName}: No title" }, + requireNotNull(rawSong.name) { "Invalid raw ${rawSong.path}: No title" }, rawSong.sortName) override val track = rawSong.track override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) } override val date = rawSong.date override val uri = - requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.fileName}: No id" } - .toAudioUri() - override val path = - requireNotNull(rawSong.directory) { "Invalid raw ${rawSong.fileName}: No parent directory" } - .file( - requireNotNull(rawSong.fileName) { - "Invalid raw ${rawSong.fileName}: No display name" - }) + requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }.toAudioUri() + override val path = requireNotNull(rawSong.path) { "Invalid raw ${rawSong.path}: No path" } override val mimeType = MimeType( fromExtension = requireNotNull(rawSong.extensionMimeType) { - "Invalid raw ${rawSong.fileName}: No mime type" + "Invalid raw ${rawSong.path}: No mime type" }, 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 = - requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.fileName}: No duration" } + requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.path}: No duration" } override val replayGainAdjustment = ReplayGainAdjustment( track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment) 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 override val album: Album @@ -170,12 +164,12 @@ class SongImpl( RawAlbum( mediaStoreId = requireNotNull(rawSong.albumMediaStoreId) { - "Invalid raw ${rawSong.fileName}: No album id" + "Invalid raw ${rawSong.path}: No album id" }, musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(), name = requireNotNull(rawSong.albumName) { - "Invalid raw ${rawSong.fileName}: No album name" + "Invalid raw ${rawSong.path}: No album name" }, sortName = rawSong.albumSortName, releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)), diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index 1dea14722..2f3b6ec73 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -42,9 +42,7 @@ data class RawSong( /** The latest date the [SongImpl]'s audio file was modified, as a unix epoch timestamp. */ var dateModified: Long? = null, /** @see Song.path */ - var fileName: String? = null, - /** @see Song.path */ - var directory: Path? = null, + var path: Path? = null, /** @see Song.size */ var size: Long? = null, /** @see Song.durationMs */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index ccb5ab738..e8737cdc2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -24,7 +24,6 @@ import android.os.Build import android.provider.MediaStore import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull -import java.io.File import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield import org.oxycblt.auxio.music.cache.Cache @@ -119,8 +118,8 @@ interface MediaStoreExtractor { private class MediaStoreExtractorImpl( private val context: Context, - private val pathInterpreterFactory: PathInterpreterFactory, - private val tagInterpreterFactory: TagInterpreterFactory + private val pathInterpreterFactory: PathInterpreter.Factory, + private val tagInterpreterFactory: TagInterpreter.Factory ) : MediaStoreExtractor { override suspend fun query( constraints: MediaStoreExtractor.Constraints @@ -239,8 +238,6 @@ private class MediaStoreExtractorImpl( ) : MediaStoreExtractor.Query { private val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) private val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE) - private val displayNameIndex = - cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME) private val mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE) private val sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE) @@ -267,9 +264,6 @@ private class MediaStoreExtractorImpl( rawSong.mediaStoreId = cursor.getLong(idIndex) rawSong.dateAdded = cursor.getLong(dateAddedIndex) 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.albumMediaStoreId = cursor.getLong(albumIdIndex) pathInterpreter.populate(rawSong) @@ -289,7 +283,8 @@ private class MediaStoreExtractorImpl( // A non-existent album name should theoretically be the name of the folder it contained // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it // the file is not actually in the root internal storage directory. We can't do - // anything to 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) // Android does not make a non-existent artist tag null, it instead fills it in // as , which makes absolutely no sense given how other columns default @@ -336,7 +331,6 @@ private class MediaStoreExtractorImpl( 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, @@ -349,62 +343,54 @@ private class MediaStoreExtractorImpl( } } -interface Interpreter { +private interface Interpreter { fun populate(rawSong: RawSong) + + interface Factory { + val projection: Array + + fun wrap(cursor: Cursor): Interpreter + } } -interface InterpreterFactory { - val projection: Array +private sealed interface PathInterpreter : Interpreter { + interface Factory : Interpreter.Factory { + override fun wrap(cursor: Cursor): PathInterpreter - fun wrap(cursor: Cursor): Interpreter + fun createSelector(paths: List): Selector? + + data class Selector(val template: String, val args: List) + } } -interface PathInterpreterFactory : InterpreterFactory { - override fun wrap(cursor: Cursor): PathInterpreter - - fun createSelector(paths: List): Selector? - - data class Selector(val template: String, val args: List) -} - -interface TagInterpreterFactory : InterpreterFactory { - override fun wrap(cursor: Cursor): TagInterpreter -} - -sealed interface PathInterpreter : Interpreter - -class DataPathInterpreter(private val cursor: Cursor, private val volumeManager: VolumeManager) : - PathInterpreter { +private class DataPathInterpreter( + private val cursor: Cursor, + private val volumeManager: VolumeManager +) : PathInterpreter { private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA) private val volumes = volumeManager.getVolumes() override fun populate(rawSong: 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 } - } + 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. - val rawPath = Components.parseUnix(data) for (volume in volumes) { val volumePath = volume.components ?: continue - if (volumePath.contains(rawPath)) { - rawSong.directory = Path(volume, rawPath.depth(volumePath.components.size)) + if (volumePath.contains(data)) { + rawSong.path = Path(volume, data.depth(volumePath.components.size)) break } } } - class Factory(private val volumeManager: VolumeManager) : PathInterpreterFactory { + class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { override val projection: Array - get() = arrayOf(MediaStore.Audio.AudioColumns.DATA) + get() = + arrayOf( + MediaStore.Audio.AudioColumns.DISPLAY_NAME, MediaStore.Audio.AudioColumns.DATA) - override fun createSelector(paths: List): PathInterpreterFactory.Selector? { + override fun createSelector(paths: List): PathInterpreter.Factory.Selector? { val args = mutableListOf() var template = "" for (i in paths.indices) { @@ -423,7 +409,7 @@ class DataPathInterpreter(private val cursor: Cursor, private val volumeManager: return null } - return PathInterpreterFactory.Selector(template, args) + return PathInterpreter.Factory.Selector(template, args) } override fun wrap(cursor: Cursor): PathInterpreter = @@ -431,8 +417,10 @@ class DataPathInterpreter(private val cursor: Cursor, private val volumeManager: } } -class VolumePathInterpreter(private val cursor: Cursor, private val volumeManager: VolumeManager) : +private class VolumePathInterpreter(private val cursor: Cursor, volumeManager: VolumeManager) : PathInterpreter { + private val displayNameIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME) private val volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME) private val relativePathIndex = @@ -440,22 +428,29 @@ class VolumePathInterpreter(private val cursor: Cursor, private val volumeManage private val volumes = volumeManager.getVolumes() override fun populate(rawSong: RawSong) { - // Find the StorageVolume whose MediaStore name corresponds to this song. - // This is combined with the plain relative path column to create the directory. + // Find the StorageVolume whose MediaStore name corresponds to it. val volumeName = cursor.getString(volumeIndex) - val relativePath = cursor.getString(relativePathIndex) val volume = volumes.find { it.mediaStoreName == volumeName } + + // 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) + if (volume != null) { - rawSong.directory = Path(volume, Components.parseUnix(relativePath)) + rawSong.path = Path(volume, components) } } - class Factory(private val volumeManager: VolumeManager) : PathInterpreterFactory { + class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { override val projection: Array get() = arrayOf( // After API 29, we now have access to the volume name and relative - // path, which simplifies working with Paths significantly. + // 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) @@ -463,7 +458,7 @@ class VolumePathInterpreter(private val cursor: Cursor, private val volumeManage // of the given directories, albeit with some conversion to the analogous MediaStore // column values. - override fun createSelector(paths: List): PathInterpreterFactory.Selector? { + override fun createSelector(paths: List): PathInterpreter.Factory.Selector? { val args = mutableListOf() var template = "" for (i in paths.indices) { @@ -488,7 +483,7 @@ class VolumePathInterpreter(private val cursor: Cursor, private val volumeManage return null } - return PathInterpreterFactory.Selector(template, args) + return PathInterpreter.Factory.Selector(template, args) } override fun wrap(cursor: Cursor): PathInterpreter = @@ -496,9 +491,13 @@ class VolumePathInterpreter(private val cursor: Cursor, private val volumeManage } } -sealed interface TagInterpreter : Interpreter +private sealed interface TagInterpreter : Interpreter { + interface Factory : Interpreter.Factory { + override fun wrap(cursor: Cursor): TagInterpreter + } +} -class Api21TagInterpreter(private val cursor: Cursor) : TagInterpreter { +private class Api21TagInterpreter(private val cursor: Cursor) : TagInterpreter { private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) override fun populate(rawSong: RawSong) { @@ -511,7 +510,7 @@ class Api21TagInterpreter(private val cursor: Cursor) : TagInterpreter { } } - class Factory : TagInterpreterFactory { + class Factory : TagInterpreter.Factory { override val projection: Array get() = arrayOf(MediaStore.Audio.AudioColumns.TRACK) @@ -533,12 +532,13 @@ class Api21TagInterpreter(private val cursor: Cursor) : TagInterpreter { * 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. + * @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) } -class Api30TagInterpreter(private val cursor: Cursor) : TagInterpreter { +private class Api30TagInterpreter(private val cursor: Cursor) : TagInterpreter { private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER) private val discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER) @@ -552,7 +552,7 @@ class Api30TagInterpreter(private val cursor: Cursor) : TagInterpreter { cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { rawSong.disc = it } } - class Factory : TagInterpreterFactory { + class Factory : TagInterpreter.Factory { override val projection: Array get() = arrayOf( From 77f0bbe614d0312b82a9c5a70f6eb0c022a9e7db Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 12:01:13 -0700 Subject: [PATCH 50/72] util: correctly throw channel errors Wasn't being properly handled before, leading to weird behavior. --- app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt index eb8358867..7ac5f632d 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt @@ -186,7 +186,7 @@ suspend fun ReceiveChannel.forEachWithTimeout( var subsequent = false val handler: suspend () -> Unit = { val value = receiveCatching() - if (value.isClosed) { + if (value.isClosed && value.exceptionOrNull() == null) { exhausted = true } else { action(value.getOrThrow()) From b10caaef546d1f13104be2a7fd8473f1713e45d8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 12:25:49 -0700 Subject: [PATCH 51/72] music: more path interpreter guards --- .../oxycblt/auxio/music/fs/MediaStoreExtractor.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index e8737cdc2..f8a10b375 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -379,9 +379,11 @@ private class DataPathInterpreter( val volumePath = volume.components ?: continue if (volumePath.contains(data)) { rawSong.path = Path(volume, data.depth(volumePath.components.size)) - break + return } } + + throw IllegalStateException("Could not find volume for path $data (tried: $volumes)") } class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { @@ -431,16 +433,16 @@ private class VolumePathInterpreter(private val cursor: Cursor, volumeManager: V // Find the StorageVolume whose MediaStore name corresponds to it. val volumeName = cursor.getString(volumeIndex) val volume = volumes.find { it.mediaStoreName == volumeName } + if (volume == null) { + throw IllegalStateException("Could not find volume for name $volumeName") + } // 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) - - if (volume != null) { - rawSong.path = Path(volume, components) - } + rawSong.path = Path(volume, components) } class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { From 17939f6b2a785ba91a48767a38807562e7da9ebb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 12:36:08 -0700 Subject: [PATCH 52/72] music: correctly propagate error --- app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 90525f223..026054e43 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -436,7 +436,7 @@ constructor( // to cascade to and cancel all other routines before finally bubbling up // to the main extractor loop. logE("MediaStore extraction failed: $e") - incompleteSongs.close(Exception("MediaStore extraction failed: e")) + incompleteSongs.close(Exception("MediaStore extraction failed: $e")) return@async } incompleteSongs.close() From 4a3beafc77ca16d748e687437526657389d130b5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 12:52:01 -0700 Subject: [PATCH 53/72] music: indicate volume components checked --- .../main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index f8a10b375..ab13f983d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -383,7 +383,7 @@ private class DataPathInterpreter( } } - throw IllegalStateException("Could not find volume for path $data (tried: $volumes)") + throw IllegalStateException("Could not find volume for path $data (tried: ${volumes.map { it.components }}})") } class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { From 673629dd2639537c9e66ca38e8d6d15bd7157797 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 1 Jan 2024 21:04:05 +0100 Subject: [PATCH 54/72] Translations update from Hosted Weblate (#635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Russian) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Italian) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/it/ * Translated using Weblate (French) Currently translated at 99.6% (291 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/ * Translated using Weblate (French) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/fr/ * Translated using Weblate (Korean) Currently translated at 99.6% (292 of 293 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (293 of 293 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Korean) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ko/ * Translated using Weblate (Czech) Currently translated at 100.0% (293 of 293 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (294 of 294 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Czech) Currently translated at 100.0% (294 of 294 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (294 of 294 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Russian) Currently translated at 100.0% (294 of 294 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (294 of 294 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (294 of 294 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (German) Currently translated at 100.0% (294 of 294 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Hindi) Currently translated at 100.0% (294 of 294 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Croatian) Currently translated at 100.0% (294 of 294 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (294 of 294 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ * Translated using Weblate (Czech) Currently translated at 100.0% (301 of 301 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (302 of 302 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Czech) Currently translated at 100.0% (304 of 304 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Russian) Currently translated at 100.0% (304 of 304 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (304 of 304 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (304 of 304 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Spanish) Currently translated at 100.0% (304 of 304 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (304 of 304 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Hindi) Currently translated at 100.0% (304 of 304 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (304 of 304 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ --------- Co-authored-by: Макар Разин Co-authored-by: atilluF <110931720+atilluF@users.noreply.github.com> Co-authored-by: cwpute Co-authored-by: Yurical Co-authored-by: Eric Co-authored-by: Fjuro Co-authored-by: gallegonovato Co-authored-by: BMT[UA] Co-authored-by: qwerty287 Co-authored-by: ShareASmile Co-authored-by: Milo Ivir --- app/src/main/res/values-be/strings.xml | 14 ++++++++++ app/src/main/res/values-cs/strings.xml | 14 ++++++++++ app/src/main/res/values-de/strings.xml | 4 +++ app/src/main/res/values-es/strings.xml | 14 ++++++++++ app/src/main/res/values-fr/strings.xml | 5 +++- app/src/main/res/values-hi/strings.xml | 14 ++++++++++ app/src/main/res/values-hr/strings.xml | 9 ++++++- app/src/main/res/values-ko/strings.xml | 5 ++++ app/src/main/res/values-pa/strings.xml | 14 ++++++++++ app/src/main/res/values-ru/strings.xml | 18 +++++++++++-- app/src/main/res/values-uk/strings.xml | 14 ++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 14 ++++++++++ .../android/fr-FR/full_description.txt | 2 +- .../metadata/android/it/full_description.txt | 2 +- .../metadata/android/ko/full_description.txt | 26 +++++++++---------- 15 files changed, 150 insertions(+), 19 deletions(-) diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 97414d1ba..b54812d10 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -305,4 +305,18 @@ Няма альбомаў Дэма Дэманстрацыі + Імпартаваны плэйліст + Шлях + Немагчыма імпартаваць плэйліст з гэтага файла + Пусты плэйліст + Абсалютны + Імпарт + Выкарыстоўваць шляхі, сумяшчальныя з Windows + Экспарт + Адносны + Плэйліст імпартаваны + Стыль шляху + Плэйліст экспартаваны + Экспартаваць плэйліст + Немагчыма экспартаваць плэйліст ў гэты файл \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index dd85dcc80..27affdef0 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -316,4 +316,18 @@ Žádná alba Demo Dema + Importovaný seznam skladeb + Cesta + Prázdný seznam skladeb + Nepodařilo se importovat seznam skladeb z tohoto souboru + Absolutní + Použít cesty kompatibilní s Windows + Export + Relativní + Styl cesty + Exportovat seznam skladeb + Nepodařilo se exportovat seznam skladeb do tohoto souboru + Import + Seznam skladeb importován + Seznam skladeb exportován \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 02da0dab5..8554409f0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -307,4 +307,8 @@ Keine Alben Demo Demos + Importierte Wiedergabeliste + Pfad + Wiedergabeliste konnte nicht aus dieser Datei importiert werden + Leere Wiedergabeliste \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 747a2b559..5dc705c7d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -311,4 +311,18 @@ Sin álbumes Demostración Demostraciones + Lista de reproducción importada + Ruta + No se puede importar una lista de reproducción desde este archivo + Lista de reproducción vacía + Absoluto + Importar + Utilizar rutas compatibles con Windows + Exportar + Relativo + Lista de reproducción importada + Estilo de la ruta + Lista de reproducción exportada + Exportar lista de reproducción + No se puede exportar la lista de reproducción a este archivo \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 11cf24113..eaacbbc02 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -298,7 +298,7 @@ Chanson Voir Jouer la chanson par elle-même - Image de sélection + Image sélectionnée Trier par Direction Sélection @@ -306,4 +306,7 @@ Copié Signaler Info sur l\'erreur + Aucun album + Démo + Démos \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 265b733e5..0e94266d1 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -306,4 +306,18 @@ कोई एल्बम नहीं डेमो डेमो + इम्पोर्टेड प्लेलिस्ट + पथ + इस फ़ाइल से प्लेलिस्ट इम्पोर्ट करने में असमर्थ + खाली प्लेलिस्ट + पूर्ण + इंपोर्ट करें + विंडोज़-संगत पथों का उपयोग करें + एक्सपोर्ट करें + सापेक्ष + प्लेलिस्ट इंपोर्ट की गयी + पथ शैली + प्लेलिस्ट एक्सपोर्ट की गई + प्लेलिस्ट एक्सपोर्ट करें + प्लेलिस्ट को इस फ़ाइल में एक्सपोर्ट करने में असमर्थ \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 737a95181..27c3c22aa 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -117,7 +117,7 @@ Slika žanra za %s Nepoznat izvođač Nepoznat žanr - Nema staze + Nema zvučnog zapisa Bez datuma Glazba se ne reproducira MPEG-1 zvuk @@ -299,4 +299,11 @@ Podaci greške Prijavi grešku Kopirano + Nema albuma + Uvezen popis pjesama + Staza + Demo snimka + Demo snimke + Nije bilo moguće uvesti popis pjesama iz ove datoteke + Prazan popis pjesama \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 97463dcae..78848acd3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -304,4 +304,9 @@ 복사했습니다. 오류 보고 오류 정보 + 앨범 없음 + 경로 + 데모 + 데모 + 빈 재생 목록 \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 2f029b2ab..0e07a90cf 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -299,4 +299,18 @@ ਕੋਈ ਐਲਬਮ ਨਹੀਂ ਡੈਮੋ ਡੈਮੋ + ਇੰਪੋਰਟਡ ਪਲੇਲਿਸਟ + ਮਾਰਗ + ਇਸ ਫਾਈਲ ਤੋਂ ਪਲੇਲਿਸਟ ਨੂੰ ਆਯਾਤ ਕਰਨ ਵਿੱਚ ਅਸਮਰੱਥ + ਖਾਲੀ ਪਲੇਲਿਸਟ + ਪੂਰਨ + ਆਯਾਤ ਕਰੋ + ਵਿੰਡੋਜ਼-ਅਨੁਕੂਲ ਮਾਰਗਾਂ ਦੀ ਵਰਤੋਂ ਕਰੋ + ਨਿਰਯਾਤ ਕਰੋ + ਰਿਲੇਟਿਵ + ਪਲੇਲਿਸਟ ਆਯਾਤ ਕੀਤੀ ਗਈ + ਮਾਰਗ ਸਟਾਈਲ + ਪਲੇਲਿਸਟ ਨਿਰਯਾਤ ਕੀਤੀ ਗਈ + ਪਲੇਲਿਸਟ ਨਿਰਯਾਤ ਕਰੋ + ਪਲੇਲਿਸਟ ਨੂੰ ਇਸ ਫ਼ਾਈਲ ਵਿੱਚ ਨਿਰਯਾਤ ਕਰਨ ਵਿੱਚ ਅਸਮਰੱਥ \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 268cd49b1..bcd52e176 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -311,7 +311,21 @@ Информация об ошибке Отчёт об ошибке Скопировано - Няма альбомаў + Нет альбомов Демо - Дэманстрацыі + Демонстрации + Импортированный плейлист + Путь + Невозможно импортировать плейлист из этого файла + Пустой плейлист + Абсолютный + Импорт + Использовать пути, совместимые с Windows + Экспорт + Относительный + Плейлист импортирован + Стиль пути + Плейлист экспортирован + Экспортировать плейлист + Невозможно экспортировать плейлист в этот файл \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index e2b1c3891..15ebe65a3 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -311,4 +311,18 @@ Альбомів немає Демо Демонстрації + Імпортований список відтворення + Шлях + Неможливо імпортувати список відтворення з цього файлу + Порожній список відтворення + Абсолютний + Імпорт + Використовувати шляхи, сумісні з Windows + Експорт + Відносний + Список відтворення імпортовано + Стиль шляху + Список відтворення експортовано + Експортувати список відтворення + Неможливо експортувати список відтворення в цей файл \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e7072c57d..6cb6dbd36 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -305,4 +305,18 @@ 无专辑 演示 样曲 + 导入了播放列表 + 路径 + 清空播放列表 + 无法从此文件导入播放列表 + 绝对 + 导入 + 使用兼容 Windows 系统的路径 + 导出 + 相对 + 路径样式 + 导出播放列表 + 无法将播放列表导出到此文件 + 导入了播放列表 + 导出了播放列表 \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 107936eaf..7567e8b1c 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -20,4 +20,4 @@ les dates précises/originales, le classement par tags, and plus encore - Lecture automatique pour les casques - Widgets stylisés qui s'adaptent automatiquement à leur taille - Complètement privé et hors-ligne -- On arrondit pas les couvertures d'albums (Sauf si vous le voulez. Dans ce cas c'est possible.) +- On arrondit pas les couvertures d'albums (par défaut) diff --git a/fastlane/metadata/android/it/full_description.txt b/fastlane/metadata/android/it/full_description.txt index 7f8f718ce..db4cabec8 100644 --- a/fastlane/metadata/android/it/full_description.txt +++ b/fastlane/metadata/android/it/full_description.txt @@ -20,4 +20,4 @@ date precise/originali, tag di ordinamento e altro ancora - Riproduzione automatica delle cuffie - Widget eleganti che si adattano automaticamente alle loro dimensioni - Completamente privato e offline -- Niente copertine arrotondate degli album (a meno che tu non le voglia, in tal caso puoi) +- Niente copertine arrotondate degli album (comportamento predefinito) diff --git a/fastlane/metadata/android/ko/full_description.txt b/fastlane/metadata/android/ko/full_description.txt index 2d5d002c0..387682b2c 100644 --- a/fastlane/metadata/android/ko/full_description.txt +++ b/fastlane/metadata/android/ko/full_description.txt @@ -1,23 +1,23 @@ -Auxio는 다른 음악 플레이어에 있는 쓸모없는 많은 기능 없이 빠르고 안정적인 UI/UX를 갖춘 로컬 음악 플레이어입니다. 최신 미디어 재생 라이브러리를 기반으로 구축된 Auxio는 오래된 안드로이드 기능을 사용하는 다른 앱에 비해 뛰어난 라이브러리 지원과 청취 품질을 제공합니다. 즉, 제대로 된 음악을 재생합니다. +Auxio는 다른 음악 플레이어에 존재하는 쓸모없는 기능 없이, 빠르고 안정적인 UI/UX를 갖춘 로컬 음악 플레이어입니다. 최신 미디어 재생 라이브러리를 기반으로 구축된 Auxio는 오래된 Android 기능을 사용하는 다른 앱에 비해 뛰어난 라이브러리 지원과 음악 재생 품질을 제공합니다. 즉, 음악을 제대로 재생하는 플레이어입니다. 기능 -- Media3 ExoPlayer 기반 재생 -- 최신주목할 만한 디자인 가이드라인에서 파생된 Snappy UI -- 엣지 케이스보다 사용 편의성을 우선시하는 의견이 많은 UX +- Media3 ExoPlayer 기반 +- 머티리얼 디자인 가이드라인을 따르는 깔끔한 UI +- 엣지 케이스보다는 사용 편의성을 우선시한 UX - 사용자 정의 가능한 동작 - 디스크 번호, 여러 아티스트, 릴리스 유형 지원, 정확한/원본 날짜, 정렬 태그 등 지원 -- 아티스트와 앨범 아티스트를 통합하는 고급 아티스트 시스템 -- SD 카드 인식 폴더 관리 +- 아티스트와 앨범 아티스트를 통합한 고급 아티스트 시스템 +- SD 카드를 지원하는 폴더 관리 기능 - 안정적인 재생 목록 기능 -- 재생 상태 지속성 -- 전체 ReplayGain 지원 (MP3, FLAC, OGG, OPUS, MP4) -- 외부 이퀄라이저 지원 (예: Wavelet) +- 이전 재생 상태 기억 +- ReplayGain 완벽 지원 (MP3, FLAC, OGG, OPUS, MP4) +- 외부 이퀄라이저 지원 (Wavelet 등) - Edge-to-edge -- 임베디드 커버 지원 +- 파일 내장 앨범 커버 지원 - 검색 기능 -- 헤드셋 자동 재생 +- 헤드셋 연결 시 자동 재생 - 크기에 따라 자동으로 조정되는 세련된 위젯 -- 완전한 비공개 및 오프라인 -- 둥근 앨범 커버 사용 안 함 (필요하면 설정할 수 있습니다.) +- 인터넷 사용 없음 +- 둥근 앨범 커버 없음 (기본값) From 538533bf3f257e6cf04f7b51bdc75910407f4d4e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 13:36:53 -0700 Subject: [PATCH 55/72] music: discard songs w/o volumes --- .../auxio/music/fs/MediaStoreExtractor.kt | 53 +++++++++---------- .../java/org/oxycblt/auxio/util/StateUtil.kt | 2 +- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index ab13f983d..5dbb968cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -78,7 +78,7 @@ interface MediaStoreExtractor { fun close() - fun populateFileInfo(rawSong: RawSong) + fun populateFileInfo(rawSong: RawSong): Boolean fun populateTags(rawSong: RawSong) } @@ -101,7 +101,7 @@ interface MediaStoreExtractor { // instead. Build.MANUFACTURER.equals("huawei", ignoreCase = true) || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q -> - DataPathInterpreter.Factory(volumeManager) + Api24PathInterpreter.Factory(volumeManager) else -> VolumePathInterpreter.Factory(volumeManager) } @@ -216,7 +216,9 @@ private class MediaStoreExtractorImpl( ) { while (query.moveToNext()) { val rawSong = RawSong() - query.populateFileInfo(rawSong) + if (!query.populateFileInfo(rawSong)) { + continue + } if (cache?.populate(rawSong) == true) { completeSongs.sendWithTimeout(rawSong) } else { @@ -260,13 +262,13 @@ private class MediaStoreExtractorImpl( override fun close() = cursor.close() - override fun populateFileInfo(rawSong: RawSong) { + override fun populateFileInfo(rawSong: RawSong): Boolean { rawSong.mediaStoreId = cursor.getLong(idIndex) rawSong.dateAdded = cursor.getLong(dateAddedIndex) rawSong.dateModified = cursor.getLong(dateModifiedIndex) rawSong.extensionMimeType = cursor.getString(mimeTypeIndex) rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex) - pathInterpreter.populate(rawSong) + return pathInterpreter.populate(rawSong) } override fun populateTags(rawSong: RawSong) { @@ -343,19 +345,13 @@ private class MediaStoreExtractorImpl( } } -private interface Interpreter { - fun populate(rawSong: RawSong) +private sealed interface PathInterpreter { + fun populate(rawSong: RawSong): Boolean interface Factory { val projection: Array - fun wrap(cursor: Cursor): Interpreter - } -} - -private sealed interface PathInterpreter : Interpreter { - interface Factory : Interpreter.Factory { - override fun wrap(cursor: Cursor): PathInterpreter + fun wrap(cursor: Cursor): PathInterpreter fun createSelector(paths: List): Selector? @@ -363,14 +359,14 @@ private sealed interface PathInterpreter : Interpreter { } } -private class DataPathInterpreter( +private open class Api24PathInterpreter( private val cursor: Cursor, private val volumeManager: VolumeManager ) : PathInterpreter { private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA) private val volumes = volumeManager.getVolumes() - override fun populate(rawSong: RawSong) { + override fun populate(rawSong: RawSong): Boolean { val data = Components.parseUnix(cursor.getString(dataIndex)) // Find the volume that transforms the DATA column into a relative path. This is @@ -379,11 +375,11 @@ private class DataPathInterpreter( val volumePath = volume.components ?: continue if (volumePath.contains(data)) { rawSong.path = Path(volume, data.depth(volumePath.components.size)) - return + return true } } - throw IllegalStateException("Could not find volume for path $data (tried: ${volumes.map { it.components }}})") + return false } class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { @@ -415,7 +411,7 @@ private class DataPathInterpreter( } override fun wrap(cursor: Cursor): PathInterpreter = - DataPathInterpreter(cursor, volumeManager) + Api24PathInterpreter(cursor, volumeManager) } } @@ -429,20 +425,17 @@ private class VolumePathInterpreter(private val cursor: Cursor, volumeManager: V cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH) private val volumes = volumeManager.getVolumes() - override fun populate(rawSong: RawSong) { + override fun populate(rawSong: RawSong): Boolean { // Find the StorageVolume whose MediaStore name corresponds to it. val volumeName = cursor.getString(volumeIndex) - val volume = volumes.find { it.mediaStoreName == volumeName } - if (volume == null) { - throw IllegalStateException("Could not find volume for name $volumeName") - } - + val volume = volumes.find { it.mediaStoreName == volumeName } ?: return false // 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) rawSong.path = Path(volume, components) + return true } class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { @@ -493,9 +486,13 @@ private class VolumePathInterpreter(private val cursor: Cursor, volumeManager: V } } -private sealed interface TagInterpreter : Interpreter { - interface Factory : Interpreter.Factory { - override fun wrap(cursor: Cursor): TagInterpreter +private sealed interface TagInterpreter { + fun populate(rawSong: RawSong) + + interface Factory { + val projection: Array + + fun wrap(cursor: Cursor): TagInterpreter } } diff --git a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt index 7ac5f632d..6e60eadf2 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt @@ -163,7 +163,7 @@ private fun Fragment.launch( suspend fun SendChannel.sendWithTimeout(element: E, timeout: Long = 10000) { try { withTimeout(timeout) { send(element) } - } catch (e: Exception) { + } catch (e: TimeoutCancellationException) { throw TimeoutException("Timed out sending element $element to channel: $e") } } From 574e129f34a226d370e4163c7df4057d346c7716 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 14:08:47 -0700 Subject: [PATCH 56/72] music: cleanup and document --- .../auxio/music/fs/MediaStoreExtractor.kt | 84 +++++++++++++++++-- 1 file changed, 76 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 5dbb968cf..618ad9b2a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -101,7 +101,7 @@ interface MediaStoreExtractor { // instead. Build.MANUFACTURER.equals("huawei", ignoreCase = true) || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q -> - Api24PathInterpreter.Factory(volumeManager) + DataPathInterpreter.Factory(volumeManager) else -> VolumePathInterpreter.Factory(volumeManager) } @@ -345,24 +345,60 @@ private class MediaStoreExtractorImpl( } } +/** + * Wrapper around a [Cursor] that interprets path information on a per-API/manufacturer basis. + * + * @author Alexander Capehart (OxygenCobalt) + */ private sealed interface PathInterpreter { + /** + * Populate the [RawSong] with version-specific path information. + * + * @param rawSong The [RawSong] to populate. + * @return True if the path was successfully populated, false otherwise. + */ fun populate(rawSong: RawSong): Boolean interface Factory { + /** The columns that must be added to a query to support this interpreter. */ val projection: Array + /** + * 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 [PathInterpreter] that will work best on the device's API level. + */ fun wrap(cursor: Cursor): PathInterpreter + /** + * 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): 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) } } -private open class Api24PathInterpreter( - private val cursor: Cursor, - private val volumeManager: VolumeManager -) : PathInterpreter { +/** + * Wrapper around a [Cursor] that interprets the DATA column as a path. Create an instance with + * [Factory]. + */ +private class DataPathInterpreter +private constructor(private val cursor: Cursor, volumeManager: VolumeManager) : PathInterpreter { private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA) private val volumes = volumeManager.getVolumes() @@ -382,6 +418,11 @@ private open class Api24PathInterpreter( return false } + /** + * Factory for [DataPathInterpreter]. + * + * @param volumeManager The [VolumeManager] to use for volume information. + */ class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { override val projection: Array get() = @@ -411,12 +452,16 @@ private open class Api24PathInterpreter( } override fun wrap(cursor: Cursor): PathInterpreter = - Api24PathInterpreter(cursor, volumeManager) + DataPathInterpreter(cursor, volumeManager) } } -private class VolumePathInterpreter(private val cursor: Cursor, volumeManager: VolumeManager) : - PathInterpreter { +/** + * 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 VolumePathInterpreter +private constructor(private val cursor: Cursor, volumeManager: VolumeManager) : PathInterpreter { private val displayNameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME) private val volumeIndex = @@ -438,6 +483,11 @@ private class VolumePathInterpreter(private val cursor: Cursor, volumeManager: V return true } + /** + * Factory for [VolumePathInterpreter]. + * + * @param volumeManager The [VolumeManager] to use for volume information. + */ class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { override val projection: Array get() = @@ -486,12 +536,30 @@ private class VolumePathInterpreter(private val cursor: Cursor, volumeManager: V } } +/** + * Wrapper around a [Cursor] that interprets certain tags on a per-API basis. + * + * @author Alexander Capehart (OxygenCobalt) + */ private sealed interface TagInterpreter { + /** + * Populate the [RawSong] with version-specific tags. + * + * @param rawSong The [RawSong] to populate. + */ fun populate(rawSong: RawSong) interface Factory { + /** The columns that must be added to a query to support this interpreter. */ val projection: Array + /** + * 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 [TagInterpreter] that will work best on the device's API level. + */ fun wrap(cursor: Cursor): TagInterpreter } } From 28ff2b416a2e6325acbef0ef168e5a03a846140e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 14:35:21 -0700 Subject: [PATCH 57/72] home: make add speed fab consistent with shuffle Was using color secondary prior since the code was derived from MaterialFiles. --- .../main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt index 845bc617d..af9150c6f 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt @@ -87,11 +87,11 @@ class ThemedSpeedDialView : SpeedDialView { val context = context mainFabClosedBackgroundColor = context - .getAttrColorCompat(com.google.android.material.R.attr.colorSecondaryContainer) + .getAttrColorCompat(com.google.android.material.R.attr.colorPrimaryContainer) .defaultColor mainFabClosedIconColor = context - .getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondaryContainer) + .getAttrColorCompat(com.google.android.material.R.attr.colorOnPrimaryContainer) .defaultColor mainFabOpenedBackgroundColor = context.getAttrColorCompat(androidx.appcompat.R.attr.colorPrimary).defaultColor From 53870cd31b4b1a0798739da6c95c43311a411893 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 14:36:26 -0700 Subject: [PATCH 58/72] music: fix reloads not cancelling prior ones Caused by a dumb mistake in the cancellation code. --- .../oxycblt/auxio/music/MusicRepository.kt | 27 ++++++++++++------- .../auxio/music/system/IndexerService.kt | 6 ++--- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 026054e43..17a8ca671 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -29,8 +29,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.device.DeviceLibrary @@ -341,11 +343,11 @@ constructor( } 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 { - indexImpl(worker, withCache) + indexImpl(context, scope, withCache) } catch (e: CancellationException) { // Got cancelled, propagate upwards to top-level co-routine. logD("Loading routine was cancelled") @@ -359,13 +361,13 @@ 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() // 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. - if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) == + if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) == PackageManager.PERMISSION_DENIED) { logE("Permissions were not granted") throw NoAudioPermissionException() @@ -390,7 +392,7 @@ constructor( emitIndexingProgress(IndexingProgress.Indeterminate) val mediaStoreQueryJob = - worker.scope.async { + scope.async { val query = try { mediaStoreExtractor.query(constraints) @@ -428,7 +430,7 @@ constructor( // does not exist. In the latter situation, it also applies it's own (inferior) metadata. logD("Starting MediaStore discovery") val mediaStoreJob = - worker.scope.async { + scope.async { try { mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) } catch (e: Exception) { @@ -446,7 +448,7 @@ constructor( // metadata for them, and then forwards it to DeviceLibrary. logD("Starting tag extraction") val tagJob = - worker.scope.async { + scope.async { try { tagExtractor.consume(incompleteSongs, completeSongs) } catch (e: Exception) { @@ -461,7 +463,7 @@ constructor( // and then forwards them to the primary loading loop. logD("Starting DeviceLibrary creation") val deviceLibraryJob = - worker.scope.async(Dispatchers.Default) { + scope.async(Dispatchers.Default) { val deviceLibrary = try { deviceLibraryFactory.create( @@ -488,6 +490,11 @@ constructor( emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) } + withTimeout(10000) { + mediaStoreJob.await() + tagJob.await() + } + // Deliberately done after the involved initialization step to make it less likely // that the short-circuit occurs so quickly as to break the UI. // TODO: Do not error, instead just wipe the entire library. @@ -510,7 +517,7 @@ constructor( // working on parent information. logD("Starting UserLibrary query") val userLibraryQueryJob = - worker.scope.async { + scope.async { val rawPlaylists = try { userLibraryFactory.query() diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 37b32f8ef..d3cad75c6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -32,7 +32,6 @@ import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.IndexingProgress import org.oxycblt.auxio.music.IndexingState @@ -124,12 +123,11 @@ class IndexerService : // --- CONTROLLER CALLBACKS --- override fun requestIndex(withCache: Boolean) { - logD("Starting new indexing job") + logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})") // Cancel the previous music loading job. currentIndexJob?.cancel() // Start a new music loading job on a co-routine. - currentIndexJob = - indexScope.launch { musicRepository.index(this@IndexerService, withCache) } + currentIndexJob = musicRepository.index(this@IndexerService, withCache) } override val context = this From 244373e2aff55752479ffa362dbb500f4afb5f9d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 14:39:03 -0700 Subject: [PATCH 59/72] info: update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 224b91250..219f42672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,12 @@ #### 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 From bf9667f545ea9229ffc345d1752ccfa3691694f9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 16:07:50 -0700 Subject: [PATCH 60/72] music: fix working directory in m3u export --- .../oxycblt/auxio/music/external/ExternalPlaylistManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt index 1a7eed2be..1cf0b0810 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt @@ -102,9 +102,9 @@ constructor( val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return false val workingDirectory = if (config.absolute) { - filePath.directory - } else { Path(filePath.volume, Components.parseUnix("/")) + } else { + filePath.directory } return try { val outputStream = context.contentResolverSafe.openOutputStream(uri) From 68584ba426f025d3ca74f8a7420eb8382c77c3ea Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 16:10:11 -0700 Subject: [PATCH 61/72] music: fix file document path extraction Apparently on Android 14 you need to reassemble the document URI and then extract it's ID to get a path object. Really not sure why. --- .../org/oxycblt/auxio/music/fs/DocumentPathFactory.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt index 7643603c7..5a9ebcfeb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt @@ -64,7 +64,15 @@ interface DocumentPathFactory { class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : DocumentPathFactory { - override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri)) + override fun unpackDocumentUri(uri: Uri): Path? { + // Abuse the document contract and extract the encoded path from the URI. + // I've seen some implementations that just use getDocumentId. That no longer seems + // to work on Android 14 onwards. But spoofing our own document URI and then decoding + // it does for some reason. + val docUri = DocumentsContract.buildDocumentUri(uri.authority, uri.pathSegments[1]) + val docId = DocumentsContract.getDocumentId(docUri) + return fromDocumentId(docId) + } override fun unpackDocumentTreeUri(uri: Uri): Path? { // Convert the document tree URI into it's relative path form, which can then be From 9ad11ec5aaf2e8fe10e7164ef9f9525e4f59b9fb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 16:12:01 -0700 Subject: [PATCH 62/72] music: allow renaming playlist before import When you import a playlist, Auxio will now always display the "New Playlist" dialog so you can change whatever name Auxio has picked for the imported playlist. This also prevents the creation of two playlists with the same names. --- .../org/oxycblt/auxio/home/HomeFragment.kt | 4 +- .../oxycblt/auxio/music/MusicRepository.kt | 1 - .../org/oxycblt/auxio/music/MusicViewModel.kt | 41 ++++++++++++++----- .../music/decision/AddToPlaylistDialog.kt | 2 +- .../auxio/music/decision/NewPlaylistDialog.kt | 14 +++++++ app/src/main/res/navigation/inner.xml | 4 ++ 6 files changed, 53 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 24b69eff9..9ed9870ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -471,7 +471,9 @@ class HomeFragment : is PlaylistDecision.New -> { logD("Creating new playlist") HomeFragmentDirections.newPlaylist( - decision.songs.map { it.uid }.toTypedArray(), decision.reason) + decision.songs.map { it.uid }.toTypedArray(), + decision.template, + decision.reason) } is PlaylistDecision.Import -> { logD("Importing playlist") diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 17a8ca671..97ecaa7fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 589d9a496..ffd629172 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -51,15 +51,18 @@ constructor( ) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { private val _indexingState = MutableStateFlow(null) + /** The current music loading state, or null if no loading is going on. */ val indexingState: StateFlow = _indexingState private val _statistics = MutableStateFlow(null) + /** [Statistics] about the last completed music load. */ val statistics: StateFlow get() = _statistics private val _playlistDecision = MutableEvent() + /** * A [PlaylistDecision] command that is awaiting a view capable of responding to it. Null if * none currently. @@ -137,7 +140,7 @@ constructor( } } else { logD("Launching creation dialog for ${songs.size} songs") - _playlistDecision.put(PlaylistDecision.New(songs, reason)) + _playlistDecision.put(PlaylistDecision.New(songs, null, reason)) } } @@ -168,14 +171,14 @@ constructor( _playlistMessage.put(PlaylistMessage.ImportFailed) return@launch } - // TODO Require the user to name it something else if the name is a duplicate of - // a prior playlist + if (target !== null) { musicRepository.rewritePlaylist(target, songs) _playlistMessage.put(PlaylistMessage.ImportSuccess) } else { - // TODO: Have to properly propagate the "Playlist Created" message - createPlaylist(importedPlaylist.name, songs, PlaylistDecision.New.Reason.IMPORT) + _playlistDecision.put( + PlaylistDecision.New( + songs, importedPlaylist.name, PlaylistDecision.New.Reason.IMPORT)) } } } else { @@ -211,17 +214,27 @@ constructor( * * @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 reason The reason why the playlist is being renamed. For all intensive purposes, you */ - fun renamePlaylist(playlist: Playlist, name: String? = null) { + fun renamePlaylist( + playlist: Playlist, + name: String? = null, + reason: PlaylistDecision.Rename.Reason = PlaylistDecision.Rename.Reason.ACTION + ) { if (name != null) { logD("Renaming $playlist to $name") viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) - _playlistMessage.put(PlaylistMessage.RenameSuccess) + val message = + when (reason) { + PlaylistDecision.Rename.Reason.ACTION -> PlaylistMessage.RenameSuccess + PlaylistDecision.Rename.Reason.IMPORT -> PlaylistMessage.ImportSuccess + } + _playlistMessage.put(message) } } else { logD("Launching rename dialog for $playlist") - _playlistDecision.put(PlaylistDecision.Rename(playlist)) + _playlistDecision.put(PlaylistDecision.Rename(playlist, reason)) } } @@ -336,9 +349,12 @@ sealed interface PlaylistDecision { * 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 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, val reason: Reason) : PlaylistDecision { + data class New(val songs: List, val template: String?, val reason: Reason) : + PlaylistDecision { enum class Reason { NEW, ADD, @@ -359,7 +375,12 @@ sealed interface PlaylistDecision { * * @param playlist The playlist to act on. */ - data class Rename(val playlist: Playlist) : PlaylistDecision + data class Rename(val playlist: Playlist, val reason: Reason) : PlaylistDecision { + enum class Reason { + ACTION, + IMPORT + } + } /** * Navigate to a dialog that allows the user to export a [Playlist]. diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt index 66a1d5507..602d1830f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt @@ -100,7 +100,7 @@ class AddToPlaylistDialog : findNavController() .navigateSafe( AddToPlaylistDialogDirections.newPlaylist( - songs.map { it.uid }.toTypedArray(), PlaylistDecision.New.Reason.ADD)) + songs.map { it.uid }.toTypedArray(), null, PlaylistDecision.New.Reason.ADD)) } private fun updatePendingSongs(songs: List?) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt index 9d8a44742..54ea142ff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.decision import android.os.Bundle +import android.text.Editable import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog import androidx.core.widget.addTextChangedListener @@ -47,6 +48,7 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment + From 0675ce8a030fe1ea2c64f839b73ba98603202872 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 20:21:39 -0700 Subject: [PATCH 63/72] music: rename playlist when reimporting When reimporting an M3U file into a playlist, if the name differs, then initiate a rename dialog so the user has a choice on whether they want to use the new name or not. This does kinda desecrate the "Rename" decision a bit, but it's still to the user the same. --- .../auxio/detail/PlaylistDetailFragment.kt | 6 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 6 +- .../java/org/oxycblt/auxio/music/Music.kt | 1 + .../org/oxycblt/auxio/music/MusicViewModel.kt | 38 ++++++++--- .../auxio/music/decision/NewPlaylistDialog.kt | 42 +++++++----- .../music/decision/PlaylistPickerViewModel.kt | 68 ++++++++++++------- .../music/decision/RenamePlaylistDialog.kt | 23 ++++--- .../oxycblt/auxio/search/SearchFragment.kt | 6 +- .../res/layout/fragment_playback_panel.xml | 2 +- app/src/main/res/navigation/inner.xml | 10 +++ 10 files changed, 142 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 40a03c6e8..5e02cd0a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -352,7 +352,11 @@ class PlaylistDetailFragment : } is PlaylistDecision.Rename -> { 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}") diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 9ed9870ce..3c8a0dd90 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -487,7 +487,11 @@ class HomeFragment : } is PlaylistDecision.Rename -> { 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}") diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 7d6fae73e..1bf23aaf6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -348,6 +348,7 @@ interface Genre : MusicParent { * @author Alexander Capehart (OxygenCobalt) */ interface Playlist : MusicParent { + override val name: Name.Known override val songs: List /** The total duration of the songs in this genre, in milliseconds. */ val durationMs: Long diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index ffd629172..97a8fd8f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -145,7 +145,7 @@ constructor( } /** - * Import a playlist from a file [Uri]. Errors pushed to [importError]. + * 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. @@ -173,8 +173,17 @@ constructor( } if (target !== null) { - musicRepository.rewritePlaylist(target, songs) - _playlistMessage.put(PlaylistMessage.ImportSuccess) + 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( @@ -188,7 +197,7 @@ constructor( } /** - * Export a [Playlist] to a file [Uri]. Errors pushed to [exportError]. + * 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. @@ -214,17 +223,24 @@ constructor( * * @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 reason The reason why the playlist is being renamed. For all intensive purposes, you + * @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, + applySongs: List = listOf(), reason: PlaylistDecision.Rename.Reason = PlaylistDecision.Rename.Reason.ACTION ) { if (name != null) { logD("Renaming $playlist to $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 @@ -234,7 +250,7 @@ constructor( } } else { logD("Launching rename dialog for $playlist") - _playlistDecision.put(PlaylistDecision.Rename(playlist, reason)) + _playlistDecision.put(PlaylistDecision.Rename(playlist, null, applySongs, reason)) } } @@ -243,7 +259,8 @@ constructor( * * @param playlist The playlist to delete. * @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) { if (rude) { @@ -375,7 +392,12 @@ sealed interface PlaylistDecision { * * @param playlist The playlist to act on. */ - data class Rename(val playlist: Playlist, val reason: Reason) : PlaylistDecision { + data class Rename( + val playlist: Playlist, + val template: String?, + val applySongs: List, + val reason: Reason + ) : PlaylistDecision { enum class Reason { ACTION, IMPORT diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt index 54ea142ff..ac3b82ec0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistDialog.kt @@ -31,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD @@ -52,9 +53,14 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment R.string.lbl_new_playlist + PlaylistDecision.New.Reason.IMPORT -> R.string.lbl_import_playlist + }) .setPositiveButton(R.string.lbl_ok) { _, _ -> - val pendingPlaylist = unlikelyToBeNull(pickerModel.currentPendingPlaylist.value) + val pendingPlaylist = unlikelyToBeNull(pickerModel.currentPendingNewPlaylist.value) val name = when (val chosenName = pickerModel.chosenName.value) { is ChosenName.Valid -> chosenName.value @@ -84,27 +90,29 @@ class NewPlaylistDialog : ViewBindingMaterialDialogFragment(null) + private val _currentPendingNewPlaylist = MutableStateFlow(null) /** A new [Playlist] having it's name chosen by the user. Null if none yet. */ - val currentPendingPlaylist: StateFlow - get() = _currentPendingPlaylist + val currentPendingNewPlaylist: StateFlow + get() = _currentPendingNewPlaylist - private val _currentPlaylistToRename = MutableStateFlow(null) + private val _currentPendingRenamePlaylist = MutableStateFlow(null) /** An existing [Playlist] that is being renamed. Null if none yet. */ - val currentPlaylistToRename: StateFlow - get() = _currentPlaylistToRename + val currentPendingRenamePlaylist: StateFlow + get() = _currentPendingRenamePlaylist private val _currentPlaylistToExport = MutableStateFlow(null) /** An existing [Playlist] that is being exported. Null if none yet. */ @@ -71,7 +71,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M get() = _currentPlaylistToDelete private val _chosenName = MutableStateFlow(ChosenName.Empty) - /** The users chosen name for [currentPendingPlaylist] or [currentPlaylistToRename]. */ + /** The users chosen name for [currentPendingNewPlaylist] or [currentPendingRenamePlaylist]. */ val chosenName: StateFlow get() = _chosenName @@ -93,14 +93,15 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M var refreshChoicesWith: List? = null val deviceLibrary = musicRepository.deviceLibrary if (changes.deviceLibrary && deviceLibrary != null) { - _currentPendingPlaylist.value = - _currentPendingPlaylist.value?.let { pendingPlaylist -> - PendingPlaylist( + _currentPendingNewPlaylist.value = + _currentPendingNewPlaylist.value?.let { pendingPlaylist -> + PendingNewPlaylist( pendingPlaylist.preferredName, 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?.let { pendingSongs -> @@ -141,7 +142,7 @@ 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 songUids The [Music.UID]s of songs to be present in the playlist. @@ -150,6 +151,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M fun setPendingPlaylist( context: Context, songUids: Array, + template: String?, reason: PlaylistDecision.New.Reason ) { logD("Opening ${songUids.size} songs to create a playlist from") @@ -173,9 +175,9 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M possibleName } - _currentPendingPlaylist.value = + _currentPendingNewPlaylist.value = if (possibleName != null && songs != null) { - PendingPlaylist(possibleName, songs, reason) + PendingNewPlaylist(possibleName, songs, template, reason) } else { logW("Given song UIDs to create were invalid") null @@ -183,16 +185,28 @@ 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. */ - fun setPlaylistToRename(playlistUid: Music.UID) { + fun setPlaylistToRename( + playlistUid: Music.UID, + applySongUids: Array, + template: String?, + reason: PlaylistDecision.Rename.Reason + ) { logD("Opening playlist $playlistUid to rename") - _currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid) - if (_currentPlaylistToDelete.value == null) { - logW("Given playlist UID to rename was invalid") - } + val playlist = musicRepository.userLibrary?.findPlaylist(playlistUid) + val applySongs = + 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 + } } /** @@ -223,7 +237,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } /** - * Set a new [currentPendingPlaylist] from a new [Playlist] [Music.UID]. + * Set a new [currentPendingNewPlaylist] from a new [Playlist] [Music.UID]. * * @param playlistUid The [Music.UID] of the [Playlist] to delete. */ @@ -301,16 +315,24 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * 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 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) */ -data class PendingPlaylist( +data class PendingNewPlaylist( val preferredName: String, val songs: List, + val template: String?, val reason: PlaylistDecision.New.Reason ) +data class PendingRenamePlaylist( + val playlist: Playlist, + val applySongs: List, + val template: String?, + val reason: PlaylistDecision.Rename.Reason +) + /** * Represents the (processed) user input from the playlist naming dialogs. * diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/RenamePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/RenamePlaylistDialog.kt index 6c39855b8..da5265daf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/RenamePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/RenamePlaylistDialog.kt @@ -30,7 +30,6 @@ import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.music.MusicViewModel -import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD @@ -54,9 +53,14 @@ class RenamePlaylistDialog : ViewBindingMaterialDialogFragment - val playlist = unlikelyToBeNull(pickerModel.currentPlaylistToRename.value) + val pendingRenamePlaylist = + unlikelyToBeNull(pickerModel.currentPendingRenamePlaylist.value) val chosenName = pickerModel.chosenName.value as ChosenName.Valid - musicModel.renamePlaylist(playlist, chosenName.value) + musicModel.renamePlaylist( + pendingRenamePlaylist.playlist, + chosenName.value, + pendingRenamePlaylist.applySongs, + pendingRenamePlaylist.reason) findNavController().navigateUp() } .setNegativeButton(R.string.lbl_cancel, null) @@ -73,20 +77,23 @@ class RenamePlaylistDialog : ViewBindingMaterialDialogFragment() { } is PlaylistDecision.Rename -> { 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 -> { logD("Deleting ${decision.playlist}") diff --git a/app/src/main/res/layout/fragment_playback_panel.xml b/app/src/main/res/layout/fragment_playback_panel.xml index 573c443b7..050d2b86b 100644 --- a/app/src/main/res/layout/fragment_playback_panel.xml +++ b/app/src/main/res/layout/fragment_playback_panel.xml @@ -72,7 +72,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintBottom_toTopOf="@+id/playback_controls_container" - app:layout_constraintEnd_toEndOf="@+id/playback_cover_pager" + app:layout_constraintEnd_toEndOf="@+id/playback_info_container" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="parent" /> diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index 9d00b047c..c688464cb 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -430,6 +430,16 @@ + + + Date: Mon, 1 Jan 2024 20:37:05 -0700 Subject: [PATCH 64/72] build: bump to 3.3.0 Bump the app version to 3.3.0 (36). --- CHANGELOG.md | 2 ++ README.md | 4 ++-- app/build.gradle | 4 ++-- fastlane/metadata/android/en-US/changelogs/36.txt | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 219f42672..49474667d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## dev +## 3.3.0 + #### What's New - Added ability to rewind/skip tracks by swiping back/forward - Added support for demo release type diff --git a/README.md b/README.md index a9a0c1ef4..e57f93417 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index 5d07581f0..999ea5e8c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.2.1" - versionCode 36 + versionName "3.3.0" + versionCode 37 minSdk 24 targetSdk 34 diff --git a/fastlane/metadata/android/en-US/changelogs/36.txt b/fastlane/metadata/android/en-US/changelogs/36.txt index b0ac8dc87..397a4fefa 100644 --- a/fastlane/metadata/android/en-US/changelogs/36.txt +++ b/fastlane/metadata/android/en-US/changelogs/36.txt @@ -1,3 +1,3 @@ Auxio 3.2.0 refreshes the item management experience, with a new menu UI and playback options. This release fixes several critical issues identified in the previous version. -For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.2.0. \ No newline at end of file +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.3.0. \ No newline at end of file From 32432b18b63d473ccaf933b936846d459fb59469 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 21:36:46 -0700 Subject: [PATCH 65/72] music: fix m3u windows volume detection --- app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 53767789a..11ce3dea1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -262,6 +262,6 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte } private companion object { - val WINDOWS_VOLUME_PREFIX_REGEX = Regex("^[A-Za-z]:\\\\*") + val WINDOWS_VOLUME_PREFIX_REGEX = Regex("^[A-Za-z]:\\\\.*") } } From e500286b8b180f3adeca586f9254d8c5ecedb8f9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 21:37:45 -0700 Subject: [PATCH 66/72] music: support paths in documents Apprently these only report their IDs, frustratingly. --- .../auxio/music/fs/DocumentPathFactory.kt | 48 +++- .../org/oxycblt/auxio/music/fs/FsModule.kt | 11 +- .../auxio/music/fs/MediaStoreExtractor.kt | 227 ++--------------- .../music/fs/MediaStorePathInterpreter.kt | 237 ++++++++++++++++++ 4 files changed, 301 insertions(+), 222 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/fs/MediaStorePathInterpreter.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt index 5a9ebcfeb..eb977a1fe 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt @@ -18,8 +18,11 @@ 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 @@ -62,16 +65,38 @@ interface DocumentPathFactory { fun fromDocumentId(path: String): Path? } -class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : - DocumentPathFactory { +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? { - // Abuse the document contract and extract the encoded path from the URI. - // I've seen some implementations that just use getDocumentId. That no longer seems - // to work on Android 14 onwards. But spoofing our own document URI and then decoding - // it does for some reason. - val docUri = DocumentsContract.buildDocumentUri(uri.authority, uri.pathSegments[1]) - val docId = DocumentsContract.getDocumentId(docUri) - return fromDocumentId(docId) + 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? { @@ -113,5 +138,10 @@ class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: Vol 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")) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index 072164323..46a200562 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -37,8 +37,15 @@ class FsModule { VolumeManagerImpl(context.getSystemServiceCompat(StorageManager::class)) @Provides - fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) = - MediaStoreExtractor.from(context, volumeManager) + 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 = diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 618ad9b2a..90b5e2051 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -93,32 +93,24 @@ interface MediaStoreExtractor { * @param volumeManager [VolumeManager] required. * @return A new [MediaStoreExtractor] that will work best on the device's API level. */ - fun from(context: Context, volumeManager: VolumeManager): MediaStoreExtractor { - val pathInterpreter = - 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 -> - DataPathInterpreter.Factory(volumeManager) - else -> VolumePathInterpreter.Factory(volumeManager) - } - - val volumeInterpreter = + fun from( + context: Context, + pathInterpreterFactory: MediaStorePathInterpreter.Factory + ): MediaStoreExtractor { + val tagInterpreterFactory = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30TagInterpreter.Factory() else -> Api21TagInterpreter.Factory() } - return MediaStoreExtractorImpl(context, pathInterpreter, volumeInterpreter) + return MediaStoreExtractorImpl(context, pathInterpreterFactory, tagInterpreterFactory) } } } private class MediaStoreExtractorImpl( private val context: Context, - private val pathInterpreterFactory: PathInterpreter.Factory, + private val mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory, private val tagInterpreterFactory: TagInterpreter.Factory ) : MediaStoreExtractor { override suspend fun query( @@ -127,7 +119,9 @@ private class MediaStoreExtractorImpl( val start = System.currentTimeMillis() val projection = - BASE_PROJECTION + pathInterpreterFactory.projection + tagInterpreterFactory.projection + BASE_PROJECTION + + mediaStorePathInterpreterFactory.projection + + tagInterpreterFactory.projection var uniSelector = BASE_SELECTOR var uniArgs = listOf() @@ -139,7 +133,8 @@ private class MediaStoreExtractorImpl( // Set up the projection to follow the music directory configuration. if (constraints.musicDirs.dirs.isNotEmpty()) { - val pathSelector = pathInterpreterFactory.createSelector(constraints.musicDirs.dirs) + val pathSelector = + mediaStorePathInterpreterFactory.createSelector(constraints.musicDirs.dirs) if (pathSelector != null) { logD("Must select for directories") uniSelector += " AND " @@ -203,7 +198,7 @@ private class MediaStoreExtractorImpl( logD("Finished initialization in ${System.currentTimeMillis() - start}ms") return QueryImpl( cursor, - pathInterpreterFactory.wrap(cursor), + mediaStorePathInterpreterFactory.wrap(cursor), tagInterpreterFactory.wrap(cursor), genreNamesMap) } @@ -234,7 +229,7 @@ private class MediaStoreExtractorImpl( class QueryImpl( private val cursor: Cursor, - private val pathInterpreter: PathInterpreter, + private val mediaStorePathInterpreter: MediaStorePathInterpreter, private val tagInterpreter: TagInterpreter, private val genreNamesMap: Map ) : MediaStoreExtractor.Query { @@ -268,7 +263,8 @@ private class MediaStoreExtractorImpl( rawSong.dateModified = cursor.getLong(dateModifiedIndex) rawSong.extensionMimeType = cursor.getString(mimeTypeIndex) rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex) - return pathInterpreter.populate(rawSong) + rawSong.path = mediaStorePathInterpreter.extract() ?: return false + return true } override fun populateTags(rawSong: RawSong) { @@ -345,197 +341,6 @@ private class MediaStoreExtractorImpl( } } -/** - * Wrapper around a [Cursor] that interprets path information on a per-API/manufacturer basis. - * - * @author Alexander Capehart (OxygenCobalt) - */ -private sealed interface PathInterpreter { - /** - * Populate the [RawSong] with version-specific path information. - * - * @param rawSong The [RawSong] to populate. - * @return True if the path was successfully populated, false otherwise. - */ - fun populate(rawSong: RawSong): Boolean - - interface Factory { - /** The columns that must be added to a query to support this interpreter. */ - val projection: Array - - /** - * 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 [PathInterpreter] that will work best on the device's API level. - */ - fun wrap(cursor: Cursor): PathInterpreter - - /** - * 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): 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) - } -} - -/** - * Wrapper around a [Cursor] that interprets the DATA column as a path. Create an instance with - * [Factory]. - */ -private class DataPathInterpreter -private constructor(private val cursor: Cursor, volumeManager: VolumeManager) : PathInterpreter { - private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA) - private val volumes = volumeManager.getVolumes() - - override fun populate(rawSong: RawSong): Boolean { - 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)) { - rawSong.path = Path(volume, data.depth(volumePath.components.size)) - return true - } - } - - return false - } - - /** - * Factory for [DataPathInterpreter]. - * - * @param volumeManager The [VolumeManager] to use for volume information. - */ - class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { - override val projection: Array - get() = - arrayOf( - MediaStore.Audio.AudioColumns.DISPLAY_NAME, MediaStore.Audio.AudioColumns.DATA) - - override fun createSelector(paths: List): PathInterpreter.Factory.Selector? { - val args = mutableListOf() - 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 PathInterpreter.Factory.Selector(template, args) - } - - override fun wrap(cursor: Cursor): PathInterpreter = - DataPathInterpreter(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 VolumePathInterpreter -private constructor(private val cursor: Cursor, volumeManager: VolumeManager) : PathInterpreter { - 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 populate(rawSong: RawSong): Boolean { - // Find the StorageVolume whose MediaStore name corresponds to it. - val volumeName = cursor.getString(volumeIndex) - val volume = volumes.find { it.mediaStoreName == volumeName } ?: return false - // 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) - rawSong.path = Path(volume, components) - return true - } - - /** - * Factory for [VolumePathInterpreter]. - * - * @param volumeManager The [VolumeManager] to use for volume information. - */ - class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory { - override val projection: Array - 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): PathInterpreter.Factory.Selector? { - val args = mutableListOf() - 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 PathInterpreter.Factory.Selector(template, args) - } - - override fun wrap(cursor: Cursor): PathInterpreter = - VolumePathInterpreter(cursor, volumeManager) - } -} - /** * Wrapper around a [Cursor] that interprets certain tags on a per-API basis. * diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStorePathInterpreter.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStorePathInterpreter.kt new file mode 100644 index 000000000..1f821aaf4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStorePathInterpreter.kt @@ -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 . + */ + +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 + + /** + * 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): 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) + + 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 + get() = + arrayOf( + MediaStore.Audio.AudioColumns.DISPLAY_NAME, MediaStore.Audio.AudioColumns.DATA) + + override fun createSelector( + paths: List + ): MediaStorePathInterpreter.Factory.Selector? { + val args = mutableListOf() + 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 + 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 + ): MediaStorePathInterpreter.Factory.Selector? { + val args = mutableListOf() + 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) + } +} From afa73a2319d272a0cd9d91711c5ae8bd71ee0991 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 1 Jan 2024 21:38:39 -0700 Subject: [PATCH 67/72] music: fix playlist import naming --- app/src/main/res/menu/new_playlist_actions.xml | 2 +- app/src/main/res/values-be/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-hi/strings.xml | 2 +- app/src/main/res/values-hr/strings.xml | 2 +- app/src/main/res/values-pa/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values/strings.xml | 3 ++- 12 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/menu/new_playlist_actions.xml b/app/src/main/res/menu/new_playlist_actions.xml index 7b9916426..b50413e5d 100644 --- a/app/src/main/res/menu/new_playlist_actions.xml +++ b/app/src/main/res/menu/new_playlist_actions.xml @@ -3,7 +3,7 @@ + android:title="@string/lbl_imported_playlist" /> Няма альбомаў Дэма Дэманстрацыі - Імпартаваны плэйліст + Імпартаваны плэйліст Шлях Немагчыма імпартаваць плэйліст з гэтага файла Пусты плэйліст diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 27affdef0..57d8ea84a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -316,7 +316,7 @@ Žádná alba Demo Dema - Importovaný seznam skladeb + Importovaný seznam skladeb Cesta Prázdný seznam skladeb Nepodařilo se importovat seznam skladeb z tohoto souboru diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8554409f0..32626f27c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -307,7 +307,7 @@ Keine Alben Demo Demos - Importierte Wiedergabeliste + Importierte Wiedergabeliste Pfad Wiedergabeliste konnte nicht aus dieser Datei importiert werden Leere Wiedergabeliste diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5dc705c7d..fbf304499 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -311,7 +311,7 @@ Sin álbumes Demostración Demostraciones - Lista de reproducción importada + Lista de reproducción importada Ruta No se puede importar una lista de reproducción desde este archivo Lista de reproducción vacía diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 0e94266d1..d29a3564e 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -306,7 +306,7 @@ कोई एल्बम नहीं डेमो डेमो - इम्पोर्टेड प्लेलिस्ट + इम्पोर्टेड प्लेलिस्ट पथ इस फ़ाइल से प्लेलिस्ट इम्पोर्ट करने में असमर्थ खाली प्लेलिस्ट diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 27c3c22aa..0aa0251a7 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -300,7 +300,7 @@ Prijavi grešku Kopirano Nema albuma - Uvezen popis pjesama + Uvezen popis pjesama Staza Demo snimka Demo snimke diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 0e07a90cf..6176826db 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -299,7 +299,7 @@ ਕੋਈ ਐਲਬਮ ਨਹੀਂ ਡੈਮੋ ਡੈਮੋ - ਇੰਪੋਰਟਡ ਪਲੇਲਿਸਟ + ਇੰਪੋਰਟਡ ਪਲੇਲਿਸਟ ਮਾਰਗ ਇਸ ਫਾਈਲ ਤੋਂ ਪਲੇਲਿਸਟ ਨੂੰ ਆਯਾਤ ਕਰਨ ਵਿੱਚ ਅਸਮਰੱਥ ਖਾਲੀ ਪਲੇਲਿਸਟ diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bcd52e176..cb7dd1a08 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -314,7 +314,7 @@ Нет альбомов Демо Демонстрации - Импортированный плейлист + Импортированный плейлист Путь Невозможно импортировать плейлист из этого файла Пустой плейлист diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 15ebe65a3..9d8b54c34 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -311,7 +311,7 @@ Альбомів немає Демо Демонстрації - Імпортований список відтворення + Імпортований список відтворення Шлях Неможливо імпортувати список відтворення з цього файлу Порожній список відтворення diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6cb6dbd36..e0449d0bd 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -305,7 +305,7 @@ 无专辑 演示 样曲 - 导入了播放列表 + 导入了播放列表 路径 清空播放列表 无法从此文件导入播放列表 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2474e88de..045965a4d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,8 +89,9 @@ Playlists New playlist Empty playlist - Imported playlist + Imported playlist Import + Import playlist Export Export playlist Rename From 8a75295d9981935e1b1207a5989fce12790182ab Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 2 Jan 2024 15:34:53 -0700 Subject: [PATCH 68/72] home: reimplement speed dial overlay The stock overlay is not sufficient for our needs, as: 1. It seemingly cannot be set up without missing certain touch areas or disabling the touch area of the speed dial itself 2. The scrim can't be evenly applied everywhere in the app due to the nested expore UI. So, modify the speed dial to work without a scrim and reimplement the overlay touch behavior manually. --- .../org/oxycblt/auxio/home/HomeFragment.kt | 25 +++++++++++++++++++ .../oxycblt/auxio/home/ThemedSpeedDialView.kt | 22 +++++++++++++--- .../res/layout-w600dp-land/fragment_main.xml | 4 +++ app/src/main/res/layout/fragment_home.xml | 9 +------ app/src/main/res/layout/fragment_main.xml | 4 +++ 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 3c8a0dd90..fca2bf924 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.home import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem +import android.view.MotionEvent import android.view.View import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts @@ -77,6 +78,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.getColorCompat +import org.oxycblt.auxio.util.isUnder import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.lazyReflectedMethod import org.oxycblt.auxio.util.logD @@ -143,6 +145,21 @@ class HomeFragment : } // --- UI SETUP --- + + binding.root.rootView.apply { + // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate + // it ourselves. + findViewById(R.id.main_scrim).setOnTouchListener { _, event -> + handleSpeedDialBoundaryTouch(event) + false + } + + findViewById(R.id.sheet_scrim).setOnTouchListener { _, event -> + handleSpeedDialBoundaryTouch(event) + false + } + } + binding.homeAppbar.addOnOffsetChangedListener(this) binding.homeNormalToolbar.apply { setOnMenuItemClickListener(this@HomeFragment) @@ -589,6 +606,14 @@ class HomeFragment : } } + private fun handleSpeedDialBoundaryTouch(event: MotionEvent) { + val binding = requireBinding() + if (binding.homeNewPlaylistFab.isOpen && + !binding.homeNewPlaylistFab.isUnder(event.x, event.y)) { + binding.homeNewPlaylistFab.close() + } + } + private fun handleShow(show: Show?) { when (show) { is Show.SongDetails -> { diff --git a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt index af9150c6f..ab978eed4 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt @@ -24,7 +24,6 @@ import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.content.Context import android.content.res.ColorStateList -import android.graphics.Color import android.graphics.drawable.Drawable import android.graphics.drawable.RotateDrawable import android.os.Bundle @@ -41,6 +40,7 @@ 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 @@ -48,6 +48,7 @@ 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 /** @@ -64,6 +65,7 @@ import org.oxycblt.auxio.util.getDimenPixels */ class ThemedSpeedDialView : SpeedDialView { private var mainFabAnimator: Animator? = null + private val spacingSmall = context.getDimenPixels(R.dimen.spacing_small) constructor(context: Context) : super(context) @@ -174,7 +176,13 @@ class ThemedSpeedDialView : SpeedDialView { val fabBackgroundColor = context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface) val labelColor = context.getAttrColorCompat(android.R.attr.textColorSecondary) - val labelBackgroundColor = Color.TRANSPARENT + val labelBackgroundColor = + context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface) + val labelStroke = + context.getAttrColorCompat(com.google.android.material.R.attr.colorOutline) + 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, @@ -184,7 +192,7 @@ class ThemedSpeedDialView : SpeedDialView { .setFabImageTintColor(fabImageTintColor.defaultColor) .setFabBackgroundColor(fabBackgroundColor.defaultColor) .setLabelColor(labelColor.defaultColor) - .setLabelBackgroundColor(labelBackgroundColor) + .setLabelBackgroundColor(labelBackgroundColor.defaultColor) .setLabelClickable(actionItem.isLabelClickable) .setTheme(actionItem.theme) .create() @@ -199,7 +207,13 @@ class ThemedSpeedDialView : SpeedDialView { labelBackground.apply { useCompatPadding = false - setContentPadding(0, 0, 0, 0) + 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) diff --git a/app/src/main/res/layout-w600dp-land/fragment_main.xml b/app/src/main/res/layout-w600dp-land/fragment_main.xml index f7b562988..15beea699 100644 --- a/app/src/main/res/layout-w600dp-land/fragment_main.xml +++ b/app/src/main/res/layout-w600dp-land/fragment_main.xml @@ -17,6 +17,8 @@ app:navGraph="@navigation/inner" tools:layout="@layout/fragment_home" /> + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 78100da62..d8344e9bb 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -156,11 +156,6 @@ app:layout_anchor="@id/home_content"> - - + app:sdMainFabClosedSrc="@drawable/ic_add_24"/> - diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 986aab759..0e85a7e51 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -18,6 +18,8 @@ app:navGraph="@navigation/inner" tools:layout="@layout/fragment_home" /> + + + + From 7537d135f2f284cee3924e0458d6146d584450d2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 2 Jan 2024 15:38:34 -0700 Subject: [PATCH 69/72] ui: remove redundant sheet content inset --- .../java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt index c8fc0305c..238086454 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt @@ -68,12 +68,6 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri if (consumed != lastConsumed) { logD("Consumed amount changed, re-applying insets") lastConsumed = consumed - - val insets = lastInsets - if (insets != null) { - child.dispatchApplyWindowInsets(insets) - } - lastInsets?.let(child::dispatchApplyWindowInsets) measureContent(parent, child, consumed) layoutContent(child) From 2b55caadd105c34de3d827a4222a26423f953f75 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 2 Jan 2024 17:16:13 -0700 Subject: [PATCH 70/72] home: fix more speed dial touch problems Handle back presses gracefully without finicky behavior when doing back gestures. I've spent far too much time trying to make this sensible. I'm going to take a break. --- .../java/org/oxycblt/auxio/MainFragment.kt | 26 ++++++++ .../org/oxycblt/auxio/home/HomeFragment.kt | 62 ++++++++++++++++--- .../org/oxycblt/auxio/home/HomeViewModel.kt | 14 +++++ .../oxycblt/auxio/home/ThemedSpeedDialView.kt | 8 ++- .../org/oxycblt/auxio/music/MusicViewModel.kt | 4 +- 5 files changed, 102 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index ed1b47c7a..742018420 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -76,6 +76,7 @@ class MainFragment : private var sheetBackCallback: SheetBackPressedCallback? = null private var detailBackCallback: DetailBackPressedCallback? = null private var selectionBackCallback: SelectionBackPressedCallback? = null + private var speedDialBackCallback: SpeedDialBackPressedCallback? = null private var selectionNavigationListener: DialogAwareNavigationListener? = null private var lastInsets: WindowInsets? = null private var elevationNormal = 0f @@ -109,6 +110,8 @@ class MainFragment : DetailBackPressedCallback(detailModel).also { detailBackCallback = it } val selectionBackCallback = SelectionBackPressedCallback(listModel).also { selectionBackCallback = it } + val speedDialBackCallback = + SpeedDialBackPressedCallback(homeModel).also { speedDialBackCallback = it } selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection) @@ -158,6 +161,7 @@ class MainFragment : collect(detailModel.toShow.flow, ::handleShow) collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) + collectImmediately(homeModel.speedDialOpen, speedDialBackCallback::invalidateEnabled) collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled) collectImmediately(playbackModel.song, ::updateSong) 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 // onResume or otherwise the FragmentManager will have precedence. requireActivity().onBackPressedDispatcher.apply { + addCallback(viewLifecycleOwner, requireNotNull(speedDialBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback)) addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback)) @@ -197,6 +202,7 @@ class MainFragment : override fun onDestroyBinding(binding: FragmentMainBinding) { super.onDestroyBinding(binding) + speedDialBackCallback = null sheetBackCallback = null detailBackCallback = null selectionBackCallback = null @@ -218,6 +224,13 @@ class MainFragment : binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? 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 halfOutRatio = min(playbackRatio * 2, 1f) val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2 @@ -493,4 +506,17 @@ class MainFragment : 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 + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index fca2bf924..aacf82749 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -149,14 +149,20 @@ class HomeFragment : binding.root.rootView.apply { // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate // it ourselves. + findViewById(R.id.main_scrim).setOnClickListener { + homeModel.setSpeedDialOpen(false) + } + findViewById(R.id.main_scrim).setOnTouchListener { _, event -> handleSpeedDialBoundaryTouch(event) - false + } + + findViewById(R.id.sheet_scrim).setOnClickListener { + homeModel.setSpeedDialOpen(false) } findViewById(R.id.sheet_scrim).setOnTouchListener { _, event -> handleSpeedDialBoundaryTouch(event) - false } } @@ -212,6 +218,7 @@ class HomeFragment : binding.homeNewPlaylistFab.apply { inflate(R.menu.new_playlist_actions) setOnActionSelectedListener(this@HomeFragment) + setChangeListener(homeModel::setSpeedDialOpen) } hideAllFabs() @@ -224,6 +231,7 @@ class HomeFragment : collect(homeModel.recreateTabs.flow, ::handleRecreate) collectImmediately(homeModel.currentTabType, ::updateCurrentTab) collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab) + collect(homeModel.speedDialOpen, ::updateSpeedDial) collect(listModel.menu.flow, ::handleMenu) collectImmediately(listModel.selected, ::updateSelection) collectImmediately(musicModel.indexingState, ::updateIndexerState) @@ -246,6 +254,7 @@ class HomeFragment : storagePermissionLauncher = null binding.homeAppbar.removeOnOffsetChangedListener(this) binding.homeNormalToolbar.setOnMenuItemClickListener(null) + binding.homeNewPlaylistFab.setChangeListener(null) binding.homeNewPlaylistFab.setOnActionSelectedListener(null) } @@ -577,8 +586,6 @@ class HomeFragment : return } - logD(binding.homeShuffleFab.isOrWillBeShown) - if (binding.homeShuffleFab.isOrWillBeShown) { logD("Animating transition") binding.homeShuffleFab.hide( @@ -606,12 +613,51 @@ class HomeFragment : } } - private fun handleSpeedDialBoundaryTouch(event: MotionEvent) { + private fun updateSpeedDial(open: Boolean) { val binding = requireBinding() - if (binding.homeNewPlaylistFab.isOpen && - !binding.homeNewPlaylistFab.isUnder(event.x, event.y)) { - binding.homeNewPlaylistFab.close() + + binding.root.rootView.apply { + // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate + // it ourselves. + findViewById(R.id.main_scrim).isClickable = open + findViewById(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 (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?) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index bb9311c84..51bc04976 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -156,6 +156,10 @@ constructor( /** A marker for whether the user is fast-scrolling in the home view or not. */ val isFastScrolling: StateFlow = _isFastScrolling + private val _speedDialOpen = MutableStateFlow(false) + /** A marker for whether the speed dial is open or not. */ + val speedDialOpen: StateFlow = _speedDialOpen + private val _showOuter = MutableEvent() val showOuter: Event get() = _showOuter @@ -293,6 +297,16 @@ constructor( _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() { _showOuter.put(Outer.Settings) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt index ab978eed4..1a9b5174d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt @@ -66,6 +66,7 @@ import org.oxycblt.auxio.util.getDimenPixels 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) @@ -126,6 +127,7 @@ class ThemedSpeedDialView : SpeedDialView { }) start() } + innerChangeListener?.invoke(isOpen) } }) } @@ -178,8 +180,6 @@ class ThemedSpeedDialView : SpeedDialView { val labelColor = context.getAttrColorCompat(android.R.attr.textColorSecondary) val labelBackgroundColor = context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface) - val labelStroke = - context.getAttrColorCompat(com.google.android.material.R.attr.colorOutline) val labelElevation = context.getDimen(com.google.android.material.R.dimen.m3_card_elevated_elevation) val cornerRadius = context.getDimenPixels(R.dimen.spacing_medium) @@ -237,6 +237,10 @@ class ThemedSpeedDialView : SpeedDialView { } } + fun setChangeListener(listener: ((Boolean) -> Unit)?) { + innerChangeListener = listener + } + companion object { private val VIEW_PROPERTY_BACKGROUND_TINT = object : Property(Int::class.java, "backgroundTint") { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 97a8fd8f0..6140261a9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -224,9 +224,9 @@ constructor( * @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 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. + * 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. + * does not need to be specified in normal use. */ fun renamePlaylist( playlist: Playlist, From b74e6229230dd53ceb97da67dfa4dc8898b450f2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 2 Jan 2024 17:22:56 -0700 Subject: [PATCH 71/72] info: add missing fastlane changelog --- fastlane/metadata/android/en-US/changelogs/37.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/37.txt diff --git a/fastlane/metadata/android/en-US/changelogs/37.txt b/fastlane/metadata/android/en-US/changelogs/37.txt new file mode 100644 index 000000000..fe19658d7 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/37.txt @@ -0,0 +1,2 @@ +Auxio 3.3.0 adds the ability to import and export playlists, skip gestures, and fixes/improvements to the music loader. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.3.0 \ No newline at end of file From 43af2d866d305c2cb7caa8eda0f4027b0e3f82bf Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 3 Jan 2024 09:26:28 -0700 Subject: [PATCH 72/72] home: fix shuffle not working on rotate --- app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index aacf82749..be9d83007 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -633,7 +633,7 @@ class HomeFragment : private fun handleSpeedDialBoundaryTouch(event: MotionEvent): Boolean { val binding = binding ?: return false - if (binding.homeNewPlaylistFab.isUnder(event.x, event.y)) { + 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