From 24dbd04ca6d261322256694320d48ad58e288f20 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 10 Jul 2024 06:51:32 -0600 Subject: [PATCH 01/87] music: fix broken name comparator --- app/src/main/java/org/oxycblt/auxio/music/info/Name.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 30626f01e..3f5662b8d 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 @@ -70,12 +70,11 @@ sealed interface Name : Comparable { final override fun compareTo(other: Name) = when (other) { is Known -> { - // Progressively compare the sort tokens between each known name. - sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) -> + val result = sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) -> acc.takeIf { it != 0 } ?: token.compareTo(otherToken) } + if (result != 0) result else sortTokens.size.compareTo(other.sortTokens.size) } - // Unknown names always come before known names. is Unknown -> 1 } From cf887cacb7ccab227ae9c3b77efcd3cd9349e28e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 10 Jul 2024 06:54:39 -0600 Subject: [PATCH 02/87] build: bump to 3.5.2 Bump to version 3.5.2 (48). --- CHANGELOG.md | 5 +++++ README.md | 4 ++-- app/build.gradle | 4 ++-- fastlane/metadata/android/en-US/changelogs/48.txt | 3 +++ 4 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/48.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4b66905..906d311b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 3.5.2 + +#### What's Fixed +- Fixed music loading failure from improper sort systems (For real this time) + ## 3.5.1 #### What's Fixed diff --git a/README.md b/README.md index 93bf8e848..6a3e6a632 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 206f0c14b..fd4bfacc9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.5.1" - versionCode 47 + versionName "3.5.2" + versionCode 48 minSdk 24 targetSdk 34 diff --git a/fastlane/metadata/android/en-US/changelogs/48.txt b/fastlane/metadata/android/en-US/changelogs/48.txt new file mode 100644 index 000000000..44e3fc9d1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/48.txt @@ -0,0 +1,3 @@ +Auxio 3.5.0 adds support for android auto alongside various playback and music quality of life improvements. +This release fixes a critical bug with the music loader. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.2 \ No newline at end of file From 7a00c3c6aa9b62f639f1958b56fbd5199437805f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 14 Aug 2024 18:46:52 -0600 Subject: [PATCH 03/87] music: parse singular spaced artist tags On ID3 and Vorbis. --- .../oxycblt/auxio/music/metadata/TagWorker.kt | 68 ++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index d30e5324e..7db8ef662 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -153,8 +153,9 @@ private class TagWorkerImpl( private fun populateWithId3v2(textFrames: Map>) { // Song + logD(textFrames) (textFrames["TXXX:musicbrainz release track id"] - ?: textFrames["TXXX:musicbrainz_releasetrackid"]) + ?: textFrames["TXXX:musicbrainz_releasetrackid"]) ?.let { rawSong.musicBrainzId = it.first() } textFrames["TIT2"]?.let { rawSong.name = it.first() } textFrames["TSOT"]?.let { rawSong.sortName = it.first() } @@ -179,9 +180,9 @@ private class TagWorkerImpl( // TODO: Handle dates that are in "January" because the actual specific release date // isn't known? (textFrames["TDOR"]?.run { Date.from(first()) } - ?: textFrames["TDRC"]?.run { Date.from(first()) } - ?: textFrames["TDRL"]?.run { Date.from(first()) } - ?: parseId3v23Date(textFrames)) + ?: textFrames["TDRC"]?.run { Date.from(first()) } + ?: textFrames["TDRL"]?.run { Date.from(first()) } + ?: parseId3v23Date(textFrames)) ?.let { rawSong.date = it } // Album @@ -191,34 +192,36 @@ private class TagWorkerImpl( textFrames["TALB"]?.let { rawSong.albumName = it.first() } textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() } (textFrames["TXXX:musicbrainz album type"] - ?: textFrames["TXXX:releasetype"] ?: - // This is a non-standard iTunes extension - textFrames["GRP1"]) + ?: textFrames["TXXX:releasetype"] ?: + // This is a non-standard iTunes extension + textFrames["GRP1"]) ?.let { rawSong.releaseTypes = it } // Artist (textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let { rawSong.artistMusicBrainzIds = it } - (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it } + (textFrames["TXXX:artists"] ?: textFrames["TPE1"] + ?: textFrames["TXXX:artist"])?.let { rawSong.artistNames = it } (textFrames["TXXX:artistssort"] - ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] - ?: textFrames["TSOP"]) + ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] + ?: textFrames["TSOP"] ?: textFrames["artistsort"] ?: textFrames["TXXX:artist sort"]) ?.let { rawSong.artistSortNames = it } // Album artist (textFrames["TXXX:musicbrainz album artist id"] - ?: textFrames["TXXX:musicbrainz_albumartistid"]) + ?: textFrames["TXXX:musicbrainz_albumartistid"]) ?.let { rawSong.albumArtistMusicBrainzIds = it } (textFrames["TXXX:albumartists"] - ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] - ?: textFrames["TPE2"]) + ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] + ?: textFrames["TPE2"] ?: textFrames["TXXX:albumartist"] + ?: textFrames["TXXX:album artist"]) ?.let { rawSong.albumArtistNames = it } (textFrames["TXXX:albumartistssort"] - ?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"] - ?: textFrames["TXXX:albumartistsort"] - // This is a non-standard iTunes extension - ?: textFrames["TSO2"]) + ?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"] + ?: textFrames["TXXX:albumartistsort"] + // This is a non-standard iTunes extension + ?: textFrames["TSO2"] ?: textFrames["TXXX:album artist sort"]) ?.let { rawSong.albumArtistSortNames = it } // Genre @@ -226,7 +229,7 @@ private class TagWorkerImpl( // Compilation Flag (textFrames["TCMP"] // This is a non-standard itunes extension - ?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"]) + ?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"]) ?.let { // Ignore invalid instances of this tag if (it.size != 1 || it[0] != "1") return@let @@ -288,14 +291,16 @@ private class TagWorkerImpl( // Track. parseVorbisPositionField( - comments["tracknumber"]?.first(), - (comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first()) + comments["tracknumber"]?.first(), + (comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first() + ) ?.let { rawSong.track = it } // Disc and it's subtitle name. parseVorbisPositionField( - comments["discnumber"]?.first(), - (comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first()) + comments["discnumber"]?.first(), + (comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first() + ) ?.let { rawSong.disc = it } comments["discsubtitle"]?.let { rawSong.subtitle = it.first() } @@ -306,8 +311,8 @@ private class TagWorkerImpl( // 3. Year, as old vorbis tags tended to use this (I know this because it's the only // date tag that android supports, so it must be 15 years old or more!) (comments["originaldate"]?.run { Date.from(first()) } - ?: comments["date"]?.run { Date.from(first()) } - ?: comments["year"]?.run { Date.from(first()) }) + ?: comments["date"]?.run { Date.from(first()) } + ?: comments["year"]?.run { Date.from(first()) }) ?.let { rawSong.date = it } // Album @@ -326,7 +331,8 @@ private class TagWorkerImpl( } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } (comments["artistssort"] - ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"]) + ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"] + ?: comments["artist sort"]) ?.let { rawSong.artistSortNames = it } // Album artist @@ -334,12 +340,12 @@ private class TagWorkerImpl( rawSong.albumArtistMusicBrainzIds = it } (comments["albumartists"] - ?: comments["album_artists"] ?: comments["album artists"] - ?: comments["albumartist"]) + ?: comments["album_artists"] ?: comments["album artists"] + ?: comments["albumartist"] ?: comments["album artist"]) ?.let { rawSong.albumArtistNames = it } (comments["albumartistssort"] - ?: comments["albumartists_sort"] ?: comments["albumartists sort"] - ?: comments["albumartistsort"]) + ?: comments["albumartists_sort"] ?: comments["albumartists sort"] + ?: comments["albumartistsort"] ?: comments["album artist sort"]) ?.let { rawSong.albumArtistSortNames = it } // Genre @@ -363,10 +369,10 @@ private class TagWorkerImpl( // normally the only tag used for opus files, but some software still writes replay gain // tags anyway. (comments["r128_track_gain"]?.parseR128Adjustment() - ?: comments["replaygain_track_gain"]?.parseReplayGainAdjustment()) + ?: comments["replaygain_track_gain"]?.parseReplayGainAdjustment()) ?.let { rawSong.replayGainTrackAdjustment = it } (comments["r128_album_gain"]?.parseR128Adjustment() - ?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment()) + ?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment()) ?.let { rawSong.replayGainAlbumAdjustment = it } } From d10f84efa870768253d616e4f283aa7ee33d6e21 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 7 Aug 2024 21:23:31 -0600 Subject: [PATCH 04/87] widgets: move size fixing into a transform --- .../extractor/RoundedRectTransformation.kt | 5 +- .../widgets/WidgetBitmapTransformation.kt | 56 +++++++++++++++++++ .../oxycblt/auxio/widgets/WidgetComponent.kt | 24 ++++---- .../org/oxycblt/auxio/widgets/WidgetUtil.kt | 19 ------- 4 files changed, 70 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/widgets/WidgetBitmapTransformation.kt diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt index c8d3ee145..0d32d20de 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt @@ -107,7 +107,10 @@ class RoundedRectTransformation( } private fun calculateOutputSize(input: Bitmap, size: Size): Pair { - // MODIFICATION: Remove short-circuiting for original size and input size + if (size == Size.ORIGINAL) { + // This path only runs w/the widget code, which already normalizes widget sizes + return input.width to input.height + } val multiplier = DecodeUtils.computeSizeMultiplier( srcWidth = input.width, diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetBitmapTransformation.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetBitmapTransformation.kt new file mode 100644 index 000000000..ac44e418d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetBitmapTransformation.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 Auxio Project + * WidgetBitmapTransformation.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.widgets + +import android.content.res.Resources +import android.graphics.Bitmap +import coil.size.Size +import coil.transform.Transformation +import kotlin.math.sqrt + +class WidgetBitmapTransformation(private val reduce: Float) : Transformation { + private val metrics = Resources.getSystem().displayMetrics + private val sw = metrics.widthPixels + private val sh = metrics.heightPixels + // Cap memory usage at 1.5 times the size of the display + // 1.5 * 4 bytes/pixel * w * h ==> 6 * w * h + // https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/appwidget/java/com/android/server/appwidget/AppWidgetServiceImpl.java + // Of course since OEMs randomly patch this check, we give a lot of slack. + private val maxBitmapArea = (1.5 * sw * sh / reduce).toInt() + + override val cacheKey: String + get() = "WidgetBitmapTransformation:${maxBitmapArea}" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + if (size !== Size.ORIGINAL) { + // The widget loading stack basically discards the size parameter since there's no + // sane value from the get-go, all this transform does is actually dynamically apply + // the size cap so this transform must always be zero. + throw IllegalArgumentException("WidgetBitmapTransformation requires original size.") + } + val inputArea = input.width * input.height + if (inputArea != maxBitmapArea) { + val scale = sqrt(maxBitmapArea / inputArea.toDouble()) + val newWidth = (input.width * scale).toInt() + val newHeight = (input.height * scale).toInt() + return Bitmap.createScaledBitmap(input, newWidth, newHeight, true) + } + return input + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 09ac5e8f1..8631845a5 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -22,6 +22,7 @@ import android.content.Context import android.graphics.Bitmap import android.os.Build import coil.request.ImageRequest +import coil.size.Size import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R @@ -96,24 +97,19 @@ constructor( 0 } - return if (cornerRadius > 0) { - // If rounded, reduce the bitmap size further to obtain more pronounced - // rounded corners. - builder.size(getSafeRemoteViewsImageSize(context, 10f)) - val cornersTransformation = - RoundedRectTransformation(cornerRadius.toFloat()) + val transformations = buildList { if (imageSettings.forceSquareCovers) { - builder.transformations( - SquareCropTransformation.INSTANCE, cornersTransformation) + add(SquareCropTransformation.INSTANCE) + } + if (cornerRadius > 0) { + add(WidgetBitmapTransformation(10f)) + add(RoundedRectTransformation(cornerRadius.toFloat())) } else { - builder.transformations(cornersTransformation) + add(WidgetBitmapTransformation(2f)) } - } else { - if (imageSettings.forceSquareCovers) { - builder.transformations(SquareCropTransformation.INSTANCE) - } - builder.size(getSafeRemoteViewsImageSize(context)) } + + return builder.size(Size.ORIGINAL).transformations(transformations) } override fun onCompleted(bitmap: Bitmap?) { diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt index 799aa8a67..953af14c8 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt @@ -27,7 +27,6 @@ import android.widget.RemoteViews import androidx.annotation.DrawableRes import androidx.annotation.IdRes import androidx.annotation.LayoutRes -import kotlin.math.sqrt import org.oxycblt.auxio.util.isLandscape import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent @@ -46,24 +45,6 @@ fun newRemoteViews(context: Context, @LayoutRes layoutRes: Int): RemoteViews { return views } -/** - * Get an image size guaranteed to not exceed the [RemoteViews] bitmap memory limit, assuming that - * there is only one image. - * - * @param context [Context] required to perform calculation. - * @param reduce Optional multiplier to reduce the image size. Recommended value is 2 to avoid - * device-specific variations in memory limit. - * @return The dimension of a bitmap that can be safely used in [RemoteViews]. - */ -fun getSafeRemoteViewsImageSize(context: Context, reduce: Float = 2f): Int { - val metrics = context.resources.displayMetrics - val sw = metrics.widthPixels - val sh = metrics.heightPixels - // Maximum size is 1/3 total screen area * 4 bytes per pixel. Reverse - // that to obtain the image size. - return sqrt((6f / 4f / reduce) * sw * sh).toInt() -} - /** * Set the background resource of a [RemoteViews] View. * From ba46895ad182d582545696dc3f8e46f7788fc3d7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 8 Aug 2024 19:10:37 -0600 Subject: [PATCH 05/87] widget: increase bitmap reduction --- .../main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 8631845a5..90f2add86 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -102,10 +102,10 @@ constructor( add(SquareCropTransformation.INSTANCE) } if (cornerRadius > 0) { - add(WidgetBitmapTransformation(10f)) + add(WidgetBitmapTransformation(20f)) add(RoundedRectTransformation(cornerRadius.toFloat())) } else { - add(WidgetBitmapTransformation(2f)) + add(WidgetBitmapTransformation(4f)) } } From 67e51ab54c1d6de8f41f8afa9ed66e88d5aa2483 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 9 Aug 2024 21:27:26 -0600 Subject: [PATCH 06/87] widgets: decrease bitmap reduction --- .../main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 90f2add86..1e95eb6f4 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -102,10 +102,10 @@ constructor( add(SquareCropTransformation.INSTANCE) } if (cornerRadius > 0) { - add(WidgetBitmapTransformation(20f)) + add(WidgetBitmapTransformation(15f)) add(RoundedRectTransformation(cornerRadius.toFloat())) } else { - add(WidgetBitmapTransformation(4f)) + add(WidgetBitmapTransformation(3f)) } } From dad0d75d977bdbb4c4d4702ca1b5ea795faa4e44 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 14 Aug 2024 18:53:04 -0600 Subject: [PATCH 07/87] music: avoid foreground crash from early loading --- app/src/main/java/org/oxycblt/auxio/AuxioService.kt | 7 ++++--- .../main/java/org/oxycblt/auxio/music/MusicRepository.kt | 3 --- .../oxycblt/auxio/music/service/IndexerServiceFragment.kt | 6 ++++++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 64121e6d1..6dc66bd76 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -43,19 +43,20 @@ class AuxioService : MediaLibraryService(), ForegroundListener { } override fun onBind(intent: Intent?): IBinder? { - handleIntent(intent) + onHandleForeground(intent) return super.onBind(intent) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // TODO: Start command occurring from a foreign service basically implies a detached // service, we might need more handling here. - handleIntent(intent) + onHandleForeground(intent) return super.onStartCommand(intent, flags, startId) } - private fun handleIntent(intent: Intent?) { + private fun onHandleForeground(intent: Intent?) { val nativeStart = intent?.getBooleanExtra(INTENT_KEY_NATIVE_START, false) ?: false + indexingFragment.start() if (!nativeStart) { // Some foreign code started us, no guarantees about foreground stability. Figure // out what to do. 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 b8a3d2b26..7d60f825d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -280,9 +280,6 @@ constructor( } logD("Registering worker $worker") indexingWorker = worker - if (indexingState == null) { - worker.requestIndex(true) - } } @Synchronized diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt index 6362def6b..571e96ca7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt @@ -79,6 +79,12 @@ constructor( foregroundListener = null } + fun start() { + if (musicRepository.indexingState == null) { + requestIndex(true) + } + } + fun createNotification(post: (IndexerNotification?) -> Unit) { val state = musicRepository.indexingState if (state is IndexingState.Indexing) { From 5c779f6d8929bd32ba8fd7e1307f52e43edfa41b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 14 Aug 2024 18:58:52 -0600 Subject: [PATCH 08/87] info: update changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 906d311b5..d953459b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 3.5.3 + +#### What's Improved +- Added support for informal singular-spaced tags like `album artist` in +file metadata + +#### What's Fixed +- Fix "Foregroud not allowed" music loading crash from starting too early +- Fixed widget not loading on some devices due to the cover being too large + ## 3.5.2 #### What's Fixed From aa140bebaa344c064a219d99e460f570b6a147f9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 14 Aug 2024 18:58:59 -0600 Subject: [PATCH 09/87] all: reformat --- .../java/org/oxycblt/auxio/music/info/Name.kt | 7 +- .../oxycblt/auxio/music/metadata/TagWorker.kt | 72 +++++++++---------- 2 files changed, 40 insertions(+), 39 deletions(-) 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 3f5662b8d..30f4564c8 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 @@ -70,9 +70,10 @@ sealed interface Name : Comparable { final override fun compareTo(other: Name) = when (other) { is Known -> { - val result = sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) -> - acc.takeIf { it != 0 } ?: token.compareTo(otherToken) - } + val result = + sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) -> + acc.takeIf { it != 0 } ?: token.compareTo(otherToken) + } if (result != 0) result else sortTokens.size.compareTo(other.sortTokens.size) } is Unknown -> 1 diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index 7db8ef662..b0e23b0a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -155,7 +155,7 @@ private class TagWorkerImpl( // Song logD(textFrames) (textFrames["TXXX:musicbrainz release track id"] - ?: textFrames["TXXX:musicbrainz_releasetrackid"]) + ?: textFrames["TXXX:musicbrainz_releasetrackid"]) ?.let { rawSong.musicBrainzId = it.first() } textFrames["TIT2"]?.let { rawSong.name = it.first() } textFrames["TSOT"]?.let { rawSong.sortName = it.first() } @@ -180,9 +180,9 @@ private class TagWorkerImpl( // TODO: Handle dates that are in "January" because the actual specific release date // isn't known? (textFrames["TDOR"]?.run { Date.from(first()) } - ?: textFrames["TDRC"]?.run { Date.from(first()) } - ?: textFrames["TDRL"]?.run { Date.from(first()) } - ?: parseId3v23Date(textFrames)) + ?: textFrames["TDRC"]?.run { Date.from(first()) } + ?: textFrames["TDRL"]?.run { Date.from(first()) } + ?: parseId3v23Date(textFrames)) ?.let { rawSong.date = it } // Album @@ -192,36 +192,38 @@ private class TagWorkerImpl( textFrames["TALB"]?.let { rawSong.albumName = it.first() } textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() } (textFrames["TXXX:musicbrainz album type"] - ?: textFrames["TXXX:releasetype"] ?: - // This is a non-standard iTunes extension - textFrames["GRP1"]) + ?: textFrames["TXXX:releasetype"] ?: + // This is a non-standard iTunes extension + textFrames["GRP1"]) ?.let { rawSong.releaseTypes = it } // Artist (textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let { rawSong.artistMusicBrainzIds = it } - (textFrames["TXXX:artists"] ?: textFrames["TPE1"] - ?: textFrames["TXXX:artist"])?.let { rawSong.artistNames = it } + (textFrames["TXXX:artists"] ?: textFrames["TPE1"] ?: textFrames["TXXX:artist"])?.let { + rawSong.artistNames = it + } (textFrames["TXXX:artistssort"] - ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] - ?: textFrames["TSOP"] ?: textFrames["artistsort"] ?: textFrames["TXXX:artist sort"]) + ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] + ?: textFrames["TSOP"] ?: textFrames["artistsort"] + ?: textFrames["TXXX:artist sort"]) ?.let { rawSong.artistSortNames = it } // Album artist (textFrames["TXXX:musicbrainz album artist id"] - ?: textFrames["TXXX:musicbrainz_albumartistid"]) + ?: textFrames["TXXX:musicbrainz_albumartistid"]) ?.let { rawSong.albumArtistMusicBrainzIds = it } (textFrames["TXXX:albumartists"] - ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] - ?: textFrames["TPE2"] ?: textFrames["TXXX:albumartist"] - ?: textFrames["TXXX:album artist"]) + ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] + ?: textFrames["TPE2"] ?: textFrames["TXXX:albumartist"] + ?: textFrames["TXXX:album artist"]) ?.let { rawSong.albumArtistNames = it } (textFrames["TXXX:albumartistssort"] - ?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"] - ?: textFrames["TXXX:albumartistsort"] - // This is a non-standard iTunes extension - ?: textFrames["TSO2"] ?: textFrames["TXXX:album artist sort"]) + ?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"] + ?: textFrames["TXXX:albumartistsort"] + // This is a non-standard iTunes extension + ?: textFrames["TSO2"] ?: textFrames["TXXX:album artist sort"]) ?.let { rawSong.albumArtistSortNames = it } // Genre @@ -229,7 +231,7 @@ private class TagWorkerImpl( // Compilation Flag (textFrames["TCMP"] // This is a non-standard itunes extension - ?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"]) + ?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"]) ?.let { // Ignore invalid instances of this tag if (it.size != 1 || it[0] != "1") return@let @@ -291,16 +293,14 @@ private class TagWorkerImpl( // Track. parseVorbisPositionField( - comments["tracknumber"]?.first(), - (comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first() - ) + comments["tracknumber"]?.first(), + (comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first()) ?.let { rawSong.track = it } // Disc and it's subtitle name. parseVorbisPositionField( - comments["discnumber"]?.first(), - (comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first() - ) + comments["discnumber"]?.first(), + (comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first()) ?.let { rawSong.disc = it } comments["discsubtitle"]?.let { rawSong.subtitle = it.first() } @@ -311,8 +311,8 @@ private class TagWorkerImpl( // 3. Year, as old vorbis tags tended to use this (I know this because it's the only // date tag that android supports, so it must be 15 years old or more!) (comments["originaldate"]?.run { Date.from(first()) } - ?: comments["date"]?.run { Date.from(first()) } - ?: comments["year"]?.run { Date.from(first()) }) + ?: comments["date"]?.run { Date.from(first()) } + ?: comments["year"]?.run { Date.from(first()) }) ?.let { rawSong.date = it } // Album @@ -331,8 +331,8 @@ private class TagWorkerImpl( } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } (comments["artistssort"] - ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"] - ?: comments["artist sort"]) + ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"] + ?: comments["artist sort"]) ?.let { rawSong.artistSortNames = it } // Album artist @@ -340,12 +340,12 @@ private class TagWorkerImpl( rawSong.albumArtistMusicBrainzIds = it } (comments["albumartists"] - ?: comments["album_artists"] ?: comments["album artists"] - ?: comments["albumartist"] ?: comments["album artist"]) + ?: comments["album_artists"] ?: comments["album artists"] ?: comments["albumartist"] + ?: comments["album artist"]) ?.let { rawSong.albumArtistNames = it } (comments["albumartistssort"] - ?: comments["albumartists_sort"] ?: comments["albumartists sort"] - ?: comments["albumartistsort"] ?: comments["album artist sort"]) + ?: comments["albumartists_sort"] ?: comments["albumartists sort"] + ?: comments["albumartistsort"] ?: comments["album artist sort"]) ?.let { rawSong.albumArtistSortNames = it } // Genre @@ -369,10 +369,10 @@ private class TagWorkerImpl( // normally the only tag used for opus files, but some software still writes replay gain // tags anyway. (comments["r128_track_gain"]?.parseR128Adjustment() - ?: comments["replaygain_track_gain"]?.parseReplayGainAdjustment()) + ?: comments["replaygain_track_gain"]?.parseReplayGainAdjustment()) ?.let { rawSong.replayGainTrackAdjustment = it } (comments["r128_album_gain"]?.parseR128Adjustment() - ?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment()) + ?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment()) ?.let { rawSong.replayGainAlbumAdjustment = it } } From 3fa5628a1efb5443afc37c8a46b79babaf25ee37 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 17 Aug 2024 18:10:55 -0600 Subject: [PATCH 10/87] playback: introduce foreground-safe restores - Allow DeferredPlayback.RestoreState to force-start playback - Allow DeferredPlayback.RestoreState to specify a fallback action guaranteed to succeed --- .../auxio/playback/service/ExoPlaybackStateHolder.kt | 12 ++++++++++-- .../auxio/playback/state/PlaybackStateHolder.kt | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 058b33403..804470a35 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -163,10 +163,18 @@ class ExoPlaybackStateHolder( is DeferredPlayback.RestoreState -> { logD("Restoring playback state") restoreScope.launch { - persistenceRepository.readState()?.let { + val state = persistenceRepository.readState() + if (state != null) { // Apply the saved state on the main thread to prevent code expecting // state updates on the main thread from crashing. - withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) } + withContext(Dispatchers.Main) { + playbackManager.applySavedState(state, false) + if (action.play) { + playbackManager.playing(true) + } + } + } else if (action.fallback != null) { + playbackManager.playDeferred(action.fallback) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 857ac6898..471a6498c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -276,7 +276,7 @@ data class QueueChange(val type: Type, val instructions: UpdateInstructions) { /** Possible long-running background tasks handled by the background playback task. */ sealed interface DeferredPlayback { /** Restore the previously saved playback state. */ - data object RestoreState : DeferredPlayback + data class RestoreState(val play: Boolean, val fallback: DeferredPlayback? = null) : DeferredPlayback /** * Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut. From ea9c5d3c88c7e7a3d4b716af22f51618a0809486 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 17 Aug 2024 18:21:39 -0600 Subject: [PATCH 11/87] tasker: add start action Add a tasker action to start AuxioService in a HIGHLY limited ammner. Resolves #754. --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 10 +++ .../java/org/oxycblt/auxio/AuxioService.kt | 16 +++-- .../java/org/oxycblt/auxio/IntegerTable.kt | 7 +- .../java/org/oxycblt/auxio/MainActivity.kt | 4 +- .../service/MediaSessionServiceFragment.kt | 16 ++++- .../playback/state/PlaybackStateHolder.kt | 3 +- .../java/org/oxycblt/auxio/tasker/Start.kt | 70 +++++++++++++++++++ 8 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/tasker/Start.kt diff --git a/app/build.gradle b/app/build.gradle index fd4bfacc9..15206897f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -151,6 +151,9 @@ dependencies { // Speed dial implementation "com.leinardi.android:speed-dial:3.3.0" + // Tasker integration + implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10' + // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 960d2c8ae..564220423 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -135,5 +135,15 @@ android:resource="@xml/widget_info" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 6dc66bd76..f0352d523 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -55,13 +55,9 @@ class AuxioService : MediaLibraryService(), ForegroundListener { } private fun onHandleForeground(intent: Intent?) { - val nativeStart = intent?.getBooleanExtra(INTENT_KEY_NATIVE_START, false) ?: false + val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1 indexingFragment.start() - if (!nativeStart) { - // Some foreign code started us, no guarantees about foreground stability. Figure - // out what to do. - mediaSessionFragment.handleNonNativeStart() - } + mediaSessionFragment.start(startId) } override fun onTaskRemoved(rootIntent: Intent?) { @@ -87,6 +83,7 @@ class AuxioService : MediaLibraryService(), ForegroundListener { if (change == ForegroundListener.Change.MEDIA_SESSION) { mediaSessionFragment.createNotification { startForeground(it.notificationId, it.notification) + isForeground = true } } // Nothing changed, but don't show anything music related since we can always @@ -95,16 +92,21 @@ class AuxioService : MediaLibraryService(), ForegroundListener { indexingFragment.createNotification { if (it != null) { startForeground(it.code, it.build()) + isForeground = true } else { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + isForeground = false } } } } companion object { + var isForeground = false + private set + // This is only meant for Auxio to internally ensure that it's state management will work. - const val INTENT_KEY_NATIVE_START = BuildConfig.APPLICATION_ID + ".service.NATIVE_START" + const val INTENT_KEY_START_ID = BuildConfig.APPLICATION_ID + ".service.START_ID" } } diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 86f3d1984..eb1a27d53 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -59,6 +59,10 @@ object IntegerTable { const val INDEXER_NOTIFICATION_CODE = 0xA0A1 /** MainActivity Intent request code */ const val REQUEST_CODE = 0xA0C0 + /** Activity AuxioService Start ID */ + const val START_ID_ACTIVITY = 0xA050 + /** Tasker AuxioService Start ID */ + const val START_ID_TASKER = 0xA051 /** RepeatMode.NONE */ const val REPEAT_MODE_NONE = 0xA100 /** RepeatMode.ALL */ @@ -133,7 +137,4 @@ object IntegerTable { const val PLAY_SONG_FROM_PLAYLIST = 0xA123 /** PlaySong.ByItself */ const val PLAY_SONG_BY_ITSELF = 0xA124 - const val PLAYER_COMMAND_INC_REPEAT_MODE = 0xA125 - const val PLAYER_COMMAND_TOGGLE_SHUFFLE = 0xA126 - const val PLAYER_COMMAND_EXIT = 0xA127 } diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 42ad2a134..ab5474c9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -71,11 +71,11 @@ class MainActivity : AppCompatActivity() { startService( Intent(this, AuxioService::class.java) - .putExtra(AuxioService.INTENT_KEY_NATIVE_START, true)) + .putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY)) if (!startIntentAction(intent)) { // No intent action to do, just restore the previously saved state. - playbackModel.playDeferred(DeferredPlayback.RestoreState) + playbackModel.playDeferred(DeferredPlayback.RestoreState(false)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index 69f2aab6d..ad5102477 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -109,11 +109,23 @@ constructor( } } - fun handleNonNativeStart() { + fun start(startedBy: Int) { // At minimum we want to ensure an active playback state. // TODO: Possibly also force to go foreground? logD("Handling non-native start.") - playbackManager.playDeferred(DeferredPlayback.RestoreState) + val action = + when (startedBy) { + IntegerTable.START_ID_ACTIVITY -> null + IntegerTable.START_ID_TASKER -> + DeferredPlayback.RestoreState( + play = true, fallback = DeferredPlayback.ShuffleAll) + // External services using Auxio better know what they are doing. + else -> DeferredPlayback.RestoreState(play = false) + } + if (action != null) { + logD("Initing service fragment using action $action") + playbackManager.playDeferred(action) + } } fun hasNotification(): Boolean = exoHolder.sessionOngoing diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 471a6498c..c2c51f0ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -276,7 +276,8 @@ data class QueueChange(val type: Type, val instructions: UpdateInstructions) { /** Possible long-running background tasks handled by the background playback task. */ sealed interface DeferredPlayback { /** Restore the previously saved playback state. */ - data class RestoreState(val play: Boolean, val fallback: DeferredPlayback? = null) : DeferredPlayback + data class RestoreState(val play: Boolean, val fallback: DeferredPlayback? = null) : + DeferredPlayback /** * Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut. diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt new file mode 100644 index 000000000..b146ec443 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 Auxio Project + * Start.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.tasker + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.content.ContextCompat +import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutputOrInput +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutputOrInput +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigNoInput +import com.joaomgcd.taskerpluginlibrary.input.TaskerInput +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess +import org.oxycblt.auxio.AuxioService +import org.oxycblt.auxio.IntegerTable + +class StartActionHelper(config: TaskerPluginConfig) : + TaskerPluginConfigHelperNoOutputOrInput(config) { + override val runnerClass: Class + get() = StartActionRunner::class.java + + override fun addToStringBlurb(input: TaskerInput, blurbBuilder: StringBuilder) { + blurbBuilder.append( + "Starts Auxio using the previously saved state. If no saved state is available, all songs will be shuffled. Playback will start immediately. Be careful controlling this service, if you close it and then try to use it again, you will probably crash the app.") + } +} + +class ActivityConfigStartAction : Activity(), TaskerPluginConfigNoInput { + override val context + get() = applicationContext + + private val taskerHelper by lazy { StartActionHelper(this) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + taskerHelper.finishForTasker() + } +} + +class StartActionRunner : TaskerPluginRunnerActionNoOutputOrInput() { + override fun run(context: Context, input: TaskerInput): TaskerPluginResult { + ContextCompat.startForegroundService( + context, + Intent(context, AuxioService::class.java) + .putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_TASKER)) + while (!AuxioService.isForeground) { + Thread.sleep(100) + } + return TaskerPluginResultSucess() + } +} From b8a652d6f2aab887c7a1c9cbb8871e95be3386d4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 21 Aug 2024 13:57:17 -0600 Subject: [PATCH 12/87] tasker: fix activity --- app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 564220423..8cce5eacb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -137,7 +137,7 @@ From 27e378ae2a241afa0bd66da19ef90a4f7845fc4a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 21 Aug 2024 13:57:40 -0600 Subject: [PATCH 13/87] tasker: give start action real name Instead of the template. --- app/src/main/AndroidManifest.xml | 2 +- app/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8cce5eacb..cbf7ea304 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -140,7 +140,7 @@ android:name=".tasker.ActivityConfigStartAction" android:exported="true" android:icon="@mipmap/ic_launcher" - android:label="My Tasker Action"> + android:label="@string/lbl_start_playback"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e4431dacd..a006fca33 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -153,6 +153,7 @@ Shuffle Shuffle all + Start playback OK Cancel From cc7f9ba5390466f1d937515636ce58f406de3be7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 21 Aug 2024 13:58:01 -0600 Subject: [PATCH 14/87] tasker: fix player main thread bugs on restore --- .../auxio/playback/service/ExoPlaybackStateHolder.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 804470a35..59ac16d95 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -164,17 +164,17 @@ class ExoPlaybackStateHolder( logD("Restoring playback state") restoreScope.launch { val state = persistenceRepository.readState() - if (state != null) { - // Apply the saved state on the main thread to prevent code expecting - // state updates on the main thread from crashing. - withContext(Dispatchers.Main) { + withContext(Dispatchers.Main) { + if (state != null) { + // Apply the saved state on the main thread to prevent code expecting + // state updates on the main thread from crashing. playbackManager.applySavedState(state, false) if (action.play) { playbackManager.playing(true) } + } else if (action.fallback != null) { + playbackManager.playDeferred(action.fallback) } - } else if (action.fallback != null) { - playbackManager.playDeferred(action.fallback) } } } From 2c976374f37195d5e927f4a5edfc71c417a858db Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 21 Aug 2024 13:58:19 -0600 Subject: [PATCH 15/87] tasker: use translated tasker action description --- app/src/main/java/org/oxycblt/auxio/tasker/Start.kt | 4 ++-- app/src/main/res/values/strings.xml | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt index b146ec443..1dd5c8997 100644 --- a/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt +++ b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt @@ -32,6 +32,7 @@ import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess import org.oxycblt.auxio.AuxioService import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R class StartActionHelper(config: TaskerPluginConfig) : TaskerPluginConfigHelperNoOutputOrInput(config) { @@ -39,8 +40,7 @@ class StartActionHelper(config: TaskerPluginConfig) : get() = StartActionRunner::class.java override fun addToStringBlurb(input: TaskerInput, blurbBuilder: StringBuilder) { - blurbBuilder.append( - "Starts Auxio using the previously saved state. If no saved state is available, all songs will be shuffled. Playback will start immediately. Be careful controlling this service, if you close it and then try to use it again, you will probably crash the app.") + blurbBuilder.append(context.getString(R.string.lng_tasker_start)) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a006fca33..886e5ce55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -213,6 +213,10 @@ Donate to the project to get your name added here! Search your library… + + Starts Auxio using the previously saved state. If no saved state is available, all songs will be shuffled. Playback will start immediately. + \n\nWARNING: Be careful controlling this service, if you close it and then try to use it again, you will probably crash the app. + From e1f75bb33770d61b9898977d588e21c002db9a41 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 22 Aug 2024 10:18:45 -0600 Subject: [PATCH 16/87] build: bump ndk to r26b --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 15206897f..38df4b67d 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 "26.1.10909125" namespace "org.oxycblt.auxio" defaultConfig { From 258dd9205cfe8f3715ef33d8ad1a3796139478fd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 22 Aug 2024 10:59:24 -0600 Subject: [PATCH 17/87] build: bump media --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 9fc2401b8..3c551372d 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 9fc2401b8fdc2b23905402462e775c6db4e1527f +Subproject commit 3c551372d4bca20e6a7519573a5d43bc54e26504 From 1a490eb7b4662a0135d12999576310f6ac88ac70 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 23 Aug 2024 13:01:15 -0600 Subject: [PATCH 18/87] build: bump ndk to r26d --- app/build.gradle | 2 +- media | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 38df4b67d..8fcf95745 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 "26.3.11579264" namespace "org.oxycblt.auxio" defaultConfig { diff --git a/media b/media index 3c551372d..6ecd4f42d 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 3c551372d4bca20e6a7519573a5d43bc54e26504 +Subproject commit 6ecd4f42dc574af7a315eeccf16b72e87dbf697a From 3cd09c3cec3f668d5c79297c339c21ff8993f45b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 23 Aug 2024 13:28:31 -0600 Subject: [PATCH 19/87] media: bump to fixed build --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 6ecd4f42d..34b33175c 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 6ecd4f42dc574af7a315eeccf16b72e87dbf697a +Subproject commit 34b33175c00183dc95cdcb8c735033b6785041e1 From a3012abe2347cc2b6609a8b4a835771c997af786 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 23 Aug 2024 13:41:00 -0600 Subject: [PATCH 20/87] info: update changelogs - Fix typo - Add tasker integration note --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d953459b7..840e6e0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,15 @@ ## 3.5.3 +#### What's New +- Basic Tasker integration for safely starting Auxio's service + #### What's Improved - Added support for informal singular-spaced tags like `album artist` in file metadata #### What's Fixed -- Fix "Foregroud not allowed" music loading crash from starting too early +- Fix "Foreground not allowed" music loading crash from starting too early - Fixed widget not loading on some devices due to the cover being too large ## 3.5.2 From d91343070aa5888982c56338b9a17d4e9187dcc9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 23 Aug 2024 13:43:49 -0600 Subject: [PATCH 21/87] build: bump to 3.5.3 Bump the version to 3.5.3 (49) --- README.md | 4 ++-- app/build.gradle | 4 ++-- .../main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt | 2 +- fastlane/metadata/android/en-US/changelogs/49.txt | 3 +++ 4 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/49.txt diff --git a/README.md b/README.md index 6a3e6a632..191ca51ec 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 8fcf95745..98ded55d6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.5.2" - versionCode 48 + versionName "3.5.3" + versionCode 49 minSdk 24 targetSdk 34 diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 2a7113066..5ea7c8ebf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 46, exportSchema = false) +@Database(entities = [CachedSong::class], version = 49, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } diff --git a/fastlane/metadata/android/en-US/changelogs/49.txt b/fastlane/metadata/android/en-US/changelogs/49.txt new file mode 100644 index 000000000..62d3f517b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/49.txt @@ -0,0 +1,3 @@ +Auxio 3.5.0 adds support for android auto alongside various playback and music quality of life improvements. +This release adds basic Tasker integration while fixing a few issues that affected certain devices. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.3 From c1e5adbc4406e8988d21179ddeb5c11a79576448 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 23 Aug 2024 13:55:49 -0600 Subject: [PATCH 22/87] media: unwind tightly bound action handling --- .../service/MediaSessionServiceFragment.kt | 12 ++ .../playback/service/PlaybackActionHandler.kt | 117 +----------------- .../service/SystemPlaybackReceiver.kt | 112 +++++++++++++++++ .../oxycblt/auxio/widgets/WidgetComponent.kt | 4 +- 4 files changed, 127 insertions(+), 118 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index ad5102477..a4be02ad2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.playback.service import android.app.Notification import android.content.Context import android.os.Bundle +import androidx.core.content.ContextCompat import androidx.media3.common.MediaItem import androidx.media3.session.CommandButton import androidx.media3.session.DefaultActionFactory @@ -49,10 +50,12 @@ import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.service.MediaItemBrowser +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent +import org.oxycblt.auxio.widgets.WidgetComponent class MediaSessionServiceFragment @Inject @@ -60,6 +63,8 @@ constructor( @ApplicationContext private val context: Context, private val playbackManager: PlaybackStateManager, private val actionHandler: PlaybackActionHandler, + private val playbackSettings: PlaybackSettings, + private val widgetComponent: WidgetComponent, private val mediaItemBrowser: MediaItemBrowser, exoHolderFactory: ExoPlaybackStateHolder.Factory ) : @@ -86,6 +91,7 @@ constructor( .also { it.setSmallIcon(R.drawable.ic_auxio_24) } private var foregroundListener: ForegroundListener? = null + lateinit var systemReceiver: SystemPlaybackReceiver lateinit var mediaSession: MediaLibrarySession private set @@ -99,6 +105,10 @@ constructor( playbackManager.addListener(this) exoHolder.attach() actionHandler.attach(this) + systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) + ContextCompat.registerReceiver( + context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) + widgetComponent.attach() mediaItemBrowser.attach(this) return mediaSession } @@ -142,6 +152,8 @@ constructor( fun release() { waitJob.cancel() mediaItemBrowser.release() + context.unregisterReceiver(systemReceiver) + widgetComponent.release() actionHandler.release() exoHolder.release() playbackManager.removeListener(this) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt index 6d0c1fbeb..3dd0acf68 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt @@ -18,13 +18,8 @@ package org.oxycblt.auxio.playback.service -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.media.AudioManager import android.os.Bundle -import androidx.core.content.ContextCompat import androidx.media3.common.Player import androidx.media3.session.CommandButton import androidx.media3.session.SessionCommand @@ -39,41 +34,31 @@ import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.widgets.WidgetComponent -import org.oxycblt.auxio.widgets.WidgetProvider class PlaybackActionHandler @Inject constructor( @ApplicationContext private val context: Context, private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings, - private val widgetComponent: WidgetComponent + private val playbackSettings: PlaybackSettings ) : PlaybackStateManager.Listener, PlaybackSettings.Listener { interface Callback { fun onCustomLayoutChanged(layout: List) } - private val systemReceiver = - SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) private var callback: Callback? = null fun attach(callback: Callback) { this.callback = callback playbackManager.addListener(this) playbackSettings.registerListener(this) - ContextCompat.registerReceiver( - context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) } fun release() { callback = null playbackManager.removeListener(this) playbackSettings.unregisterListener(this) - context.unregisterReceiver(systemReceiver) - widgetComponent.release() } fun withCommands(commands: SessionCommands) = @@ -178,103 +163,3 @@ object PlaybackActions { const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" } - -/** - * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an - * active [IntentFilter] to be registered. - */ -class SystemPlaybackReceiver( - private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings, - private val widgetComponent: WidgetComponent -) : BroadcastReceiver() { - private var initialHeadsetPlugEventHandled = false - - val intentFilter = - IntentFilter().apply { - addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) - addAction(AudioManager.ACTION_HEADSET_PLUG) - addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) - addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) - addAction(PlaybackActions.ACTION_SKIP_PREV) - addAction(PlaybackActions.ACTION_PLAY_PAUSE) - addAction(PlaybackActions.ACTION_SKIP_NEXT) - addAction(WidgetProvider.ACTION_WIDGET_UPDATE) - } - - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - // --- SYSTEM EVENTS --- - - // Android has three different ways of handling audio plug events for some reason: - // 1. ACTION_HEADSET_PLUG, which only works with wired headsets - // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires - // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less - // a non-starter since both require me to display a permission prompt - // 3. Some internal framework thing that also handles bluetooth headsets - // Just use ACTION_HEADSET_PLUG. - AudioManager.ACTION_HEADSET_PLUG -> { - logD("Received headset plug event") - when (intent.getIntExtra("state", -1)) { - 0 -> pauseFromHeadsetPlug() - 1 -> playFromHeadsetPlug() - } - - initialHeadsetPlugEventHandled = true - } - AudioManager.ACTION_AUDIO_BECOMING_NOISY -> { - logD("Received Headset noise event") - pauseFromHeadsetPlug() - } - - // --- AUXIO EVENTS --- - PlaybackActions.ACTION_PLAY_PAUSE -> { - logD("Received play event") - playbackManager.playing(!playbackManager.progression.isPlaying) - } - PlaybackActions.ACTION_INC_REPEAT_MODE -> { - logD("Received repeat mode event") - playbackManager.repeatMode(playbackManager.repeatMode.increment()) - } - PlaybackActions.ACTION_INVERT_SHUFFLE -> { - logD("Received shuffle event") - playbackManager.shuffled(!playbackManager.isShuffled) - } - PlaybackActions.ACTION_SKIP_PREV -> { - logD("Received skip previous event") - playbackManager.prev() - } - PlaybackActions.ACTION_SKIP_NEXT -> { - logD("Received skip next event") - playbackManager.next() - } - PlaybackActions.ACTION_EXIT -> { - logD("Received exit event") - playbackManager.endSession() - } - WidgetProvider.ACTION_WIDGET_UPDATE -> { - logD("Received widget update event") - widgetComponent.update() - } - } - } - - private fun playFromHeadsetPlug() { - // ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached, - // which would result in unexpected playback. Work around it by dropping the first - // call to this function, which should come from that Intent. - if (playbackSettings.headsetAutoplay && - playbackManager.currentSong != null && - initialHeadsetPlugEventHandled) { - logD("Device connected, resuming") - playbackManager.playing(true) - } - } - - private fun pauseFromHeadsetPlug() { - if (playbackManager.currentSong != null) { - logD("Device disconnected, pausing") - playbackManager.playing(false) - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt new file mode 100644 index 000000000..31c931fdb --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -0,0 +1,112 @@ +package org.oxycblt.auxio.playback.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.widgets.WidgetComponent +import org.oxycblt.auxio.widgets.WidgetProvider + +/** + * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an + * active [IntentFilter] to be registered. + */ +class SystemPlaybackReceiver( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val widgetComponent: WidgetComponent +) : BroadcastReceiver() { + private var initialHeadsetPlugEventHandled = false + + val intentFilter = + IntentFilter().apply { + addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + addAction(AudioManager.ACTION_HEADSET_PLUG) + addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) + addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) + addAction(PlaybackActions.ACTION_SKIP_PREV) + addAction(PlaybackActions.ACTION_PLAY_PAUSE) + addAction(PlaybackActions.ACTION_SKIP_NEXT) + addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + } + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + // --- SYSTEM EVENTS --- + + // Android has three different ways of handling audio plug events for some reason: + // 1. ACTION_HEADSET_PLUG, which only works with wired headsets + // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires + // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less + // a non-starter since both require me to display a permission prompt + // 3. Some internal framework thing that also handles bluetooth headsets + // Just use ACTION_HEADSET_PLUG. + AudioManager.ACTION_HEADSET_PLUG -> { + logD("Received headset plug event") + when (intent.getIntExtra("state", -1)) { + 0 -> pauseFromHeadsetPlug() + 1 -> playFromHeadsetPlug() + } + + initialHeadsetPlugEventHandled = true + } + AudioManager.ACTION_AUDIO_BECOMING_NOISY -> { + logD("Received Headset noise event") + pauseFromHeadsetPlug() + } + + // --- AUXIO EVENTS --- + PlaybackActions.ACTION_PLAY_PAUSE -> { + logD("Received play event") + playbackManager.playing(!playbackManager.progression.isPlaying) + } + PlaybackActions.ACTION_INC_REPEAT_MODE -> { + logD("Received repeat mode event") + playbackManager.repeatMode(playbackManager.repeatMode.increment()) + } + PlaybackActions.ACTION_INVERT_SHUFFLE -> { + logD("Received shuffle event") + playbackManager.shuffled(!playbackManager.isShuffled) + } + PlaybackActions.ACTION_SKIP_PREV -> { + logD("Received skip previous event") + playbackManager.prev() + } + PlaybackActions.ACTION_SKIP_NEXT -> { + logD("Received skip next event") + playbackManager.next() + } + PlaybackActions.ACTION_EXIT -> { + logD("Received exit event") + playbackManager.endSession() + } + WidgetProvider.ACTION_WIDGET_UPDATE -> { + logD("Received widget update event") + widgetComponent.update() + } + } + } + + private fun playFromHeadsetPlug() { + // ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached, + // which would result in unexpected playback. Work around it by dropping the first + // call to this function, which should come from that Intent. + if (playbackSettings.headsetAutoplay && + playbackManager.currentSong != null && + initialHeadsetPlugEventHandled) { + logD("Device connected, resuming") + playbackManager.playing(true) + } + } + + private fun pauseFromHeadsetPlug() { + if (playbackManager.currentSong != null) { + logD("Device disconnected, pausing") + playbackManager.playing(false) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 1e95eb6f4..bb8caf693 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -57,7 +57,7 @@ constructor( ) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { private val widgetProvider = WidgetProvider() - init { + fun attach() { playbackManager.addListener(this) uiSettings.registerListener(this) imageSettings.registerListener(this) @@ -90,7 +90,7 @@ constructor( } else if (uiSettings.roundMode) { // < Android 12, but the user still enabled round mode. logD("Using default corner radius") - context.getDimenPixels(R.dimen.size_corners_medium) + context.getDimenPixels(R.dimen.spacing_medium) } else { // User did not enable round mode. logD("Using no corner radius") From e43f55bc78ee699c3cab7f36f7c6497b14a8e345 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 26 Aug 2024 10:45:58 -0600 Subject: [PATCH 23/87] service: drop media3 session entirely --- app/build.gradle | 1 - .../java/org/oxycblt/auxio/AuxioService.kt | 123 ++- .../java/org/oxycblt/auxio/MainActivity.kt | 2 +- .../music/service/IndexerNotifications.kt | 56 +- .../auxio/music/service/MediaItemBrowser.kt | 710 +++++++++--------- ...iceFragment.kt => MusicServiceFragment.kt} | 5 +- .../playback/service/MediaSessionHolder.kt | 609 +++++++++++++++ .../service/MediaSessionServiceFragment.kt | 297 -------- .../playback/service/PlaybackActionHandler.kt | 135 ---- .../service/PlaybackServiceFragment.kt | 212 ++++++ .../service/SystemPlaybackReceiver.kt | 20 +- 11 files changed, 1308 insertions(+), 862 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/service/{IndexerServiceFragment.kt => MusicServiceFragment.kt} (97%) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt diff --git a/app/build.gradle b/app/build.gradle index 98ded55d6..2312bcb38 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -126,7 +126,6 @@ dependencies { // --- THIRD PARTY --- // Exoplayer (Vendored) - implementation project(":media-lib-session") implementation project(":media-lib-exoplayer") implementation project(":media-lib-decoder-ffmpeg") coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index f0352d523..9c62fdcd2 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -19,34 +19,35 @@ package org.oxycblt.auxio import android.annotation.SuppressLint +import android.content.Context import android.content.Intent +import android.os.Bundle import android.os.IBinder +import android.support.v4.media.MediaBrowserCompat +import androidx.annotation.StringRes +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat -import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaSession +import androidx.media.MediaBrowserServiceCompat import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import org.oxycblt.auxio.music.service.IndexerServiceFragment -import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment +import org.oxycblt.auxio.music.service.MusicServiceFragment +import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @AndroidEntryPoint -class AuxioService : MediaLibraryService(), ForegroundListener { - @Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment +class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { + @Inject lateinit var mediaSessionFragment: PlaybackServiceFragment - @Inject lateinit var indexingFragment: IndexerServiceFragment + @Inject lateinit var indexingFragment: MusicServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { super.onCreate() - mediaSessionFragment.attach(this, this) + setSessionToken(mediaSessionFragment.attach(this)) indexingFragment.attach(this) } - override fun onBind(intent: Intent?): IBinder? { - onHandleForeground(intent) - return super.onBind(intent) - } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // TODO: Start command occurring from a foreign service basically implies a detached // service, we might need more handling here. @@ -54,6 +55,11 @@ class AuxioService : MediaLibraryService(), ForegroundListener { return super.onStartCommand(intent, flags, startId) } + override fun onBind(intent: Intent): IBinder? { + onHandleForeground(intent) + return super.onBind(intent) + } + private fun onHandleForeground(intent: Intent?) { val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1 indexingFragment.start() @@ -71,20 +77,54 @@ class AuxioService : MediaLibraryService(), ForegroundListener { mediaSessionFragment.release() } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = - mediaSessionFragment.mediaSession + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot? { + TODO("Not yet implemented") + } - override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - updateForeground(ForegroundListener.Change.MEDIA_SESSION) + override fun onLoadChildren( + parentId: String, + result: Result> + ) = throw NotImplementedError() + + override fun onLoadChildren( + parentId: String, + result: Result>, + options: Bundle + ) { + super.onLoadChildren(parentId, result, options) + } + + override fun onLoadItem(itemId: String, result: Result) { + super.onLoadItem(itemId, result) + } + + override fun onSearch( + query: String, + extras: Bundle?, + result: Result> + ) { + super.onSearch(query, extras, result) + } + + @SuppressLint("RestrictedApi") + override fun onSubscribe(id: String?, option: Bundle?) { + super.onSubscribe(id, option) + } + + @SuppressLint("RestrictedApi") + override fun onUnsubscribe(id: String?) { + super.onUnsubscribe(id) } override fun updateForeground(change: ForegroundListener.Change) { - if (mediaSessionFragment.hasNotification()) { + val mediaNotification = mediaSessionFragment.notification + if (mediaNotification != null) { if (change == ForegroundListener.Change.MEDIA_SESSION) { - mediaSessionFragment.createNotification { - startForeground(it.notificationId, it.notification) - isForeground = true - } + startForeground(mediaNotification.code, mediaNotification.build()) } // Nothing changed, but don't show anything music related since we can always // index during playback. @@ -118,3 +158,42 @@ interface ForegroundListener { INDEXER } } + +/** + * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that + * signal a Service's ongoing foreground state. + * + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) : + NotificationCompat.Builder(context, info.id) { + private val notificationManager = NotificationManagerCompat.from(context) + + init { + // Set up the notification channel. Foreground notifications are non-substantial, and + // thus make no sense to have lights, vibration, or lead to a notification badge. + val channel = + NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(context.getString(info.nameRes)) + .setLightsEnabled(false) + .setVibrationEnabled(false) + .setShowBadge(false) + .build() + notificationManager.createNotificationChannel(channel) + } + + /** + * The code used to identify this notification. + * + * @see NotificationManagerCompat.notify + */ + abstract val code: Int + + /** + * Reduced representation of a [NotificationChannelCompat]. + * + * @param id The ID of the channel. + * @param nameRes A string resource ID corresponding to the human-readable name of this channel. + */ + data class ChannelInfo(val id: String, @StringRes val nameRes: Int) +} diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index ab5474c9c..530f3f14f 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -79,7 +79,7 @@ class MainActivity : AppCompatActivity() { } } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) startIntentAction(intent) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt index d857ab32b..0e895196d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt @@ -20,11 +20,9 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.os.SystemClock -import androidx.annotation.StringRes -import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.IndexingProgress @@ -32,52 +30,13 @@ import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent /** - * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that - * signal a Service's ongoing foreground state. - * - * @author Alexander Capehart (OxygenCobalt) - */ -abstract class IndexerNotification(context: Context, info: ChannelInfo) : - NotificationCompat.Builder(context, info.id) { - private val notificationManager = NotificationManagerCompat.from(context) - - init { - // Set up the notification channel. Foreground notifications are non-substantial, and - // thus make no sense to have lights, vibration, or lead to a notification badge. - val channel = - NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(context.getString(info.nameRes)) - .setLightsEnabled(false) - .setVibrationEnabled(false) - .setShowBadge(false) - .build() - notificationManager.createNotificationChannel(channel) - } - - /** - * The code used to identify this notification. - * - * @see NotificationManagerCompat.notify - */ - abstract val code: Int - - /** - * Reduced representation of a [NotificationChannelCompat]. - * - * @param id The ID of the channel. - * @param nameRes A string resource ID corresponding to the human-readable name of this channel. - */ - data class ChannelInfo(val id: String, @StringRes val nameRes: Int) -} - -/** - * A dynamic [IndexerNotification] that shows the current music loading state. + * A dynamic [ForegroundServiceNotification] that shows the current music loading state. * * @param context [Context] required to create the notification. * @author Alexander Capehart (OxygenCobalt) */ class IndexingNotification(private val context: Context) : - IndexerNotification(context, indexerChannel) { + ForegroundServiceNotification(context, indexerChannel) { private var lastUpdateTime = -1L init { @@ -133,12 +92,13 @@ class IndexingNotification(private val context: Context) : } /** - * A static [IndexerNotification] that signals to the user that the app is currently monitoring the - * music library for changes. + * A static [ForegroundServiceNotification] that signals to the user that the app is currently + * monitoring the music library for changes. * * @author Alexander Capehart (OxygenCobalt) */ -class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) { +class ObservingNotification(context: Context) : + ForegroundServiceNotification(context, indexerChannel) { init { setSmallIcon(R.drawable.ic_indexer_24) setCategory(NotificationCompat.CATEGORY_SERVICE) @@ -156,5 +116,5 @@ class ObservingNotification(context: Context) : IndexerNotification(context, ind /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ private val indexerChannel = - IndexerNotification.ChannelInfo( + ForegroundServiceNotification.ChannelInfo( id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 93841a63f..5d578119a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -17,358 +17,358 @@ */ package org.oxycblt.auxio.music.service - -import android.content.Context -import android.os.Bundle -import androidx.annotation.StringRes -import androidx.media.utils.MediaConstants -import androidx.media3.common.MediaItem -import androidx.media3.session.MediaSession.ControllerInfo -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import kotlin.math.min -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.ListSettings -import org.oxycblt.auxio.list.sort.Sort -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.user.UserLibrary -import org.oxycblt.auxio.search.SearchEngine - -class MediaItemBrowser -@Inject -constructor( - @ApplicationContext private val context: Context, - private val musicRepository: MusicRepository, - private val listSettings: ListSettings, - private val searchEngine: SearchEngine -) : MusicRepository.UpdateListener { - private val browserJob = Job() - private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) - private val searchSubscribers = mutableMapOf() - private val searchResults = mutableMapOf>() - private var invalidator: Invalidator? = null - - interface Invalidator { - fun invalidate(ids: Map) - - fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) - } - - fun attach(invalidator: Invalidator) { - this.invalidator = invalidator - musicRepository.addUpdateListener(this) - } - - fun release() { - browserJob.cancel() - invalidator = null - musicRepository.removeUpdateListener(this) - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary - var invalidateSearch = false - val invalidate = mutableMapOf() - if (changes.deviceLibrary && deviceLibrary != null) { - MediaSessionUID.Category.DEVICE_MUSIC.forEach { - invalidate[it.toString()] = getCategorySize(it, musicRepository) - } - - deviceLibrary.albums.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size - } - - deviceLibrary.artists.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size - } - - deviceLibrary.genres.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size + it.artists.size - } - - invalidateSearch = true - } - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - MediaSessionUID.Category.USER_MUSIC.forEach { - invalidate[it.toString()] = getCategorySize(it, musicRepository) - } - userLibrary.playlists.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size - } - invalidateSearch = true - } - - if (invalidate.isNotEmpty()) { - invalidator?.invalidate(invalidate) - } - - if (invalidateSearch) { - for (entry in searchResults.entries) { - searchResults[entry.key]?.cancel() - } - searchResults.clear() - - for (entry in searchSubscribers.entries) { - if (searchResults[entry.value] != null) { - continue - } - searchResults[entry.value] = searchTo(entry.value) - } - } - } - - val root: MediaItem - get() = MediaSessionUID.Category.ROOT.toMediaItem(context) - - fun getItem(mediaId: String): MediaItem? { - val music = - when (val uid = MediaSessionUID.fromString(mediaId)) { - is MediaSessionUID.Category -> return uid.toMediaItem(context) - is MediaSessionUID.Single -> - musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - is MediaSessionUID.Joined -> - musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } - null -> null - } - ?: return null - - return when (music) { - is Album -> music.toMediaItem(context) - is Artist -> music.toMediaItem(context) - is Genre -> music.toMediaItem(context) - is Playlist -> music.toMediaItem(context) - is Song -> music.toMediaItem(context, null) - } - } - - fun getChildren(parentId: String, page: Int, pageSize: Int): List? { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return listOf() - } - - val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null - return items.paginate(page, pageSize) - } - - private fun getMediaItemList( - id: String, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary - ): List? { - return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { - is MediaSessionUID.Category -> { - when (mediaSessionUID) { - MediaSessionUID.Category.ROOT -> - MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } - MediaSessionUID.Category.SONGS -> - listSettings.songSort.songs(deviceLibrary.songs).map { - it.toMediaItem(context, null) - } - MediaSessionUID.Category.ALBUMS -> - listSettings.albumSort.albums(deviceLibrary.albums).map { - it.toMediaItem(context) - } - MediaSessionUID.Category.ARTISTS -> - listSettings.artistSort.artists(deviceLibrary.artists).map { - it.toMediaItem(context) - } - MediaSessionUID.Category.GENRES -> - listSettings.genreSort.genres(deviceLibrary.genres).map { - it.toMediaItem(context) - } - MediaSessionUID.Category.PLAYLISTS -> - userLibrary.playlists.map { it.toMediaItem(context) } - } - } - is MediaSessionUID.Single -> { - getChildMediaItems(mediaSessionUID.uid) - } - is MediaSessionUID.Joined -> { - getChildMediaItems(mediaSessionUID.childUid) - } - null -> { - return null - } - } - } - - private fun getChildMediaItems(uid: Music.UID): List? { - return when (val item = musicRepository.find(uid)) { - is Album -> { - val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } - } - is Artist -> { - val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) - val songs = listSettings.artistSongSort.songs(item.songs) - albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + - songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } - } - is Genre -> { - val artists = GENRE_ARTISTS_SORT.artists(item.artists) - val songs = listSettings.genreSongSort.songs(item.songs) - artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + - songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } - } - is Playlist -> { - item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } - } - is Song, - null -> return null - } - } - - private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { - val oldExtras = mediaMetadata.extras ?: Bundle() - val newExtras = - Bundle(oldExtras).apply { - putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - context.getString(res)) - } - return buildUpon() - .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) - .build() - } - - private fun getCategorySize( - category: MediaSessionUID.Category, - musicRepository: MusicRepository - ): Int { - val deviceLibrary = musicRepository.deviceLibrary ?: return 0 - val userLibrary = musicRepository.userLibrary ?: return 0 - return when (category) { - MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size - MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size - MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size - MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size - MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size - MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size - } - } - - suspend fun prepareSearch(query: String, controller: ControllerInfo) { - searchSubscribers[controller] = query - val existing = searchResults[query] - if (existing == null) { - val new = searchTo(query) - searchResults[query] = new - new.await() - } else { - val items = existing.await() - invalidator?.invalidate(controller, query, items.count()) - } - } - - suspend fun getSearchResult( - query: String, - page: Int, - pageSize: Int, - ): List? { - val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } - return deferred.await().concat().paginate(page, pageSize) - } - - private fun SearchEngine.Items.concat(): MutableList { - val music = mutableListOf() - if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null) }) - } - if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context) }) - } - if (artists != null) { - music.addAll(artists.map { it.toMediaItem(context) }) - } - if (genres != null) { - music.addAll(genres.map { it.toMediaItem(context) }) - } - if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(context) }) - } - return music - } - - private fun SearchEngine.Items.count(): Int { - var count = 0 - if (songs != null) { - count += songs.size - } - if (albums != null) { - count += albums.size - } - if (artists != null) { - count += artists.size - } - if (genres != null) { - count += genres.size - } - if (playlists != null) { - count += playlists.size - } - return count - } - - private fun searchTo(query: String) = - searchScope.async { - if (query.isEmpty()) { - return@async SearchEngine.Items() - } - val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() - val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() - val items = - SearchEngine.Items( - deviceLibrary.songs, - deviceLibrary.albums, - deviceLibrary.artists, - deviceLibrary.genres, - userLibrary.playlists) - val results = searchEngine.search(items, query) - for (entry in searchSubscribers.entries) { - if (entry.value == query) { - invalidator?.invalidate(entry.key, query, results.count()) - } - } - results - } - - private fun List.paginate(page: Int, pageSize: Int): List? { - if (page == Int.MAX_VALUE) { - // I think if someone requests this page it more or less implies that I should - // return all of the pages. - return this - } - val start = page * pageSize - val end = min((page + 1) * pageSize, size) // Tolerate partial page queries - if (pageSize == 0 || start !in indices) { - // These pages are probably invalid. Hopefully this won't backfire. - return null - } - return subList(start, end).toMutableList() - } - - private companion object { - // TODO: Rely on detail item gen logic? - val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) - val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - } -} +// +// import android.content.Context +// import android.os.Bundle +// import androidx.annotation.StringRes +// import androidx.media.utils.MediaConstants +// import androidx.media3.common.MediaItem +// import androidx.media3.session.MediaSession.ControllerInfo +// import dagger.hilt.android.qualifiers.ApplicationContext +// import javax.inject.Inject +// import kotlin.math.min +// import kotlinx.coroutines.CoroutineScope +// import kotlinx.coroutines.Deferred +// import kotlinx.coroutines.Dispatchers +// import kotlinx.coroutines.Job +// import kotlinx.coroutines.async +// import org.oxycblt.auxio.R +// import org.oxycblt.auxio.list.ListSettings +// import org.oxycblt.auxio.list.sort.Sort +// import org.oxycblt.auxio.music.Album +// import org.oxycblt.auxio.music.Artist +// import org.oxycblt.auxio.music.Genre +// import org.oxycblt.auxio.music.Music +// import org.oxycblt.auxio.music.MusicRepository +// import org.oxycblt.auxio.music.Playlist +// import org.oxycblt.auxio.music.Song +// import org.oxycblt.auxio.music.device.DeviceLibrary +// import org.oxycblt.auxio.music.user.UserLibrary +// import org.oxycblt.auxio.search.SearchEngine +// +// class MediaItemBrowser +// @Inject +// constructor( +// @ApplicationContext private val context: Context, +// private val musicRepository: MusicRepository, +// private val listSettings: ListSettings, +// private val searchEngine: SearchEngine +// ) : MusicRepository.UpdateListener { +// private val browserJob = Job() +// private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) +// private val searchSubscribers = mutableMapOf() +// private val searchResults = mutableMapOf>() +// private var invalidator: Invalidator? = null +// +// interface Invalidator { +// fun invalidate(ids: Map) +// +// fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) +// } +// +// fun attach(invalidator: Invalidator) { +// this.invalidator = invalidator +// musicRepository.addUpdateListener(this) +// } +// +// fun release() { +// browserJob.cancel() +// invalidator = null +// musicRepository.removeUpdateListener(this) +// } +// +// override fun onMusicChanges(changes: MusicRepository.Changes) { +// val deviceLibrary = musicRepository.deviceLibrary +// var invalidateSearch = false +// val invalidate = mutableMapOf() +// if (changes.deviceLibrary && deviceLibrary != null) { +// MediaSessionUID.Category.DEVICE_MUSIC.forEach { +// invalidate[it.toString()] = getCategorySize(it, musicRepository) +// } +// +// deviceLibrary.albums.forEach { +// val id = MediaSessionUID.Single(it.uid).toString() +// invalidate[id] = it.songs.size +// } +// +// deviceLibrary.artists.forEach { +// val id = MediaSessionUID.Single(it.uid).toString() +// invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size +// } +// +// deviceLibrary.genres.forEach { +// val id = MediaSessionUID.Single(it.uid).toString() +// invalidate[id] = it.songs.size + it.artists.size +// } +// +// invalidateSearch = true +// } +// val userLibrary = musicRepository.userLibrary +// if (changes.userLibrary && userLibrary != null) { +// MediaSessionUID.Category.USER_MUSIC.forEach { +// invalidate[it.toString()] = getCategorySize(it, musicRepository) +// } +// userLibrary.playlists.forEach { +// val id = MediaSessionUID.Single(it.uid).toString() +// invalidate[id] = it.songs.size +// } +// invalidateSearch = true +// } +// +// if (invalidate.isNotEmpty()) { +// invalidator?.invalidate(invalidate) +// } +// +// if (invalidateSearch) { +// for (entry in searchResults.entries) { +// searchResults[entry.key]?.cancel() +// } +// searchResults.clear() +// +// for (entry in searchSubscribers.entries) { +// if (searchResults[entry.value] != null) { +// continue +// } +// searchResults[entry.value] = searchTo(entry.value) +// } +// } +// } +// +// val root: MediaItem +// get() = MediaSessionUID.Category.ROOT.toMediaItem(context) +// +// fun getItem(mediaId: String): MediaItem? { +// val music = +// when (val uid = MediaSessionUID.fromString(mediaId)) { +// is MediaSessionUID.Category -> return uid.toMediaItem(context) +// is MediaSessionUID.Single -> +// musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } +// is MediaSessionUID.Joined -> +// musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } +// null -> null +// } +// ?: return null +// +// return when (music) { +// is Album -> music.toMediaItem(context) +// is Artist -> music.toMediaItem(context) +// is Genre -> music.toMediaItem(context) +// is Playlist -> music.toMediaItem(context) +// is Song -> music.toMediaItem(context, null) +// } +// } +// +// fun getChildren(parentId: String, page: Int, pageSize: Int): List? { +// val deviceLibrary = musicRepository.deviceLibrary +// val userLibrary = musicRepository.userLibrary +// if (deviceLibrary == null || userLibrary == null) { +// return listOf() +// } +// +// val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null +// return items.paginate(page, pageSize) +// } +// +// private fun getMediaItemList( +// id: String, +// deviceLibrary: DeviceLibrary, +// userLibrary: UserLibrary +// ): List? { +// return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { +// is MediaSessionUID.Category -> { +// when (mediaSessionUID) { +// MediaSessionUID.Category.ROOT -> +// MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } +// MediaSessionUID.Category.SONGS -> +// listSettings.songSort.songs(deviceLibrary.songs).map { +// it.toMediaItem(context, null) +// } +// MediaSessionUID.Category.ALBUMS -> +// listSettings.albumSort.albums(deviceLibrary.albums).map { +// it.toMediaItem(context) +// } +// MediaSessionUID.Category.ARTISTS -> +// listSettings.artistSort.artists(deviceLibrary.artists).map { +// it.toMediaItem(context) +// } +// MediaSessionUID.Category.GENRES -> +// listSettings.genreSort.genres(deviceLibrary.genres).map { +// it.toMediaItem(context) +// } +// MediaSessionUID.Category.PLAYLISTS -> +// userLibrary.playlists.map { it.toMediaItem(context) } +// } +// } +// is MediaSessionUID.Single -> { +// getChildMediaItems(mediaSessionUID.uid) +// } +// is MediaSessionUID.Joined -> { +// getChildMediaItems(mediaSessionUID.childUid) +// } +// null -> { +// return null +// } +// } +// } +// +// private fun getChildMediaItems(uid: Music.UID): List? { +// return when (val item = musicRepository.find(uid)) { +// is Album -> { +// val songs = listSettings.albumSongSort.songs(item.songs) +// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } +// } +// is Artist -> { +// val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) +// val songs = listSettings.artistSongSort.songs(item.songs) +// albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + +// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } +// } +// is Genre -> { +// val artists = GENRE_ARTISTS_SORT.artists(item.artists) +// val songs = listSettings.genreSongSort.songs(item.songs) +// artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + +// songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } +// } +// is Playlist -> { +// item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } +// } +// is Song, +// null -> return null +// } +// } +// +// private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { +// val oldExtras = mediaMetadata.extras ?: Bundle() +// val newExtras = +// Bundle(oldExtras).apply { +// putString( +// MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, +// context.getString(res)) +// } +// return buildUpon() +// .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) +// .build() +// } +// +// private fun getCategorySize( +// category: MediaSessionUID.Category, +// musicRepository: MusicRepository +// ): Int { +// val deviceLibrary = musicRepository.deviceLibrary ?: return 0 +// val userLibrary = musicRepository.userLibrary ?: return 0 +// return when (category) { +// MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size +// MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size +// MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size +// MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size +// MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size +// MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size +// } +// } +// +// suspend fun prepareSearch(query: String, controller: ControllerInfo) { +// searchSubscribers[controller] = query +// val existing = searchResults[query] +// if (existing == null) { +// val new = searchTo(query) +// searchResults[query] = new +// new.await() +// } else { +// val items = existing.await() +// invalidator?.invalidate(controller, query, items.count()) +// } +// } +// +// suspend fun getSearchResult( +// query: String, +// page: Int, +// pageSize: Int, +// ): List? { +// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } +// return deferred.await().concat().paginate(page, pageSize) +// } +// +// private fun SearchEngine.Items.concat(): MutableList { +// val music = mutableListOf() +// if (songs != null) { +// music.addAll(songs.map { it.toMediaItem(context, null) }) +// } +// if (albums != null) { +// music.addAll(albums.map { it.toMediaItem(context) }) +// } +// if (artists != null) { +// music.addAll(artists.map { it.toMediaItem(context) }) +// } +// if (genres != null) { +// music.addAll(genres.map { it.toMediaItem(context) }) +// } +// if (playlists != null) { +// music.addAll(playlists.map { it.toMediaItem(context) }) +// } +// return music +// } +// +// private fun SearchEngine.Items.count(): Int { +// var count = 0 +// if (songs != null) { +// count += songs.size +// } +// if (albums != null) { +// count += albums.size +// } +// if (artists != null) { +// count += artists.size +// } +// if (genres != null) { +// count += genres.size +// } +// if (playlists != null) { +// count += playlists.size +// } +// return count +// } +// +// private fun searchTo(query: String) = +// searchScope.async { +// if (query.isEmpty()) { +// return@async SearchEngine.Items() +// } +// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() +// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() +// val items = +// SearchEngine.Items( +// deviceLibrary.songs, +// deviceLibrary.albums, +// deviceLibrary.artists, +// deviceLibrary.genres, +// userLibrary.playlists) +// val results = searchEngine.search(items, query) +// for (entry in searchSubscribers.entries) { +// if (entry.value == query) { +// invalidator?.invalidate(entry.key, query, results.count()) +// } +// } +// results +// } +// +// private fun List.paginate(page: Int, pageSize: Int): List? { +// if (page == Int.MAX_VALUE) { +// // I think if someone requests this page it more or less implies that I should +// // return all of the pages. +// return this +// } +// val start = page * pageSize +// val end = min((page + 1) * pageSize, size) // Tolerate partial page queries +// if (pageSize == 0 || start !in indices) { +// // These pages are probably invalid. Hopefully this won't backfire. +// return null +// } +// return subList(start, end).toMutableList() +// } +// +// private companion object { +// // TODO: Rely on detail item gen logic? +// val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) +// val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) +// } +// } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 571e96ca7..4616daf13 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings @@ -35,7 +36,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD -class IndexerServiceFragment +class MusicServiceFragment @Inject constructor( @ApplicationContext override val workerContext: Context, @@ -85,7 +86,7 @@ constructor( } } - fun createNotification(post: (IndexerNotification?) -> Unit) { + fun createNotification(post: (ForegroundServiceNotification?) -> Unit) { val state = musicRepository.indexingState if (state is IndexingState.Indexing) { // There are a few reasons why we stay in the foreground with automatic rescanning: diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt new file mode 100644 index 000000000..4c8c367fc --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -0,0 +1,609 @@ +/* + * Copyright (c) 2021 Auxio Project + * MediaSessionHolder.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.system + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.annotation.DrawableRes +import androidx.core.app.NotificationCompat +import androidx.media.app.NotificationCompat.MediaStyle +import javax.inject.Inject +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R +import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.image.ImageSettings +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.playback.ActionMode +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.service.PlaybackActions +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Progression +import org.oxycblt.auxio.playback.state.QueueChange +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.newBroadcastPendingIntent +import org.oxycblt.auxio.util.newMainPendingIntent + +/** + * A component that mirrors the current playback state into the [MediaSessionCompat] and + * [NotificationComponent]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class MediaSessionHolder +private constructor( + private val context: Context, + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val bitmapProvider: BitmapProvider, + private val imageSettings: ImageSettings +) : + MediaSessionCompat.Callback(), + PlaybackStateManager.Listener, + ImageSettings.Listener, + PlaybackSettings.Listener { + + class Factory + @Inject + constructor( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val bitmapProvider: BitmapProvider, + private val imageSettings: ImageSettings + ) { + fun create(context: Context) = + MediaSessionHolder( + context, playbackManager, playbackSettings, bitmapProvider, imageSettings) + } + + private val mediaSession = + MediaSessionCompat(context, context.packageName).apply { + isActive = true + setQueueTitle(context.getString(R.string.lbl_queue)) + } + val token: MediaSessionCompat.Token + get() = mediaSession.sessionToken + + private val _notification = PlaybackNotification(context, mediaSession.sessionToken) + val notification: ForegroundServiceNotification + get() = _notification + + private var foregroundListener: ForegroundListener? = null + + fun attach(foregroundListener: ForegroundListener) { + this.foregroundListener = foregroundListener + playbackManager.addListener(this) + playbackSettings.registerListener(this) + imageSettings.registerListener(this) + mediaSession.setCallback(this) + } + + /** + * Release this instance, closing the [MediaSessionCompat] and preventing any further updates to + * the [NotificationComponent]. + */ + fun release() { + foregroundListener = null + bitmapProvider.release() + playbackSettings.unregisterListener(this) + imageSettings.unregisterListener(this) + playbackManager.removeListener(this) + mediaSession.apply { + isActive = false + release() + } + } + + // --- PLAYBACKSTATEMANAGER OVERRIDES --- + + override fun onIndexMoved(index: Int) { + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + invalidateSessionState() + } + + override fun onQueueChanged(queue: List, index: Int, change: QueueChange) { + updateQueue(queue) + when (change.type) { + // Nothing special to do with mapping changes. + QueueChange.Type.MAPPING -> {} + // Index changed, ensure playback state's index changes. + QueueChange.Type.INDEX -> invalidateSessionState() + // Song changed, ensure metadata changes. + QueueChange.Type.SONG -> + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + } + } + + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + updateQueue(queue) + invalidateSessionState() + mediaSession.setShuffleMode( + if (isShuffled) { + PlaybackStateCompat.SHUFFLE_MODE_ALL + } else { + PlaybackStateCompat.SHUFFLE_MODE_NONE + }) + invalidateSecondaryAction() + } + + override fun onNewPlayback( + parent: MusicParent?, + queue: List, + index: Int, + isShuffled: Boolean + ) { + updateMediaMetadata(playbackManager.currentSong, parent) + updateQueue(queue) + invalidateSessionState() + } + + override fun onProgressionChanged(progression: Progression) { + invalidateSessionState() + _notification.updatePlaying(playbackManager.progression.isPlaying) + if (!bitmapProvider.isBusy) { + foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + } + + override fun onRepeatModeChanged(repeatMode: RepeatMode) { + mediaSession.setRepeatMode( + when (repeatMode) { + RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE + RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE + RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL + }) + + invalidateSecondaryAction() + } + + // --- SETTINGS OVERRIDES --- + + override fun onImageSettingsChanged() { + // Need to reload the metadata cover. + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + } + + override fun onNotificationActionChanged() { + // Need to re-load the action shown in the notification. + invalidateSecondaryAction() + } + + // --- MEDIASESSION OVERRIDES --- + + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + super.onPlayFromMediaId(mediaId, extras) + // STUB: Unimplemented, no media browser + } + + override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { + super.onPlayFromUri(uri, extras) + // STUB: Unimplemented, no media browser + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + super.onPlayFromSearch(query, extras) + // STUB: Unimplemented, no media browser + } + + override fun onAddQueueItem(description: MediaDescriptionCompat?) { + super.onAddQueueItem(description) + // STUB: Unimplemented + } + + override fun onRemoveQueueItem(description: MediaDescriptionCompat?) { + super.onRemoveQueueItem(description) + // STUB: Unimplemented + } + + override fun onPlay() { + playbackManager.playing(true) + } + + override fun onPause() { + playbackManager.playing(false) + } + + override fun onSkipToNext() { + playbackManager.next() + } + + override fun onSkipToPrevious() { + playbackManager.prev() + } + + override fun onSeekTo(position: Long) { + playbackManager.seekTo(position) + } + + override fun onFastForward() { + playbackManager.next() + } + + override fun onRewind() { + playbackManager.seekTo(0) + playbackManager.playing(true) + } + + override fun onSetRepeatMode(repeatMode: Int) { + playbackManager.repeatMode( + when (repeatMode) { + PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL + PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL + PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK + else -> RepeatMode.NONE + }) + } + + override fun onSetShuffleMode(shuffleMode: Int) { + playbackManager.shuffled( + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) + } + + override fun onSkipToQueueItem(id: Long) { + playbackManager.goto(id.toInt()) + } + + override fun onCustomAction(action: String, extras: Bundle?) { + super.onCustomAction(action, extras) + // Service already handles intents from the old notification actions, easier to + // plug into that system. + context.sendBroadcast(Intent(action)) + } + + override fun onStop() { + // Get the service to shut down with the ACTION_EXIT intent + context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) + } + + // --- INTERNAL --- + + /** + * Upload a new [MediaMetadataCompat] based on the current playback state to the + * [MediaSessionCompat] and [NotificationComponent]. + * + * @param song The current [Song] to create the [MediaMetadataCompat] from, or null if no [Song] + * is currently playing. + * @param parent The current [MusicParent] to create the [MediaMetadataCompat] from, or null if + * playback is currently occuring from all songs. + */ + private fun updateMediaMetadata(song: Song?, parent: MusicParent?) { + logD("Updating media metadata to $song with $parent") + if (song == null) { + // Nothing playing, reset the MediaSession and close the notification. + logD("Nothing playing, resetting media session") + mediaSession.setMetadata(emptyMetadata) + return + } + + // Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used + // several times. + val title = song.name.resolve(context) + val artist = song.artists.resolveNames(context) + val builder = + MediaMetadataCompat.Builder() + .putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name.resolve(context)) + // Note: We would leave the artist field null if it didn't exist and let downstream + // consumers handle it, but that would break the notification display. + .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + .putText( + MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, + song.album.artists.resolveNames(context)) + .putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist) + .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) + .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist) + .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genres.resolveNames(context)) + .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) + .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) + .putText( + MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, + parent?.run { name.resolve(context) } + ?: context.getString(R.string.lbl_all_songs)) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs) + // These fields are nullable and so we must check first before adding them to the fields. + song.track?.let { + logD("Adding track information") + builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong()) + } + song.disc?.let { + logD("Adding disc information") + builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.number.toLong()) + } + song.date?.let { + logD("Adding date information") + builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) + } + + // We are normally supposed to use URIs for album art, but that removes some of the + // nice things we can do like square cropping or high quality covers. Instead, + // we load a full-size bitmap into the media session and take the performance hit. + bitmapProvider.load( + song, + object : BitmapProvider.Target { + override fun onCompleted(bitmap: Bitmap?) { + logD("Bitmap loaded, applying media session and posting notification") + if (bitmap != null) { + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) + } + val metadata = builder.build() + mediaSession.setMetadata(metadata) + _notification.updateMetadata(metadata) + foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + }) + } + + /** + * Upload a new queue to the [MediaSessionCompat]. + * + * @param queue The current queue to upload. + */ + private fun updateQueue(queue: List) { + val queueItems = + queue.mapIndexed { i, song -> + val description = + MediaDescriptionCompat.Builder() + // Media ID should not be the item index but rather the UID, + // as it's used to request a song to be played from the queue. + .setMediaId(song.uid.toString()) + .setTitle(song.name.resolve(context)) + .setSubtitle(song.artists.resolveNames(context)) + // Since we usually have to load many songs into the queue, use the + // MediaStore URI instead of loading a bitmap. + .setIconUri(song.album.cover.single.mediaStoreCoverUri) + .setMediaUri(song.uri) + .build() + // Store the item index so we can then use the analogous index in the + // playback state. + MediaSessionCompat.QueueItem(description, i.toLong()) + } + logD("Uploading ${queueItems.size} songs to MediaSession queue") + mediaSession.setQueue(queueItems) + } + + /** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */ + private fun invalidateSessionState() { + logD("Updating media session playback state") + + val state = + // InternalPlayer.State handles position/state information. + playbackManager.progression + .intoPlaybackState(PlaybackStateCompat.Builder()) + .setActions(ACTIONS) + // Active queue ID corresponds to the indices we populated prior, use them here. + .setActiveQueueItemId(playbackManager.index.toLong()) + + // Android 13+ relies on custom actions in the notification. + + // Add the secondary action (either repeat/shuffle depending on the configuration) + val secondaryAction = + when (playbackSettings.notificationAction) { + ActionMode.SHUFFLE -> { + logD("Using shuffle MediaSession action") + PlaybackStateCompat.CustomAction.Builder( + PlaybackActions.ACTION_INVERT_SHUFFLE, + context.getString(R.string.desc_shuffle), + if (playbackManager.isShuffled) { + R.drawable.ic_shuffle_on_24 + } else { + R.drawable.ic_shuffle_off_24 + }) + } + else -> { + logD("Using repeat mode MediaSession action") + PlaybackStateCompat.CustomAction.Builder( + PlaybackActions.ACTION_INC_REPEAT_MODE, + context.getString(R.string.desc_change_repeat), + playbackManager.repeatMode.icon) + } + } + state.addCustomAction(secondaryAction.build()) + + // Add the exit action so the service can be closed + val exitAction = + PlaybackStateCompat.CustomAction.Builder( + PlaybackActions.ACTION_EXIT, + context.getString(R.string.desc_exit), + R.drawable.ic_close_24) + .build() + state.addCustomAction(exitAction) + + mediaSession.setPlaybackState(state.build()) + } + + /** Invalidate the "secondary" action (i.e shuffle/repeat mode). */ + private fun invalidateSecondaryAction() { + logD("Invalidating secondary action") + invalidateSessionState() + + when (playbackSettings.notificationAction) { + ActionMode.SHUFFLE -> { + logD("Using shuffle notification action") + _notification.updateShuffled(playbackManager.isShuffled) + } + else -> { + logD("Using repeat mode notification action") + _notification.updateRepeatMode(playbackManager.repeatMode) + } + } + + if (!bitmapProvider.isBusy) { + logD("Not loading a bitmap, post the notification") + foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + } + + companion object { + private val emptyMetadata = MediaMetadataCompat.Builder().build() + private const val ACTIONS = + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SET_REPEAT_MODE or + PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or + PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_STOP + } +} + +/** + * The playback notification component. Due to race conditions regarding notification updates, this + * component is not self-sufficient. [MediaSessionHolder] should be used instead of manage it. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@SuppressLint("RestrictedApi") +private class PlaybackNotification( + private val context: Context, + sessionToken: MediaSessionCompat.Token +) : ForegroundServiceNotification(context, CHANNEL_INFO) { + init { + setSmallIcon(R.drawable.ic_auxio_24) + setCategory(NotificationCompat.CATEGORY_TRANSPORT) + setShowWhen(false) + setSilent(true) + setContentIntent(context.newMainPendingIntent()) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + + addAction(buildRepeatAction(context, RepeatMode.NONE)) + addAction( + buildAction(context, PlaybackActions.ACTION_SKIP_PREV, R.drawable.ic_skip_prev_24)) + addAction(buildPlayPauseAction(context, true)) + addAction( + buildAction(context, PlaybackActions.ACTION_SKIP_NEXT, R.drawable.ic_skip_next_24)) + addAction(buildAction(context, PlaybackActions.ACTION_EXIT, R.drawable.ic_close_24)) + + setStyle( + MediaStyle(this).setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3)) + } + + override val code: Int + get() = IntegerTable.PLAYBACK_NOTIFICATION_CODE + + // --- STATE FUNCTIONS --- + + /** + * Update the currently shown metadata in this notification. + * + * @param metadata The [MediaMetadataCompat] to display in this notification. + */ + fun updateMetadata(metadata: MediaMetadataCompat) { + logD("Updating shown metadata") + setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)) + setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE)) + setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST)) + setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION)) + } + + /** + * Update the playing state shown in this notification. + * + * @param isPlaying Whether playback should be indicated as ongoing or paused. + */ + fun updatePlaying(isPlaying: Boolean) { + logD("Updating playing state: $isPlaying") + mActions[2] = buildPlayPauseAction(context, isPlaying) + } + + /** + * Update the secondary action in this notification to show the current [RepeatMode]. + * + * @param repeatMode The current [RepeatMode]. + */ + fun updateRepeatMode(repeatMode: RepeatMode) { + logD("Applying repeat mode action: $repeatMode") + mActions[0] = buildRepeatAction(context, repeatMode) + } + + /** + * Update the secondary action in this notification to show the current shuffle state. + * + * @param isShuffled Whether the queue is currently shuffled or not. + */ + fun updateShuffled(isShuffled: Boolean) { + logD("Applying shuffle action: $isShuffled") + mActions[0] = buildShuffleAction(context, isShuffled) + } + + // --- NOTIFICATION ACTION BUILDERS --- + + private fun buildPlayPauseAction( + context: Context, + isPlaying: Boolean + ): NotificationCompat.Action { + val drawableRes = + if (isPlaying) { + R.drawable.ic_pause_24 + } else { + R.drawable.ic_play_24 + } + return buildAction(context, PlaybackActions.ACTION_PLAY_PAUSE, drawableRes) + } + + private fun buildRepeatAction( + context: Context, + repeatMode: RepeatMode + ): NotificationCompat.Action { + return buildAction(context, PlaybackActions.ACTION_INC_REPEAT_MODE, repeatMode.icon) + } + + private fun buildShuffleAction( + context: Context, + isShuffled: Boolean + ): NotificationCompat.Action { + val drawableRes = + if (isShuffled) { + R.drawable.ic_shuffle_on_24 + } else { + R.drawable.ic_shuffle_off_24 + } + return buildAction(context, PlaybackActions.ACTION_INVERT_SHUFFLE, drawableRes) + } + + private fun buildAction(context: Context, actionName: String, @DrawableRes iconRes: Int) = + NotificationCompat.Action.Builder( + iconRes, actionName, context.newBroadcastPendingIntent(actionName)) + .build() + + private companion object { + /** Notification channel used by solely the playback notification. */ + val CHANNEL_INFO = + ChannelInfo( + id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK", + nameRes = R.string.lbl_playback) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt deleted file mode 100644 index a4be02ad2..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ /dev/null @@ -1,297 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaSessionServiceFragment.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.service - -import android.app.Notification -import android.content.Context -import android.os.Bundle -import androidx.core.content.ContextCompat -import androidx.media3.common.MediaItem -import androidx.media3.session.CommandButton -import androidx.media3.session.DefaultActionFactory -import androidx.media3.session.DefaultMediaNotificationProvider -import androidx.media3.session.LibraryResult -import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaLibraryService.MediaLibrarySession -import androidx.media3.session.MediaNotification -import androidx.media3.session.MediaNotification.ActionFactory -import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSession.ConnectionResult -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionResult -import com.google.common.collect.ImmutableList -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.guava.asListenableFuture -import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.ForegroundListener -import org.oxycblt.auxio.IntegerTable -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.service.MediaItemBrowser -import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.state.DeferredPlayback -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.newMainPendingIntent -import org.oxycblt.auxio.widgets.WidgetComponent - -class MediaSessionServiceFragment -@Inject -constructor( - @ApplicationContext private val context: Context, - private val playbackManager: PlaybackStateManager, - private val actionHandler: PlaybackActionHandler, - private val playbackSettings: PlaybackSettings, - private val widgetComponent: WidgetComponent, - private val mediaItemBrowser: MediaItemBrowser, - exoHolderFactory: ExoPlaybackStateHolder.Factory -) : - MediaLibrarySession.Callback, - PlaybackActionHandler.Callback, - MediaItemBrowser.Invalidator, - PlaybackStateManager.Listener { - private val waitJob = Job() - private val waitScope = CoroutineScope(waitJob + Dispatchers.Default) - private val exoHolder = exoHolderFactory.create() - - private lateinit var actionFactory: ActionFactory - private val mediaNotificationProvider = - DefaultMediaNotificationProvider.Builder(context) - .setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE) - .setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK") - .setChannelName(R.string.lbl_playback) - .setPlayDrawableResourceId(R.drawable.ic_play_24) - .setPauseDrawableResourceId(R.drawable.ic_pause_24) - .setSkipNextDrawableResourceId(R.drawable.ic_skip_next_24) - .setSkipPrevDrawableResourceId(R.drawable.ic_skip_prev_24) - .setContentIntent(context.newMainPendingIntent()) - .build() - .also { it.setSmallIcon(R.drawable.ic_auxio_24) } - private var foregroundListener: ForegroundListener? = null - - lateinit var systemReceiver: SystemPlaybackReceiver - lateinit var mediaSession: MediaLibrarySession - private set - - // --- MEDIASESSION CALLBACKS --- - - fun attach(service: MediaLibraryService, listener: ForegroundListener): MediaLibrarySession { - foregroundListener = listener - mediaSession = createSession(service) - service.addSession(mediaSession) - actionFactory = DefaultActionFactory(service) - playbackManager.addListener(this) - exoHolder.attach() - actionHandler.attach(this) - systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) - ContextCompat.registerReceiver( - context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) - widgetComponent.attach() - mediaItemBrowser.attach(this) - return mediaSession - } - - fun handleTaskRemoved() { - if (!playbackManager.progression.isPlaying) { - playbackManager.endSession() - } - } - - fun start(startedBy: Int) { - // At minimum we want to ensure an active playback state. - // TODO: Possibly also force to go foreground? - logD("Handling non-native start.") - val action = - when (startedBy) { - IntegerTable.START_ID_ACTIVITY -> null - IntegerTable.START_ID_TASKER -> - DeferredPlayback.RestoreState( - play = true, fallback = DeferredPlayback.ShuffleAll) - // External services using Auxio better know what they are doing. - else -> DeferredPlayback.RestoreState(play = false) - } - if (action != null) { - logD("Initing service fragment using action $action") - playbackManager.playDeferred(action) - } - } - - fun hasNotification(): Boolean = exoHolder.sessionOngoing - - fun createNotification(post: (MediaNotification) -> Unit) { - val notification = - mediaNotificationProvider.createNotification( - mediaSession, mediaSession.customLayout, actionFactory) { notification -> - post(wrapMediaNotification(notification)) - } - post(wrapMediaNotification(notification)) - } - - fun release() { - waitJob.cancel() - mediaItemBrowser.release() - context.unregisterReceiver(systemReceiver) - widgetComponent.release() - actionHandler.release() - exoHolder.release() - playbackManager.removeListener(this) - mediaSession.release() - foregroundListener = null - } - - private fun wrapMediaNotification(notification: MediaNotification): MediaNotification { - // Pulled from MediaNotificationManager: Need to specify MediaSession token manually - // in notification - val fwkToken = - mediaSession.sessionCompatToken.token as android.media.session.MediaSession.Token - notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken) - return notification - } - - private fun createSession(service: MediaLibraryService) = - MediaLibrarySession.Builder(service, exoHolder.mediaSessionPlayer, this).build() - - override fun onConnect( - session: MediaSession, - controller: MediaSession.ControllerInfo - ): ConnectionResult { - val sessionCommands = - actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS) - return ConnectionResult.AcceptedResultBuilder(session) - .setAvailableSessionCommands(sessionCommands) - .setCustomLayout(actionHandler.createCustomLayout()) - .build() - } - - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle - ): ListenableFuture = - if (actionHandler.handleCommand(customCommand)) { - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } else { - super.onCustomCommand(session, controller, customCommand, args) - } - - override fun onGetLibraryRoot( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - params: MediaLibraryService.LibraryParams? - ): ListenableFuture> = - Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params)) - - override fun onGetItem( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - mediaId: String - ): ListenableFuture> { - val result = - mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } - ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - return Futures.immediateFuture(result) - } - - override fun onSetMediaItems( - mediaSession: MediaSession, - controller: MediaSession.ControllerInfo, - mediaItems: MutableList, - startIndex: Int, - startPositionMs: Long - ): ListenableFuture = - Futures.immediateFuture( - MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) - - override fun onGetChildren( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - parentId: String, - page: Int, - pageSize: Int, - params: MediaLibraryService.LibraryParams? - ): ListenableFuture>> { - val children = - mediaItemBrowser.getChildren(parentId, page, pageSize)?.let { - LibraryResult.ofItemList(it, params) - } - ?: LibraryResult.ofError>( - LibraryResult.RESULT_ERROR_BAD_VALUE) - return Futures.immediateFuture(children) - } - - override fun onSearch( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - params: MediaLibraryService.LibraryParams? - ): ListenableFuture> = - waitScope - .async { - mediaItemBrowser.prepareSearch(query, browser) - // Invalidator will send the notify result - LibraryResult.ofVoid() - } - .asListenableFuture() - - override fun onGetSearchResult( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - page: Int, - pageSize: Int, - params: MediaLibraryService.LibraryParams? - ) = - waitScope - .async { - mediaItemBrowser.getSearchResult(query, page, pageSize)?.let { - LibraryResult.ofItemList(it, params) - } - ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - } - .asListenableFuture() - - override fun onSessionEnded() { - foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) - } - - override fun onCustomLayoutChanged(layout: List) { - mediaSession.setCustomLayout(layout) - } - - override fun invalidate(ids: Map) { - for (id in ids) { - mediaSession.notifyChildrenChanged(id.key, id.value, null) - } - } - - override fun invalidate( - controller: MediaSession.ControllerInfo, - query: String, - itemCount: Int - ) { - mediaSession.notifySearchResultChanged(controller, query, itemCount, null) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt index 3dd0acf68..441bf5253 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt @@ -18,142 +18,7 @@ package org.oxycblt.auxio.playback.service -import android.content.Context -import android.os.Bundle -import androidx.media3.common.Player -import androidx.media3.session.CommandButton -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionCommands -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.ActionMode -import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.Progression -import org.oxycblt.auxio.playback.state.RepeatMode - -class PlaybackActionHandler -@Inject -constructor( - @ApplicationContext private val context: Context, - private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings -) : PlaybackStateManager.Listener, PlaybackSettings.Listener { - - interface Callback { - fun onCustomLayoutChanged(layout: List) - } - - private var callback: Callback? = null - - fun attach(callback: Callback) { - this.callback = callback - playbackManager.addListener(this) - playbackSettings.registerListener(this) - } - - fun release() { - callback = null - playbackManager.removeListener(this) - playbackSettings.unregisterListener(this) - } - - fun withCommands(commands: SessionCommands) = - commands - .buildUpon() - .add(SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle.EMPTY)) - .add(SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle.EMPTY)) - .add(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle.EMPTY)) - .build() - - fun handleCommand(command: SessionCommand): Boolean { - when (command.customAction) { - PlaybackActions.ACTION_INC_REPEAT_MODE -> - playbackManager.repeatMode(playbackManager.repeatMode.increment()) - PlaybackActions.ACTION_INVERT_SHUFFLE -> - playbackManager.shuffled(!playbackManager.isShuffled) - PlaybackActions.ACTION_EXIT -> playbackManager.endSession() - else -> return false - } - return true - } - - fun createCustomLayout(): List { - val actions = mutableListOf() - - when (playbackSettings.notificationAction) { - ActionMode.REPEAT -> { - actions.add( - CommandButton.Builder() - .setIconResId(playbackManager.repeatMode.icon) - .setDisplayName(context.getString(R.string.desc_change_repeat)) - .setSessionCommand( - SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle())) - .setEnabled(true) - .build()) - } - ActionMode.SHUFFLE -> { - actions.add( - CommandButton.Builder() - .setIconResId( - if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24 - else R.drawable.ic_shuffle_off_24) - .setDisplayName(context.getString(R.string.lbl_shuffle)) - .setSessionCommand( - SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle())) - .setEnabled(true) - .build()) - } - else -> {} - } - - actions.add( - CommandButton.Builder() - .setIconResId(R.drawable.ic_skip_prev_24) - .setDisplayName(context.getString(R.string.desc_skip_prev)) - .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) - .setEnabled(true) - .build()) - - actions.add( - CommandButton.Builder() - .setIconResId(R.drawable.ic_close_24) - .setDisplayName(context.getString(R.string.desc_exit)) - .setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle())) - .setEnabled(true) - .build()) - - return actions - } - - override fun onPauseOnRepeatChanged() { - super.onPauseOnRepeatChanged() - callback?.onCustomLayoutChanged(createCustomLayout()) - } - - override fun onProgressionChanged(progression: Progression) { - super.onProgressionChanged(progression) - callback?.onCustomLayoutChanged(createCustomLayout()) - } - - override fun onRepeatModeChanged(repeatMode: RepeatMode) { - super.onRepeatModeChanged(repeatMode) - callback?.onCustomLayoutChanged(createCustomLayout()) - } - - override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { - super.onQueueReordered(queue, index, isShuffled) - callback?.onCustomLayoutChanged(createCustomLayout()) - } - - override fun onNotificationActionChanged() { - super.onNotificationActionChanged() - callback?.onCustomLayoutChanged(createCustomLayout()) - } -} object PlaybackActions { const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt new file mode 100644 index 000000000..91d6d8b77 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaSessionServiceFragment.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.service + +import android.annotation.SuppressLint +import android.content.Context +import android.support.v4.media.session.MediaSessionCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.Job +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.state.DeferredPlayback +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.system.MediaSessionHolder +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.widgets.WidgetComponent + +class PlaybackServiceFragment +@Inject +constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val sessionHolderFactory: MediaSessionHolder.Factory, + private val widgetComponent: WidgetComponent, + exoHolderFactory: ExoPlaybackStateHolder.Factory +) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener { + private val waitJob = Job() + private val exoHolder = exoHolderFactory.create() + private var foregroundListener: ForegroundListener? = null + + private lateinit var sessionHolder: MediaSessionHolder + private lateinit var systemReceiver: SystemPlaybackReceiver + + // --- MEDIASESSION CALLBACKS --- + + @SuppressLint("WrongConstant") + fun attach(listener: ForegroundListener): MediaSessionCompat.Token { + foregroundListener = listener + playbackManager.addListener(this) + exoHolder.attach() + sessionHolder = sessionHolderFactory.create(context) + systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) + ContextCompat.registerReceiver( + context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) + widgetComponent.attach() + return sessionHolder.token + } + + fun handleTaskRemoved() { + if (!playbackManager.progression.isPlaying) { + playbackManager.endSession() + } + } + + fun start(startedBy: Int) { + // At minimum we want to ensure an active playback state. + // TODO: Possibly also force to go foreground? + logD("Handling non-native start.") + val action = + when (startedBy) { + IntegerTable.START_ID_ACTIVITY -> null + IntegerTable.START_ID_TASKER -> + DeferredPlayback.RestoreState( + play = true, fallback = DeferredPlayback.ShuffleAll) + // External services using Auxio better know what they are doing. + else -> DeferredPlayback.RestoreState(play = false) + } + if (action != null) { + logD("Initing service fragment using action $action") + playbackManager.playDeferred(action) + } + } + + val notification: ForegroundServiceNotification? + get() = if (exoHolder.sessionOngoing) sessionHolder.notification else null + + fun release() { + waitJob.cancel() + widgetComponent.release() + context.unregisterReceiver(systemReceiver) + sessionHolder.release() + exoHolder.release() + playbackManager.removeListener(this) + foregroundListener = null + } + + override fun onSessionEnded() { + foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + + // override fun onConnect( + // session: MediaSession, + // controller: MediaSession.ControllerInfo + // ): ConnectionResult { + // val sessionCommands = + // actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS) + // return ConnectionResult.AcceptedResultBuilder(session) + // .setAvailableSessionCommands(sessionCommands) + // .setCustomLayout(actionHandler.createCustomLayout()) + // .build() + // } + // + // override fun onCustomCommand( + // session: MediaSession, + // controller: MediaSession.ControllerInfo, + // customCommand: SessionCommand, + // args: Bundle + // ): ListenableFuture = + // if (actionHandler.handleCommand(customCommand)) { + // Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + // } else { + // super.onCustomCommand(session, controller, customCommand, args) + // } + // + // override fun onGetLibraryRoot( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // params: MediaLibraryService.LibraryParams? + // ): ListenableFuture> = + // Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params)) + // + // override fun onGetItem( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // mediaId: String + // ): ListenableFuture> { + // val result = + // mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } + // ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + // return Futures.immediateFuture(result) + // } + // + // override fun onSetMediaItems( + // mediaSession: MediaSession, + // controller: MediaSession.ControllerInfo, + // mediaItems: MutableList, + // startIndex: Int, + // startPositionMs: Long + // ): ListenableFuture = + // Futures.immediateFuture( + // MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) + // + // override fun onGetChildren( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // parentId: String, + // page: Int, + // pageSize: Int, + // params: MediaLibraryService.LibraryParams? + // ): ListenableFuture>> { + // val children = + // mediaItemBrowser.getChildren(parentId, page, pageSize)?.let { + // LibraryResult.ofItemList(it, params) + // } + // ?: LibraryResult.ofError>( + // LibraryResult.RESULT_ERROR_BAD_VALUE) + // return Futures.immediateFuture(children) + // } + // + // override fun onSearch( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // query: String, + // params: MediaLibraryService.LibraryParams? + // ): ListenableFuture> = + // waitScope + // .async { + // mediaItemBrowser.prepareSearch(query, browser) + // // Invalidator will send the notify result + // LibraryResult.ofVoid() + // } + // .asListenableFuture() + // + // override fun onGetSearchResult( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // query: String, + // page: Int, + // pageSize: Int, + // params: MediaLibraryService.LibraryParams? + // ) = + // waitScope + // .async { + // mediaItemBrowser.getSearchResult(query, page, pageSize)?.let { + // LibraryResult.ofItemList(it, params) + // } + // ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + // } + // .asListenableFuture() + +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index 31c931fdb..d671219a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2024 Auxio Project + * SystemPlaybackReceiver.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.service import android.content.BroadcastReceiver @@ -109,4 +127,4 @@ class SystemPlaybackReceiver( playbackManager.playing(false) } } -} \ No newline at end of file +} From 69070e7b1329ca550887e9952d8ab492d40863cc Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 10:33:54 -0600 Subject: [PATCH 24/87] playback: port basic media descriptions --- .../java/org/oxycblt/auxio/AuxioService.kt | 13 +- .../auxio/music/service/MediaItemBrowser.kt | 374 ----------------- .../music/service/MediaItemTranslation.kt | 359 ++++++---------- .../auxio/music/service/MusicBrowser.kt | 388 +++++++++++++++++ .../service/ExoPlaybackStateHolder.kt | 63 ++- .../playback/service/MediaSessionHolder.kt | 83 +++- .../playback/service/MediaSessionPlayer.kt | 390 ------------------ .../service/PlaybackServiceFragment.kt | 26 +- app/src/main/res/values/strings.xml | 1 + 9 files changed, 656 insertions(+), 1041 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 9c62fdcd2..777cb5ddb 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -24,6 +24,7 @@ import android.content.Intent import android.os.Bundle import android.os.IBinder import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.annotation.StringRes import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat @@ -81,31 +82,29 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot? { - TODO("Not yet implemented") - } + ): BrowserRoot? = null override fun onLoadChildren( parentId: String, - result: Result> + result: Result> ) = throw NotImplementedError() override fun onLoadChildren( parentId: String, - result: Result>, + result: Result>, options: Bundle ) { super.onLoadChildren(parentId, result, options) } - override fun onLoadItem(itemId: String, result: Result) { + override fun onLoadItem(itemId: String, result: Result) { super.onLoadItem(itemId, result) } override fun onSearch( query: String, extras: Bundle?, - result: Result> + result: Result> ) { super.onSearch(query, extras, result) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt deleted file mode 100644 index 5d578119a..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ /dev/null @@ -1,374 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaItemBrowser.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.service -// -// import android.content.Context -// import android.os.Bundle -// import androidx.annotation.StringRes -// import androidx.media.utils.MediaConstants -// import androidx.media3.common.MediaItem -// import androidx.media3.session.MediaSession.ControllerInfo -// import dagger.hilt.android.qualifiers.ApplicationContext -// import javax.inject.Inject -// import kotlin.math.min -// import kotlinx.coroutines.CoroutineScope -// import kotlinx.coroutines.Deferred -// import kotlinx.coroutines.Dispatchers -// import kotlinx.coroutines.Job -// import kotlinx.coroutines.async -// import org.oxycblt.auxio.R -// import org.oxycblt.auxio.list.ListSettings -// import org.oxycblt.auxio.list.sort.Sort -// import org.oxycblt.auxio.music.Album -// import org.oxycblt.auxio.music.Artist -// import org.oxycblt.auxio.music.Genre -// import org.oxycblt.auxio.music.Music -// import org.oxycblt.auxio.music.MusicRepository -// import org.oxycblt.auxio.music.Playlist -// import org.oxycblt.auxio.music.Song -// import org.oxycblt.auxio.music.device.DeviceLibrary -// import org.oxycblt.auxio.music.user.UserLibrary -// import org.oxycblt.auxio.search.SearchEngine -// -// class MediaItemBrowser -// @Inject -// constructor( -// @ApplicationContext private val context: Context, -// private val musicRepository: MusicRepository, -// private val listSettings: ListSettings, -// private val searchEngine: SearchEngine -// ) : MusicRepository.UpdateListener { -// private val browserJob = Job() -// private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) -// private val searchSubscribers = mutableMapOf() -// private val searchResults = mutableMapOf>() -// private var invalidator: Invalidator? = null -// -// interface Invalidator { -// fun invalidate(ids: Map) -// -// fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) -// } -// -// fun attach(invalidator: Invalidator) { -// this.invalidator = invalidator -// musicRepository.addUpdateListener(this) -// } -// -// fun release() { -// browserJob.cancel() -// invalidator = null -// musicRepository.removeUpdateListener(this) -// } -// -// override fun onMusicChanges(changes: MusicRepository.Changes) { -// val deviceLibrary = musicRepository.deviceLibrary -// var invalidateSearch = false -// val invalidate = mutableMapOf() -// if (changes.deviceLibrary && deviceLibrary != null) { -// MediaSessionUID.Category.DEVICE_MUSIC.forEach { -// invalidate[it.toString()] = getCategorySize(it, musicRepository) -// } -// -// deviceLibrary.albums.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size -// } -// -// deviceLibrary.artists.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size -// } -// -// deviceLibrary.genres.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size + it.artists.size -// } -// -// invalidateSearch = true -// } -// val userLibrary = musicRepository.userLibrary -// if (changes.userLibrary && userLibrary != null) { -// MediaSessionUID.Category.USER_MUSIC.forEach { -// invalidate[it.toString()] = getCategorySize(it, musicRepository) -// } -// userLibrary.playlists.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size -// } -// invalidateSearch = true -// } -// -// if (invalidate.isNotEmpty()) { -// invalidator?.invalidate(invalidate) -// } -// -// if (invalidateSearch) { -// for (entry in searchResults.entries) { -// searchResults[entry.key]?.cancel() -// } -// searchResults.clear() -// -// for (entry in searchSubscribers.entries) { -// if (searchResults[entry.value] != null) { -// continue -// } -// searchResults[entry.value] = searchTo(entry.value) -// } -// } -// } -// -// val root: MediaItem -// get() = MediaSessionUID.Category.ROOT.toMediaItem(context) -// -// fun getItem(mediaId: String): MediaItem? { -// val music = -// when (val uid = MediaSessionUID.fromString(mediaId)) { -// is MediaSessionUID.Category -> return uid.toMediaItem(context) -// is MediaSessionUID.Single -> -// musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } -// is MediaSessionUID.Joined -> -// musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } -// null -> null -// } -// ?: return null -// -// return when (music) { -// is Album -> music.toMediaItem(context) -// is Artist -> music.toMediaItem(context) -// is Genre -> music.toMediaItem(context) -// is Playlist -> music.toMediaItem(context) -// is Song -> music.toMediaItem(context, null) -// } -// } -// -// fun getChildren(parentId: String, page: Int, pageSize: Int): List? { -// val deviceLibrary = musicRepository.deviceLibrary -// val userLibrary = musicRepository.userLibrary -// if (deviceLibrary == null || userLibrary == null) { -// return listOf() -// } -// -// val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null -// return items.paginate(page, pageSize) -// } -// -// private fun getMediaItemList( -// id: String, -// deviceLibrary: DeviceLibrary, -// userLibrary: UserLibrary -// ): List? { -// return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { -// is MediaSessionUID.Category -> { -// when (mediaSessionUID) { -// MediaSessionUID.Category.ROOT -> -// MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } -// MediaSessionUID.Category.SONGS -> -// listSettings.songSort.songs(deviceLibrary.songs).map { -// it.toMediaItem(context, null) -// } -// MediaSessionUID.Category.ALBUMS -> -// listSettings.albumSort.albums(deviceLibrary.albums).map { -// it.toMediaItem(context) -// } -// MediaSessionUID.Category.ARTISTS -> -// listSettings.artistSort.artists(deviceLibrary.artists).map { -// it.toMediaItem(context) -// } -// MediaSessionUID.Category.GENRES -> -// listSettings.genreSort.genres(deviceLibrary.genres).map { -// it.toMediaItem(context) -// } -// MediaSessionUID.Category.PLAYLISTS -> -// userLibrary.playlists.map { it.toMediaItem(context) } -// } -// } -// is MediaSessionUID.Single -> { -// getChildMediaItems(mediaSessionUID.uid) -// } -// is MediaSessionUID.Joined -> { -// getChildMediaItems(mediaSessionUID.childUid) -// } -// null -> { -// return null -// } -// } -// } -// -// private fun getChildMediaItems(uid: Music.UID): List? { -// return when (val item = musicRepository.find(uid)) { -// is Album -> { -// val songs = listSettings.albumSongSort.songs(item.songs) -// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } -// } -// is Artist -> { -// val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) -// val songs = listSettings.artistSongSort.songs(item.songs) -// albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + -// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } -// } -// is Genre -> { -// val artists = GENRE_ARTISTS_SORT.artists(item.artists) -// val songs = listSettings.genreSongSort.songs(item.songs) -// artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + -// songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } -// } -// is Playlist -> { -// item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } -// } -// is Song, -// null -> return null -// } -// } -// -// private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { -// val oldExtras = mediaMetadata.extras ?: Bundle() -// val newExtras = -// Bundle(oldExtras).apply { -// putString( -// MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, -// context.getString(res)) -// } -// return buildUpon() -// .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) -// .build() -// } -// -// private fun getCategorySize( -// category: MediaSessionUID.Category, -// musicRepository: MusicRepository -// ): Int { -// val deviceLibrary = musicRepository.deviceLibrary ?: return 0 -// val userLibrary = musicRepository.userLibrary ?: return 0 -// return when (category) { -// MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size -// MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size -// MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size -// MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size -// MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size -// MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size -// } -// } -// -// suspend fun prepareSearch(query: String, controller: ControllerInfo) { -// searchSubscribers[controller] = query -// val existing = searchResults[query] -// if (existing == null) { -// val new = searchTo(query) -// searchResults[query] = new -// new.await() -// } else { -// val items = existing.await() -// invalidator?.invalidate(controller, query, items.count()) -// } -// } -// -// suspend fun getSearchResult( -// query: String, -// page: Int, -// pageSize: Int, -// ): List? { -// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } -// return deferred.await().concat().paginate(page, pageSize) -// } -// -// private fun SearchEngine.Items.concat(): MutableList { -// val music = mutableListOf() -// if (songs != null) { -// music.addAll(songs.map { it.toMediaItem(context, null) }) -// } -// if (albums != null) { -// music.addAll(albums.map { it.toMediaItem(context) }) -// } -// if (artists != null) { -// music.addAll(artists.map { it.toMediaItem(context) }) -// } -// if (genres != null) { -// music.addAll(genres.map { it.toMediaItem(context) }) -// } -// if (playlists != null) { -// music.addAll(playlists.map { it.toMediaItem(context) }) -// } -// return music -// } -// -// private fun SearchEngine.Items.count(): Int { -// var count = 0 -// if (songs != null) { -// count += songs.size -// } -// if (albums != null) { -// count += albums.size -// } -// if (artists != null) { -// count += artists.size -// } -// if (genres != null) { -// count += genres.size -// } -// if (playlists != null) { -// count += playlists.size -// } -// return count -// } -// -// private fun searchTo(query: String) = -// searchScope.async { -// if (query.isEmpty()) { -// return@async SearchEngine.Items() -// } -// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() -// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() -// val items = -// SearchEngine.Items( -// deviceLibrary.songs, -// deviceLibrary.albums, -// deviceLibrary.artists, -// deviceLibrary.genres, -// userLibrary.playlists) -// val results = searchEngine.search(items, query) -// for (entry in searchSubscribers.entries) { -// if (entry.value == query) { -// invalidator?.invalidate(entry.key, query, results.count()) -// } -// } -// results -// } -// -// private fun List.paginate(page: Int, pageSize: Int): List? { -// if (page == Int.MAX_VALUE) { -// // I think if someone requests this page it more or less implies that I should -// // return all of the pages. -// return this -// } -// val start = page * pageSize -// val end = min((page + 1) * pageSize, size) // Tolerate partial page queries -// if (pageSize == 0 || start !in indices) { -// // These pages are probably invalid. Hopefully this won't backfire. -// return null -// } -// return subList(start, end).toMutableList() -// } -// -// private companion object { -// // TODO: Rely on detail item gen logic? -// val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) -// val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) -// } -// } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 9a5bb53c2..043aa0d46 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -22,12 +22,13 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat.MediaItem +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.media.utils.MediaConstants -import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata -import java.io.ByteArrayOutputStream import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album @@ -37,239 +38,37 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.getPlural +import java.io.ByteArrayOutputStream +import kotlin.math.ceil -fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem { - // TODO: Make custom overflow menu for compat - val style = - Bundle().apply { - putInt( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) - } - val metadata = - MediaMetadata.Builder() - .setTitle(context.getString(nameRes)) - .setIsPlayable(false) - .setIsBrowsable(true) - .setMediaType(mediaType) - .setExtras(style) - if (bitmapRes != null) { - val data = ByteArrayOutputStream() - BitmapFactory.decodeResource(context.resources, bitmapRes) - .compress(Bitmap.CompressFormat.PNG, 100, data) - metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON) - } - return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build() -} +enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) { + ROOT("root", R.string.info_app_name, null), + MORE("more", R.string.lbl_more, R.drawable.ic_more_24), + SONGS("songs", R.string.lbl_songs, R.drawable.ic_song_bitmap_24), + ALBUMS("albums", R.string.lbl_albums, R.drawable.ic_album_bitmap_24), + ARTISTS("artists", R.string.lbl_artists, R.drawable.ic_artist_bitmap_24), + GENRES("genres", R.string.lbl_genres, R.drawable.ic_genre_bitmap_24), + PLAYLISTS("playlists", R.string.lbl_playlists, R.drawable.ic_playlist_bitmap_24); -fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { - val mediaSessionUID = - if (parent == null) { - MediaSessionUID.Single(uid) - } else { - MediaSessionUID.Joined(parent.uid, uid) - } - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setArtist(artists.resolveNames(context)) - .setAlbumTitle(album.name.resolve(context)) - .setAlbumArtist(album.artists.resolveNames(context)) - .setTrackNumber(track) - .setDiscNumber(disc?.number) - .setGenre(genres.resolveNames(context)) - .setDisplayTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setRecordingYear(album.dates?.min?.year) - .setRecordingMonth(album.dates?.min?.month) - .setRecordingDay(album.dates?.min?.day) - .setReleaseYear(album.dates?.min?.year) - .setReleaseMonth(album.dates?.min?.month) - .setReleaseDay(album.dates?.min?.day) - .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) - .setIsPlayable(true) - .setIsBrowsable(false) - .setArtworkUri(cover.mediaStoreCoverUri) - .setExtras( - Bundle().apply { - putString("uid", mediaSessionUID.toString()) - putLong("durationMs", durationMs) - }) - .build() - return MediaItem.Builder() - .setUri(uri) - .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -fun Album.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setArtist(artists.resolveNames(context)) - .setAlbumTitle(name.resolve(context)) - .setAlbumArtist(artists.resolveNames(context)) - .setRecordingYear(dates?.min?.year) - .setRecordingMonth(dates?.min?.month) - .setRecordingDay(dates?.min?.day) - .setReleaseYear(dates?.min?.year) - .setReleaseMonth(dates?.min?.month) - .setReleaseDay(dates?.min?.day) - .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) - .setIsPlayable(false) - .setIsBrowsable(true) - .setArtworkUri(cover.single.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() - .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -fun Artist.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - context.getString( - R.string.fmt_two, - if (explicitAlbums.isNotEmpty()) { - context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) - } else { - context.getString(R.string.def_album_count) - }, - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - })) - .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) - .setIsPlayable(false) - .setIsBrowsable(true) - .setGenre(genres.resolveNames(context)) - .setArtworkUri(cover.single.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() - .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -fun Genre.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - }) - .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) - .setIsPlayable(false) - .setIsBrowsable(true) - .setArtworkUri(cover.single.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() - .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -fun Playlist.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - }) - .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) - .setIsPlayable(false) - .setIsBrowsable(true) - .setArtworkUri(cover?.single?.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() - .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? { - val uid = MediaSessionUID.fromString(mediaId) ?: return null - return when (uid) { - is MediaSessionUID.Single -> { - deviceLibrary.findSong(uid.uid) - } - is MediaSessionUID.Joined -> { - deviceLibrary.findSong(uid.childUid) - } - is MediaSessionUID.Category -> null + companion object { + val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES) + val USER_MUSIC = listOf(ROOT, PLAYLISTS) + val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) } } sealed interface MediaSessionUID { - enum class Category( - val id: String, - @StringRes val nameRes: Int, - @DrawableRes val bitmapRes: Int?, - val mediaType: Int? - ) : MediaSessionUID { - ROOT("root", R.string.info_app_name, null, null), - SONGS( - "songs", - R.string.lbl_songs, - R.drawable.ic_song_bitmap_24, - MediaMetadata.MEDIA_TYPE_MUSIC), - ALBUMS( - "albums", - R.string.lbl_albums, - R.drawable.ic_album_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), - ARTISTS( - "artists", - R.string.lbl_artists, - R.drawable.ic_artist_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), - GENRES( - "genres", - R.string.lbl_genres, - R.drawable.ic_genre_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), - PLAYLISTS( - "playlists", - R.string.lbl_playlists, - R.drawable.ic_playlist_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); - - override fun toString() = "$ID_CATEGORY:$id" - - companion object { - val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES) - val USER_MUSIC = listOf(ROOT, PLAYLISTS) - val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) - } + data class CategoryItem(val category: Category) : MediaSessionUID { + override fun toString() = "$ID_CATEGORY:$category" } - data class Single(val uid: Music.UID) : MediaSessionUID { + data class SingleItem(val uid: Music.UID) : MediaSessionUID { override fun toString() = "$ID_ITEM:$uid" } - data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { + data class ChildItem(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { override fun toString() = "$ID_ITEM:$parentUid>$childUid" } @@ -284,22 +83,23 @@ sealed interface MediaSessionUID { } return when (parts[0]) { ID_CATEGORY -> - when (parts[1]) { + CategoryItem(when (parts[1]) { Category.ROOT.id -> Category.ROOT + Category.MORE.id -> Category.MORE Category.SONGS.id -> Category.SONGS Category.ALBUMS.id -> Category.ALBUMS Category.ARTISTS.id -> Category.ARTISTS Category.GENRES.id -> Category.GENRES Category.PLAYLISTS.id -> Category.PLAYLISTS - else -> null - } + else -> return null + }) ID_ITEM -> { val uids = parts[1].split(">", limit = 2) if (uids.size == 1) { - Music.UID.fromString(uids[0])?.let { Single(it) } + Music.UID.fromString(uids[0])?.let { SingleItem(it) } } else { Music.UID.fromString(uids[0])?.let { parent -> - Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) } + Music.UID.fromString(uids[1])?.let { child -> ChildItem(parent, child) } } } } @@ -308,3 +108,108 @@ sealed interface MediaSessionUID { } } } + +fun Category.toMediaItem(context: Context): MediaItem { + // TODO: Make custom overflow menu for compat + val style = + Bundle().apply { + putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) + } + val mediaSessionUID = MediaSessionUID.CategoryItem(this) + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(context.getString(nameRes)) + if (bitmapRes != null) { + val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes) + description.setIconBitmap(bitmap) + } + return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) +} +fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { + val mediaSessionUID = + if (parent == null) { + MediaSessionUID.SingleItem(uid) + } else { + MediaSessionUID.ChildItem(parent.uid, uid) + } + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setDescription(album.name.resolve(context)) + .setIconUri(album.cover.single.mediaStoreCoverUri) + .setMediaUri(uri) + .build() + return MediaItem(description, MediaItem.FLAG_PLAYABLE) +} + +fun Album.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} + +fun Artist.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = + context.getString( + R.string.fmt_two, + if (explicitAlbums.isNotEmpty()) { + context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) + } else { + context.getString(R.string.def_album_count) + }, + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + }) + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} + +fun Genre.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + } + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} + +fun Playlist.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + } + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover?.single?.mediaStoreCoverUri) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt new file mode 100644 index 000000000..62238c5b9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -0,0 +1,388 @@ +/* + * Copyright (c) 2024 Auxio Project + * MusicBrowser.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.service + +import android.content.Context +import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat.MediaItem +import androidx.annotation.StringRes +import androidx.media.utils.MediaConstants +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import org.oxycblt.auxio.R +import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.search.SearchEngine +import javax.inject.Inject +import kotlin.math.min + +class MediaItemBrowser +@Inject +constructor( + @ApplicationContext private val context: Context, + private val musicRepository: MusicRepository, + private val listSettings: ListSettings +) : MusicRepository.UpdateListener { + private val browserJob = Job() + private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) + private val searchSubscribers = mutableMapOf() + private val searchResults = mutableMapOf>() + private var invalidator: Invalidator? = null + + interface Invalidator { + fun invalidate(ids: Map) + + fun invalidate(controller: String, query: String, itemCount: Int) + } + + fun attach(invalidator: Invalidator) { + this.invalidator = invalidator + musicRepository.addUpdateListener(this) + } + + fun release() { + browserJob.cancel() + invalidator = null + musicRepository.removeUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary + var invalidateSearch = false + val invalidate = mutableMapOf() + if (changes.deviceLibrary && deviceLibrary != null) { + MediaSessionUID.Category.DEVICE_MUSIC.forEach { + invalidate[it.toString()] = getCategorySize(it, musicRepository) + } + + deviceLibrary.albums.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + } + + deviceLibrary.artists.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size + } + + deviceLibrary.genres.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + it.artists.size + } + + invalidateSearch = true + } + val userLibrary = musicRepository.userLibrary + if (changes.userLibrary && userLibrary != null) { + MediaSessionUID.Category.USER_MUSIC.forEach { + invalidate[it.toString()] = getCategorySize(it, musicRepository) + } + userLibrary.playlists.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + } + invalidateSearch = true + } + + if (invalidate.isNotEmpty()) { + invalidator?.invalidate(invalidate) + } + + if (invalidateSearch) { + for (entry in searchResults.entries) { + searchResults[entry.key]?.cancel() + } + searchResults.clear() + + for (entry in searchSubscribers.entries) { + if (searchResults[entry.value] != null) { + continue + } + searchResults[entry.value] = searchTo(entry.value) + } + } + } + + val root: MediaItem + get() = MediaSessionUID.Category.ROOT.toMediaItem(context) + + fun getItem(mediaId: String): MediaItem? { + val music = + when (val uid = MediaSessionUID.fromString(mediaId)) { + is MediaSessionUID.Category -> return uid.toMediaItem(context) + is MediaSessionUID.SingleItem -> + musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } + + is MediaSessionUID.ChildItem -> + musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } + + null -> null + } + ?: return null + + return when (music) { + is Album -> music.toMediaItem(context) + is Artist -> music.toMediaItem(context) + is Genre -> music.toMediaItem(context) + is Playlist -> music.toMediaItem(context) + is Song -> music.toMediaItem(context, null) + } + } + + fun getChildren(parentId: String, page: Int, pageSize: Int): List? { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (deviceLibrary == null || userLibrary == null) { + return listOf() + } + + val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null + return items.paginate(page, pageSize) + } + + private fun getMediaItemList( + id: String, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ): List? { + return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { + is MediaSessionUID.Category -> { + when (mediaSessionUID) { + MediaSessionUID.Category.ROOT -> + MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } + + MediaSessionUID.Category.SONGS -> + listSettings.songSort.songs(deviceLibrary.songs).map { + it.toMediaItem(context, null) + } + + MediaSessionUID.Category.ALBUMS -> + listSettings.albumSort.albums(deviceLibrary.albums).map { + it.toMediaItem(context) + } + + MediaSessionUID.Category.ARTISTS -> + listSettings.artistSort.artists(deviceLibrary.artists).map { + it.toMediaItem(context) + } + + MediaSessionUID.Category.GENRES -> + listSettings.genreSort.genres(deviceLibrary.genres).map { + it.toMediaItem(context) + } + + MediaSessionUID.Category.PLAYLISTS -> + userLibrary.playlists.map { it.toMediaItem(context) } + } + } + + is MediaSessionUID.SingleItem -> { + getChildMediaItems(mediaSessionUID.uid) + } + + is MediaSessionUID.ChildItem -> { + getChildMediaItems(mediaSessionUID.childUid) + } + + null -> { + return null + } + } + } + + private fun getChildMediaItems(uid: Music.UID): List? { + return when (val item = musicRepository.find(uid)) { + is Album -> { + val songs = listSettings.albumSongSort.songs(item.songs) + songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + } + + is Artist -> { + val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) + val songs = listSettings.artistSongSort.songs(item.songs) + albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + + songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + } + + is Genre -> { + val artists = GENRE_ARTISTS_SORT.artists(item.artists) + val songs = listSettings.genreSongSort.songs(item.songs) + artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + + songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } + } + + is Playlist -> { + item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + } + + is Song, + null -> return null + } + } + + private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { + val oldExtras = mediaMetadata.extras ?: Bundle() + val newExtras = + Bundle(oldExtras).apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + context.getString(res) + ) + } + return buildUpon() + .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) + .build() + } + + private fun getCategorySize( + category: MediaSessionUID.Category, + musicRepository: MusicRepository + ): Int { + val deviceLibrary = musicRepository.deviceLibrary ?: return 0 + val userLibrary = musicRepository.userLibrary ?: return 0 + return when (category) { + MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size + MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size + MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size + MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size + MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size + MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size + } + } + + suspend fun prepareSearch(query: String, controller: ControllerInfo) { + searchSubscribers[controller] = query + val existing = searchResults[query] + if (existing == null) { + val new = searchTo(query) + searchResults[query] = new + new.await() + } else { + val items = existing.await() + invalidator?.invalidate(controller, query, items.count()) + } + } + + suspend fun getSearchResult( + query: String, + page: Int, + pageSize: Int, + ): List? { + val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } + return deferred.await().concat().paginate(page, pageSize) + } + + private fun SearchEngine.Items.concat(): MutableList { + val music = mutableListOf() + if (songs != null) { + music.addAll(songs.map { it.toMediaItem(context, null) }) + } + if (albums != null) { + music.addAll(albums.map { it.toMediaItem(context) }) + } + if (artists != null) { + music.addAll(artists.map { it.toMediaItem(context) }) + } + if (genres != null) { + music.addAll(genres.map { it.toMediaItem(context) }) + } + if (playlists != null) { + music.addAll(playlists.map { it.toMediaItem(context) }) + } + return music + } + + private fun SearchEngine.Items.count(): Int { + var count = 0 + if (songs != null) { + count += songs.size + } + if (albums != null) { + count += albums.size + } + if (artists != null) { + count += artists.size + } + if (genres != null) { + count += genres.size + } + if (playlists != null) { + count += playlists.size + } + return count + } + + private fun searchTo(query: String) = + searchScope.async { + if (query.isEmpty()) { + return@async SearchEngine.Items() + } + val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() + val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() + val items = + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists + ) + val results = searchEngine.search(items, query) + for (entry in searchSubscribers.entries) { + if (entry.value == query) { + invalidator?.invalidate(entry.key, query, results.count()) + } + } + results + } + + private fun List.paginate(page: Int, pageSize: Int): List? { + if (page == Int.MAX_VALUE) { + // I think if someone requests this page it more or less implies that I should + // return all of the pages. + return this + } + val start = page * pageSize + val end = min((page + 1) * pageSize, size) // Tolerate partial page queries + if (pageSize == 0 || start !in indices) { + // These pages are probably invalid. Hopefully this won't backfire. + return null + } + return subList(start, end).toMutableList() + } + + private companion object { + // TODO: Rely on detail item gen logic? + val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) + val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 59ac16d95..1abc6bfb3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -47,7 +47,6 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.service.toMediaItem -import org.oxycblt.auxio.music.service.toSong import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor @@ -110,10 +109,6 @@ class ExoPlaybackStateHolder( override var parent: MusicParent? = null private set - val mediaSessionPlayer: Player - get() = - MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository) - override val progression: Progression get() { val mediaItem = player.currentMediaItem ?: return Progression.nil() @@ -147,7 +142,7 @@ class ExoPlaybackStateHolder( emptyList() } return RawQueue( - heap.mapNotNull { it.toSong(deviceLibrary) }, + heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex) } @@ -226,7 +221,7 @@ class ExoPlaybackStateHolder( override fun newPlayback(command: PlaybackCommand) { parent = command.parent player.shuffleModeEnabled = command.shuffled - player.setMediaItems(command.queue.map { it.toMediaItem(context, null) }) + player.setMediaItems(command.queue.map { it.buildMediaItem() }) val startIndex = command.song ?.let { command.queue.indexOf(it) } @@ -316,16 +311,16 @@ class ExoPlaybackStateHolder( } if (nextIndex == C.INDEX_UNSET) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(songs.map { it.buildMediaItem() }) } else { - player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() }) } playbackManager.ack(this, ack) deferSave() } override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(songs.map { it.buildMediaItem() }) playbackManager.ack(this, ack) deferSave() } @@ -382,7 +377,7 @@ class ExoPlaybackStateHolder( sendEvent = true } if (rawQueue != resolveQueue()) { - player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) + player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() }) if (rawQueue.isShuffled) { player.shuffleModeEnabled = true player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) @@ -538,6 +533,52 @@ class ExoPlaybackStateHolder( currentSaveJob = saveScope.launch { block() } } + private fun Song.buildMediaItem() = MediaItem.Builder() + .setUri(uri) + .setTag(this) + .build() + + private val MediaItem.song: Song? get() = this.localConfiguration?.tag as? Song? + + private fun Player.unscrambleQueueIndices(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = mutableListOf() + + // Add the active queue item. + val currentMediaItemIndex = currentMediaItemIndex + queue.add(currentMediaItemIndex) + + // Fill queue alternating with next and/or previous queue items. + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { + // Begin with next to have a longer tail than head if an even sized queue needs to be + // trimmed. + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = + timeline.getNextWindowIndex( + lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(lastMediaItemIndex) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET) { + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.add(0, firstMediaItemIndex) + } + } + } + + return queue + } + class Factory @Inject constructor( diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 4c8c367fc..63c281080 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -39,16 +39,25 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.service.PlaybackActions +import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.ShuffleMode import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newBroadcastPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent @@ -64,6 +73,8 @@ private constructor( private val context: Context, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings ) : @@ -77,12 +88,14 @@ private constructor( constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings ) { fun create(context: Context) = MediaSessionHolder( - context, playbackManager, playbackSettings, bitmapProvider, imageSettings) + context, playbackManager, playbackSettings, commandFactory, musicRepository, bitmapProvider, imageSettings) } private val mediaSession = @@ -201,27 +214,47 @@ private constructor( override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) - // STUB: Unimplemented, no media browser + val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return + val command = expandIntoCommand(uid) + requireNotNull(command) { "Invalid playback configuration" } + playbackManager.play(command) } override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { super.onPlayFromUri(uri, extras) - // STUB: Unimplemented, no media browser + // STUB } override fun onPlayFromSearch(query: String?, extras: Bundle?) { super.onPlayFromSearch(query, extras) - // STUB: Unimplemented, no media browser + // STUB: Unimplemented, no search engine } - override fun onAddQueueItem(description: MediaDescriptionCompat?) { + override fun onAddQueueItem(description: MediaDescriptionCompat) { super.onAddQueueItem(description) - // STUB: Unimplemented + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val song = when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } ?: return + playbackManager.addToQueue(song) } - override fun onRemoveQueueItem(description: MediaDescriptionCompat?) { + override fun onRemoveQueueItem(description: MediaDescriptionCompat) { super.onRemoveQueueItem(description) - // STUB: Unimplemented + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val song = when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } ?: return + val queueIndex = playbackManager.queue.indexOf(song) + if (queueIndex > -1) { + playbackManager.removeQueueItem(queueIndex) + } } override fun onPlay() { @@ -392,6 +425,40 @@ private constructor( mediaSession.setQueue(queueItems) } + private fun expandIntoCommand(uid: MediaSessionUID): PlaybackCommand? { + val music: Music + var parent: MusicParent? = null + when (uid) { + is MediaSessionUID.SingleItem -> { + music = musicRepository.find(uid.uid) ?: return null + } + is MediaSessionUID.ChildItem -> { + music = musicRepository.find(uid.childUid) ?: return null + parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null + } + else -> return null + } + + return when (music) { + is Song -> inferSongFromParent(music, parent) + is Album -> commandFactory.album(music, ShuffleMode.OFF) + is Artist -> commandFactory.artist(music, ShuffleMode.OFF) + is Genre -> commandFactory.genre(music, ShuffleMode.OFF) + is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) + } + } + + private fun inferSongFromParent(music: Song, parent: MusicParent?) = + when (parent) { + is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) + null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) + } + /** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */ private fun invalidateSessionState() { logD("Updating media session playback state") diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt deleted file mode 100644 index f5ea4215c..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ /dev/null @@ -1,390 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaSessionPlayer.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.service - -import android.content.Context -import android.os.Bundle -import android.view.Surface -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.view.TextureView -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.ForwardingPlayer -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.common.TrackSelectionParameters -import java.lang.Exception -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.service.MediaSessionUID -import org.oxycblt.auxio.music.service.toSong -import org.oxycblt.auxio.playback.state.PlaybackCommand -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.state.ShuffleMode -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logE - -/** - * A thin wrapper around the player instance that drastically reduces the command surface and - * forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands that - * Media3 will throw at me will be handled in a predictable way, rather than just clobbering the - * playback state. Largely limited to the legacy media APIs. - * - * I'll add more support as I go along when I can confirm that apps will use the Media3 API and send - * more advanced commands. - * - * @author Alexander Capehart - */ -class MediaSessionPlayer( - private val context: Context, - player: Player, - private val playbackManager: PlaybackStateManager, - private val commandFactory: PlaybackCommand.Factory, - private val musicRepository: MusicRepository -) : ForwardingPlayer(player) { - override fun getAvailableCommands(): Player.Commands { - return super.getAvailableCommands() - .buildUpon() - .addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - .build() - } - - override fun isCommandAvailable(command: Int): Boolean { - // We can always skip forward and backward (this is to retain parity with the old behavior) - return super.isCommandAvailable(command) || - command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - } - - override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { - if (!resetPosition) { - error("Playing MediaItems with custom position parameters is not supported") - } - - setMediaItems(mediaItems, C.INDEX_UNSET, C.TIME_UNSET) - } - - override fun getMediaMetadata() = - super.getMediaMetadata().run { - val existingExtras = extras - val newExtras = existingExtras?.let { Bundle(it) } ?: Bundle() - newExtras.apply { - putString( - "parent", - playbackManager.parent?.name?.resolve(context) - ?: context.getString(R.string.lbl_all_songs)) - } - - buildUpon().setExtras(newExtras).build() - } - - override fun setMediaItems( - mediaItems: MutableList, - startIndex: Int, - startPositionMs: Long - ) { - // We assume the only people calling this method are going to be the MediaSession callbacks. - // As part of this, we expand the given MediaItems into the command that should be sent to - // the player. - if (startIndex != C.INDEX_UNSET || startPositionMs != C.TIME_UNSET) { - error("Playing MediaItems with custom position parameters is not supported") - } - if (mediaItems.size != 1) { - error("Playing multiple MediaItems is not supported") - } - val command = expandMediaItemIntoCommand(mediaItems.first()) - requireNotNull(command) { "Invalid playback configuration" } - playbackManager.play(command) - } - - private fun expandMediaItemIntoCommand(mediaItem: MediaItem): PlaybackCommand? { - val uid = MediaSessionUID.fromString(mediaItem.mediaId) ?: return null - val music: Music - var parent: MusicParent? = null - when (uid) { - is MediaSessionUID.Single -> { - music = musicRepository.find(uid.uid) ?: return null - } - is MediaSessionUID.Joined -> { - music = musicRepository.find(uid.childUid) ?: return null - parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null - } - else -> return null - } - - return when (music) { - is Song -> inferSongFromParentCommand(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) - } - } - - private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) = - when (parent) { - is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) - is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) - is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) - is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) - null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) - } - - override fun play() = playbackManager.playing(true) - - override fun pause() = playbackManager.playing(false) - - override fun setRepeatMode(repeatMode: Int) { - val appRepeatMode = - when (repeatMode) { - Player.REPEAT_MODE_OFF -> RepeatMode.NONE - Player.REPEAT_MODE_ONE -> RepeatMode.TRACK - Player.REPEAT_MODE_ALL -> RepeatMode.ALL - else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") - } - playbackManager.repeatMode(appRepeatMode) - } - - override fun seekToDefaultPosition(mediaItemIndex: Int) { - val indices = unscrambleQueueIndices() - val fakeIndex = indices.indexOf(mediaItemIndex) - if (fakeIndex < 0) { - return - } - playbackManager.goto(fakeIndex) - } - - override fun seekToNext() = playbackManager.next() - - override fun seekToNextMediaItem() = playbackManager.next() - - override fun seekToPrevious() = playbackManager.prev() - - override fun seekToPreviousMediaItem() = playbackManager.prev() - - override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs) - - override fun seekTo(mediaItemIndex: Int, positionMs: Long) = notAllowed() - - override fun seekToDefaultPosition() = notAllowed() - - override fun addMediaItems(index: Int, mediaItems: MutableList) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } - when { - index == - currentTimeline.getNextWindowIndex( - currentMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) -> { - playbackManager.playNext(songs) - } - index >= mediaItemCount -> playbackManager.addToQueue(songs) - else -> error("Unsupported index $index") - } - } - - override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { - playbackManager.shuffled(shuffleModeEnabled) - } - - override fun moveMediaItem(currentIndex: Int, newIndex: Int) { - val indices = unscrambleQueueIndices() - val fakeFrom = indices.indexOf(currentIndex) - if (fakeFrom < 0) { - return - } - val fakeTo = - if (newIndex >= mediaItemCount) { - currentTimeline.getLastWindowIndex(shuffleModeEnabled) - } else { - indices.indexOf(newIndex) - } - if (fakeTo < 0) { - return - } - playbackManager.moveQueueItem(fakeFrom, fakeTo) - } - - override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) = - error("Multi-item queue moves are unsupported") - - override fun removeMediaItem(index: Int) { - val indices = unscrambleQueueIndices() - val fakeAt = indices.indexOf(index) - if (fakeAt < 0) { - return - } - playbackManager.removeQueueItem(fakeAt) - } - - override fun removeMediaItems(fromIndex: Int, toIndex: Int) = - error("Any multi-item queue removal is unsupported") - - override fun stop() = playbackManager.endSession() - - // These methods I don't want MediaSession calling in any way since they'll do insane things - // that I'm not tracking. If they do call them, I will know. - - override fun setMediaItem(mediaItem: MediaItem) = notAllowed() - - override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = notAllowed() - - override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) = notAllowed() - - override fun setMediaItems(mediaItems: MutableList) = notAllowed() - - override fun addMediaItem(mediaItem: MediaItem) = notAllowed() - - override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() - - override fun addMediaItems(mediaItems: MutableList) = notAllowed() - - override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() - - override fun replaceMediaItems( - fromIndex: Int, - toIndex: Int, - mediaItems: MutableList - ) = notAllowed() - - override fun clearMediaItems() = notAllowed() - - override fun setPlaybackSpeed(speed: Float) = notAllowed() - - override fun seekForward() = notAllowed() - - override fun seekBack() = notAllowed() - - @Deprecated("Deprecated in Java") override fun next() = notAllowed() - - @Deprecated("Deprecated in Java") override fun previous() = notAllowed() - - @Deprecated("Deprecated in Java") override fun seekToPreviousWindow() = notAllowed() - - @Deprecated("Deprecated in Java") override fun seekToNextWindow() = notAllowed() - - override fun prepare() = notAllowed() - - override fun release() = notAllowed() - - override fun setPlayWhenReady(playWhenReady: Boolean) = notAllowed() - - override fun hasNextMediaItem() = notAllowed() - - override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = - notAllowed() - - override fun setVolume(volume: Float) = notAllowed() - - override fun setDeviceVolume(volume: Int, flags: Int) = notAllowed() - - override fun setDeviceMuted(muted: Boolean, flags: Int) = notAllowed() - - override fun increaseDeviceVolume(flags: Int) = notAllowed() - - override fun decreaseDeviceVolume(flags: Int) = notAllowed() - - @Deprecated("Deprecated in Java") override fun increaseDeviceVolume() = notAllowed() - - @Deprecated("Deprecated in Java") override fun decreaseDeviceVolume() = notAllowed() - - @Deprecated("Deprecated in Java") override fun setDeviceVolume(volume: Int) = notAllowed() - - @Deprecated("Deprecated in Java") override fun setDeviceMuted(muted: Boolean) = notAllowed() - - override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = notAllowed() - - override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) = notAllowed() - - override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) = notAllowed() - - override fun setVideoSurface(surface: Surface?) = notAllowed() - - override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() - - override fun setVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() - - override fun setVideoTextureView(textureView: TextureView?) = notAllowed() - - override fun clearVideoSurface() = notAllowed() - - override fun clearVideoSurface(surface: Surface?) = notAllowed() - - override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() - - override fun clearVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() - - override fun clearVideoTextureView(textureView: TextureView?) = notAllowed() - - private fun notAllowed(): Nothing { - logD("MediaSession unexpectedly called this method") - logE(Exception().stackTraceToString()) - error("MediaSession unexpectedly called this method") - } -} - -fun Player.unscrambleQueueIndices(): List { - val timeline = currentTimeline - if (timeline.isEmpty) { - return emptyList() - } - val queue = mutableListOf() - - // Add the active queue item. - val currentMediaItemIndex = currentMediaItemIndex - queue.add(currentMediaItemIndex) - - // Fill queue alternating with next and/or previous queue items. - var firstMediaItemIndex = currentMediaItemIndex - var lastMediaItemIndex = currentMediaItemIndex - val shuffleModeEnabled = shuffleModeEnabled - while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { - // Begin with next to have a longer tail than head if an even sized queue needs to be - // trimmed. - if (lastMediaItemIndex != C.INDEX_UNSET) { - lastMediaItemIndex = - timeline.getNextWindowIndex( - lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (lastMediaItemIndex != C.INDEX_UNSET) { - queue.add(lastMediaItemIndex) - } - } - if (firstMediaItemIndex != C.INDEX_UNSET) { - firstMediaItemIndex = - timeline.getPreviousWindowIndex( - firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (firstMediaItemIndex != C.INDEX_UNSET) { - queue.add(0, firstMediaItemIndex) - } - } - } - - return queue -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 91d6d8b77..26c1eea01 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -109,30 +109,8 @@ constructor( foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } - // override fun onConnect( - // session: MediaSession, - // controller: MediaSession.ControllerInfo - // ): ConnectionResult { - // val sessionCommands = - // actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS) - // return ConnectionResult.AcceptedResultBuilder(session) - // .setAvailableSessionCommands(sessionCommands) - // .setCustomLayout(actionHandler.createCustomLayout()) - // .build() - // } - // - // override fun onCustomCommand( - // session: MediaSession, - // controller: MediaSession.ControllerInfo, - // customCommand: SessionCommand, - // args: Bundle - // ): ListenableFuture = - // if (actionHandler.handleCommand(customCommand)) { - // Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - // } else { - // super.onCustomCommand(session, controller, customCommand, args) - // } - // + + // override fun onGetLibraryRoot( // session: MediaLibrarySession, // browser: MediaSession.ControllerInfo, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 886e5ce55..8bdd5fa2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,6 +163,7 @@ Reset Add + More Path style Absolute From b1e871c6e129513801a31ac0981804f2918f8b02 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 16:12:41 -0600 Subject: [PATCH 25/87] music: re-add music browsing --- .../java/org/oxycblt/auxio/AuxioService.kt | 43 +-- .../oxycblt/auxio/music/service/Indexer.kt | 146 +++++++++ .../music/service/MediaItemTranslation.kt | 56 ++-- .../auxio/music/service/MusicBrowser.kt | 296 +++++++----------- .../music/service/MusicServiceFragment.kt | 168 ++++------ 5 files changed, 383 insertions(+), 326 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 777cb5ddb..b1a9da89b 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -23,7 +23,6 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.IBinder -import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.annotation.StringRes import androidx.core.app.NotificationChannelCompat @@ -37,16 +36,16 @@ import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @AndroidEntryPoint -class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { - @Inject lateinit var mediaSessionFragment: PlaybackServiceFragment +class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { + @Inject lateinit var playbackFragment: PlaybackServiceFragment - @Inject lateinit var indexingFragment: MusicServiceFragment + @Inject lateinit var musicFragment: MusicServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { super.onCreate() - setSessionToken(mediaSessionFragment.attach(this)) - indexingFragment.attach(this) + sessionToken = playbackFragment.attach(this) + musicFragment.attach(this, this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -63,26 +62,31 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { private fun onHandleForeground(intent: Intent?) { val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1 - indexingFragment.start() - mediaSessionFragment.start(startId) + musicFragment.start() + playbackFragment.start(startId) } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) - mediaSessionFragment.handleTaskRemoved() + playbackFragment.handleTaskRemoved() } override fun onDestroy() { super.onDestroy() - indexingFragment.release() - mediaSessionFragment.release() + musicFragment.release() + playbackFragment.release() + sessionToken = null } override fun onGetRoot( clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot? = null + ): BrowserRoot = musicFragment.getRoot() + + override fun onLoadItem(itemId: String, result: Result) { + musicFragment.getItem(itemId, result) + } override fun onLoadChildren( parentId: String, @@ -93,13 +97,8 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { parentId: String, result: Result>, options: Bundle - ) { - super.onLoadChildren(parentId, result, options) - } + ) = musicFragment.getChildren(parentId, result) - override fun onLoadItem(itemId: String, result: Result) { - super.onLoadItem(itemId, result) - } override fun onSearch( query: String, @@ -120,7 +119,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { } override fun updateForeground(change: ForegroundListener.Change) { - val mediaNotification = mediaSessionFragment.notification + val mediaNotification = playbackFragment.notification if (mediaNotification != null) { if (change == ForegroundListener.Change.MEDIA_SESSION) { startForeground(mediaNotification.code, mediaNotification.build()) @@ -128,7 +127,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { // Nothing changed, but don't show anything music related since we can always // index during playback. } else { - indexingFragment.createNotification { + musicFragment.createNotification { if (it != null) { startForeground(it.code, it.build()) isForeground = true @@ -140,6 +139,10 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { } } + override fun invalidateMusic(mediaId: String) { + notifyChildrenChanged(mediaId) + } + companion object { var isForeground = false private set diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt new file mode 100644 index 000000000..398afdbb5 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2024 Auxio Project + * IndexerServiceFragment.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.service + +import android.content.Context +import android.os.PowerManager +import coil.ImageLoader +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.music.IndexingState +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.logD + +class Indexer +@Inject +constructor( + @ApplicationContext override val workerContext: Context, + private val playbackManager: PlaybackStateManager, + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings, + private val imageLoader: ImageLoader +) : + MusicRepository.IndexingWorker, + MusicRepository.IndexingListener, + MusicRepository.UpdateListener, + MusicSettings.Listener { + private val indexJob = Job() + private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) + private var currentIndexJob: Job? = null + private var foregroundListener: ForegroundListener? = null + private val wakeLock = + workerContext + .getSystemServiceCompat(PowerManager::class) + .newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") + + fun attach(listener: ForegroundListener) { + foregroundListener = listener + musicSettings.registerListener(this) + musicRepository.addUpdateListener(this) + musicRepository.addIndexingListener(this) + musicRepository.registerWorker(this) + } + + fun release() { + musicSettings.registerListener(this) + musicRepository.addIndexingListener(this) + musicRepository.addUpdateListener(this) + musicRepository.removeIndexingListener(this) + foregroundListener = null + } + + override fun requestIndex(withCache: Boolean) { + 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 = musicRepository.index(this, withCache) + } + + override val scope = indexScope + + override fun onIndexingStateChanged() { + foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + wakeLock.acquireSafe() + } else { + wakeLock.releaseSafe() + } + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + logD("Music changed, updating shared objects") + // Wipe possibly-invalidated outdated covers + imageLoader.memoryCache?.clear() + // Clear invalid models from PlaybackStateManager. This is not connected + // to a listener as it is bad practice for a shared object to attach to + // the listener system of another. + playbackManager.toSavedState()?.let { savedState -> + playbackManager.applySavedState( + savedState.copy( + heap = + savedState.heap.map { song -> + song?.let { deviceLibrary.findSong(it.uid) } + }), + true) + } + } + + override fun onIndexingSettingChanged() { + super.onIndexingSettingChanged() + musicRepository.requestIndex(true) + } + + /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ + private fun PowerManager.WakeLock.acquireSafe() { + // Avoid unnecessary acquire calls. + if (!wakeLock.isHeld) { + logD("Acquiring wake lock") + // Time out after a minute, which is the average music loading time for a medium-sized + // library. If this runs out, we will re-request the lock, and if music loading is + // shorter than the timeout, it will be released early. + acquire(WAKELOCK_TIMEOUT_MS) + } + } + + /** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */ + private fun PowerManager.WakeLock.releaseSafe() { + // Avoid unnecessary release calls. + if (wakeLock.isHeld) { + logD("Releasing wake lock") + release() + } + } + + companion object { + const val WAKELOCK_TIMEOUT_MS = 60 * 1000L + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 043aa0d46..afe6c3eda 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -19,16 +19,13 @@ package org.oxycblt.auxio.music.service import android.content.Context -import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.media.utils.MediaConstants -import androidx.media3.common.MediaMetadata import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album @@ -40,8 +37,6 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.getPlural -import java.io.ByteArrayOutputStream -import kotlin.math.ceil enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) { ROOT("root", R.string.info_app_name, null), @@ -109,9 +104,15 @@ sealed interface MediaSessionUID { } } +typealias Sugar = Bundle.(Context) -> Unit + +fun header(@StringRes nameRes: Int): Sugar = { + putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) +} + fun Category.toMediaItem(context: Context): MediaItem { // TODO: Make custom overflow menu for compat - val style = + val extras = Bundle().apply { putInt( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, @@ -121,32 +122,41 @@ fun Category.toMediaItem(context: Context): MediaItem { val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(context.getString(nameRes)) + .setExtras(extras) if (bitmapRes != null) { val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes) description.setIconBitmap(bitmap) } return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } -fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { + +fun Song.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar: Sugar): MediaItem { + val mediaSessionUID = + if (parent == null) { + MediaSessionUID.SingleItem(uid) + } else { + MediaSessionUID.ChildItem(parent.uid, uid) + } + val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setDescription(album.name.resolve(context)) + .setIconUri(album.cover.single.mediaStoreCoverUri) + .setMediaUri(uri) + .setExtras(extras) + .build() + return MediaItem(description, MediaItem.FLAG_PLAYABLE) +} + +fun Album.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar: Sugar): MediaItem { val mediaSessionUID = if (parent == null) { MediaSessionUID.SingleItem(uid) } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setDescription(album.name.resolve(context)) - .setIconUri(album.cover.single.mediaStoreCoverUri) - .setMediaUri(uri) - .build() - return MediaItem(description, MediaItem.FLAG_PLAYABLE) -} - -fun Album.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.SingleItem(uid) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) @@ -156,7 +166,7 @@ fun Album.toMediaItem(context: Context): MediaItem { return MediaItem(description, MediaItem.FLAG_BROWSABLE) } -fun Artist.toMediaItem(context: Context): MediaItem { +fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = context.getString( @@ -180,7 +190,7 @@ fun Artist.toMediaItem(context: Context): MediaItem { return MediaItem(description, MediaItem.FLAG_BROWSABLE) } -fun Genre.toMediaItem(context: Context): MediaItem { +fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = if (songs.isNotEmpty()) { @@ -197,7 +207,7 @@ fun Genre.toMediaItem(context: Context): MediaItem { return MediaItem(description, MediaItem.FLAG_BROWSABLE) } -fun Playlist.toMediaItem(context: Context): MediaItem { +fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = if (songs.isNotEmpty()) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 62238c5b9..542095a90 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -45,100 +45,71 @@ import org.oxycblt.auxio.search.SearchEngine import javax.inject.Inject import kotlin.math.min -class MediaItemBrowser +class MusicBrowser @Inject constructor( @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, private val listSettings: ListSettings ) : MusicRepository.UpdateListener { - private val browserJob = Job() - private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) - private val searchSubscribers = mutableMapOf() - private val searchResults = mutableMapOf>() - private var invalidator: Invalidator? = null - interface Invalidator { - fun invalidate(ids: Map) - - fun invalidate(controller: String, query: String, itemCount: Int) + fun invalidateMusic(ids: Set) } + private var invalidator: Invalidator? = null + fun attach(invalidator: Invalidator) { this.invalidator = invalidator musicRepository.addUpdateListener(this) } fun release() { - browserJob.cancel() - invalidator = null musicRepository.removeUpdateListener(this) } override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary - var invalidateSearch = false - val invalidate = mutableMapOf() + val invalidate = mutableSetOf() if (changes.deviceLibrary && deviceLibrary != null) { - MediaSessionUID.Category.DEVICE_MUSIC.forEach { - invalidate[it.toString()] = getCategorySize(it, musicRepository) + Category.DEVICE_MUSIC.forEach { + invalidate.add(MediaSessionUID.CategoryItem(it).toString()) } deviceLibrary.albums.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate[id] = it.songs.size + invalidate.add(id) } deviceLibrary.artists.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size + invalidate.add(id) } deviceLibrary.genres.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate[id] = it.songs.size + it.artists.size + invalidate.add(id) } - - invalidateSearch = true } val userLibrary = musicRepository.userLibrary if (changes.userLibrary && userLibrary != null) { - MediaSessionUID.Category.USER_MUSIC.forEach { - invalidate[it.toString()] = getCategorySize(it, musicRepository) + Category.USER_MUSIC.forEach { + invalidate.add(MediaSessionUID.CategoryItem(it).toString()) } userLibrary.playlists.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate[id] = it.songs.size + invalidate.add(id) } - invalidateSearch = true } if (invalidate.isNotEmpty()) { - invalidator?.invalidate(invalidate) - } - - if (invalidateSearch) { - for (entry in searchResults.entries) { - searchResults[entry.key]?.cancel() - } - searchResults.clear() - - for (entry in searchSubscribers.entries) { - if (searchResults[entry.value] != null) { - continue - } - searchResults[entry.value] = searchTo(entry.value) - } + invalidator?.invalidateMusic(invalidate) } } - val root: MediaItem - get() = MediaSessionUID.Category.ROOT.toMediaItem(context) - fun getItem(mediaId: String): MediaItem? { val music = when (val uid = MediaSessionUID.fromString(mediaId)) { - is MediaSessionUID.Category -> return uid.toMediaItem(context) + is MediaSessionUID.CategoryItem -> return uid.category.toMediaItem(context) is MediaSessionUID.SingleItem -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } @@ -158,15 +129,14 @@ constructor( } } - fun getChildren(parentId: String, page: Int, pageSize: Int): List? { + fun getChildren(parentId: String): List? { val deviceLibrary = musicRepository.deviceLibrary val userLibrary = musicRepository.userLibrary if (deviceLibrary == null || userLibrary == null) { return listOf() } - val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null - return items.paginate(page, pageSize) + return getMediaItemList(parentId, deviceLibrary, userLibrary) } private fun getMediaItemList( @@ -175,32 +145,34 @@ constructor( userLibrary: UserLibrary ): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { - is MediaSessionUID.Category -> { - when (mediaSessionUID) { - MediaSessionUID.Category.ROOT -> - MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } + is MediaSessionUID.CategoryItem -> { + when (mediaSessionUID.category) { + Category.ROOT -> + Category.IMPORTANT.map { it.toMediaItem(context) } - MediaSessionUID.Category.SONGS -> + Category.MORE -> TODO() + + Category.SONGS -> listSettings.songSort.songs(deviceLibrary.songs).map { it.toMediaItem(context, null) } - MediaSessionUID.Category.ALBUMS -> + Category.ALBUMS -> listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } - MediaSessionUID.Category.ARTISTS -> + Category.ARTISTS -> listSettings.artistSort.artists(deviceLibrary.artists).map { it.toMediaItem(context) } - MediaSessionUID.Category.GENRES -> + Category.GENRES -> listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } - MediaSessionUID.Category.PLAYLISTS -> + Category.PLAYLISTS -> userLibrary.playlists.map { it.toMediaItem(context) } } } @@ -223,25 +195,25 @@ constructor( return when (val item = musicRepository.find(uid)) { is Album -> { val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs))} } is Artist -> { val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val songs = listSettings.artistSongSort.songs(item.songs) - albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + - songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + albums.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } + + songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } } is Genre -> { val artists = GENRE_ARTISTS_SORT.artists(item.artists) val songs = listSettings.genreSongSort.songs(item.songs) - artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + - songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } + artists.map { it.toMediaItem(context, header(R.string.lbl_songs)) } + + songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } } is Playlist -> { - item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + item.songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } } is Song, @@ -249,121 +221,91 @@ constructor( } } - private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { - val oldExtras = mediaMetadata.extras ?: Bundle() - val newExtras = - Bundle(oldExtras).apply { - putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - context.getString(res) - ) - } - return buildUpon() - .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) - .build() - } - - private fun getCategorySize( - category: MediaSessionUID.Category, - musicRepository: MusicRepository - ): Int { - val deviceLibrary = musicRepository.deviceLibrary ?: return 0 - val userLibrary = musicRepository.userLibrary ?: return 0 - return when (category) { - MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size - MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size - MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size - MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size - MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size - MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size - } - } - - suspend fun prepareSearch(query: String, controller: ControllerInfo) { - searchSubscribers[controller] = query - val existing = searchResults[query] - if (existing == null) { - val new = searchTo(query) - searchResults[query] = new - new.await() - } else { - val items = existing.await() - invalidator?.invalidate(controller, query, items.count()) - } - } - - suspend fun getSearchResult( - query: String, - page: Int, - pageSize: Int, - ): List? { - val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } - return deferred.await().concat().paginate(page, pageSize) - } - - private fun SearchEngine.Items.concat(): MutableList { - val music = mutableListOf() - if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null) }) - } - if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context) }) - } - if (artists != null) { - music.addAll(artists.map { it.toMediaItem(context) }) - } - if (genres != null) { - music.addAll(genres.map { it.toMediaItem(context) }) - } - if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(context) }) - } - return music - } - - private fun SearchEngine.Items.count(): Int { - var count = 0 - if (songs != null) { - count += songs.size - } - if (albums != null) { - count += albums.size - } - if (artists != null) { - count += artists.size - } - if (genres != null) { - count += genres.size - } - if (playlists != null) { - count += playlists.size - } - return count - } - - private fun searchTo(query: String) = - searchScope.async { - if (query.isEmpty()) { - return@async SearchEngine.Items() - } - val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() - val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() - val items = - SearchEngine.Items( - deviceLibrary.songs, - deviceLibrary.albums, - deviceLibrary.artists, - deviceLibrary.genres, - userLibrary.playlists - ) - val results = searchEngine.search(items, query) - for (entry in searchSubscribers.entries) { - if (entry.value == query) { - invalidator?.invalidate(entry.key, query, results.count()) - } - } - results - } +// suspend fun prepareSearch(query: String, controller: String) { +// searchSubscribers[controller] = query +// val existing = searchResults[query] +// if (existing == null) { +// val new = searchTo(query) +// searchResults[query] = new +// new.await() +// } else { +// val items = existing.await() +// invalidator?.invalidate(controller, query, items.count()) +// } +// } +// +// suspend fun getSearchResult( +// query: String, +// page: Int, +// pageSize: Int, +// ): List? { +// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } +// return deferred.await().concat().paginate(page, pageSize) +// } +// +// private fun SearchEngine.Items.concat(): MutableList { +// val music = mutableListOf() +// if (songs != null) { +// music.addAll(songs.map { it.toMediaItem(context, null) }) +// } +// if (albums != null) { +// music.addAll(albums.map { it.toMediaItem(context) }) +// } +// if (artists != null) { +// music.addAll(artists.map { it.toMediaItem(context) }) +// } +// if (genres != null) { +// music.addAll(genres.map { it.toMediaItem(context) }) +// } +// if (playlists != null) { +// music.addAll(playlists.map { it.toMediaItem(context) }) +// } +// return music +// } +// +// private fun SearchEngine.Items.count(): Int { +// var count = 0 +// if (songs != null) { +// count += songs.size +// } +// if (albums != null) { +// count += albums.size +// } +// if (artists != null) { +// count += artists.size +// } +// if (genres != null) { +// count += genres.size +// } +// if (playlists != null) { +// count += playlists.size +// } +// return count +// } +// +// private fun searchTo(query: String) = +// searchScope.async { +// if (query.isEmpty()) { +// return@async SearchEngine.Items() +// } +// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() +// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() +// val items = +// SearchEngine.Items( +// deviceLibrary.songs, +// deviceLibrary.albums, +// deviceLibrary.artists, +// deviceLibrary.genres, +// userLibrary.playlists +// ) +// val results = searchEngine.search(items, query) +// for (entry in searchSubscribers.entries) { +// if (entry.value == query) { +// invalidator?.invalidate(entry.key, query, results.count()) +// } +// } +// results +// } private fun List.paginate(page: Int, pageSize: Int): List? { if (page == Int.MAX_VALUE) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 4616daf13..ae7f8a3ff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -19,7 +19,12 @@ package org.oxycblt.auxio.music.service import android.content.Context +import android.os.Bundle import android.os.PowerManager +import androidx.media.MediaBrowserServiceCompat.BrowserRoot +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import android.support.v4.media.MediaBrowserCompat.MediaItem import coil.ImageLoader import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -35,54 +40,65 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW class MusicServiceFragment @Inject constructor( - @ApplicationContext override val workerContext: Context, - private val playbackManager: PlaybackStateManager, + @ApplicationContext context: Context, + private val indexer: Indexer, + private val browser: MusicBrowser, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, private val contentObserver: SystemContentObserver, - private val imageLoader: ImageLoader -) : - MusicRepository.IndexingWorker, - MusicRepository.IndexingListener, - MusicRepository.UpdateListener, - MusicSettings.Listener { - private val indexJob = Job() - private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) - private var currentIndexJob: Job? = null - private val indexingNotification = IndexingNotification(workerContext) - private val observingNotification = ObservingNotification(workerContext) +) : MusicBrowser.Invalidator, MusicSettings.Listener { + private val indexingNotification = IndexingNotification(context) + private val observingNotification = ObservingNotification(context) + private var invalidator: Invalidator? = null private var foregroundListener: ForegroundListener? = null - private val wakeLock = - workerContext - .getSystemServiceCompat(PowerManager::class) - .newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") - fun attach(listener: ForegroundListener) { - foregroundListener = listener - musicSettings.registerListener(this) - musicRepository.addUpdateListener(this) - musicRepository.addIndexingListener(this) - musicRepository.registerWorker(this) + interface Invalidator { + fun invalidateMusic(mediaId: String) + } + + fun attach(foregroundListener: ForegroundListener, invalidator: Invalidator) { + this.invalidator = invalidator + indexer.attach(foregroundListener) + browser.attach(this) contentObserver.attach() + musicSettings.registerListener(this) } fun release() { + musicSettings.unregisterListener(this) contentObserver.release() - musicSettings.registerListener(this) - musicRepository.addIndexingListener(this) - musicRepository.addUpdateListener(this) - musicRepository.removeIndexingListener(this) - foregroundListener = null + browser.release() + indexer.release() + invalidator = null + } + + + override fun invalidateMusic(ids: Set) { + ids.forEach { mediaId -> + requireNotNull(invalidator) { "Invalidator not available" }.invalidateMusic(mediaId) + } + } + + override fun onObservingChanged() { + super.onObservingChanged() + // Make sure we don't override the service state with the observing + // notification if we were actively loading when the automatic rescanning + // setting changed. In such a case, the state will still be updated when + // the music loading process ends. + if (musicRepository.indexingState == null) { + logD("Not loading, updating idle session") + foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + } } fun start() { if (musicRepository.indexingState == null) { - requestIndex(true) + musicRepository.requestIndex(true) } } @@ -108,84 +124,24 @@ constructor( } } - override fun requestIndex(withCache: Boolean) { - 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 = musicRepository.index(this, withCache) - } + fun getRoot() = BrowserRoot(Category.ROOT.id, null) - override val scope = indexScope + fun getItem(mediaId: String, result: MediaBrowserServiceCompat.Result) = + result.dispatch { browser.getItem(mediaId) } - override fun onIndexingStateChanged() { - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) - val state = musicRepository.indexingState - if (state is IndexingState.Indexing) { - wakeLock.acquireSafe() - } else { - wakeLock.releaseSafe() + fun getChildren(mediaId: String, result: MediaBrowserServiceCompat.Result>) = + result.dispatch { browser.getChildren(mediaId)?.toMutableList() } + + private fun MediaBrowserServiceCompat.Result.dispatch(body: () -> T?) { + try { + val result = body() + if (result == null) { + logW("Result is null") + } + sendResult(result) + } catch (e: Exception) { + logD("Error while dispatching: $e") + sendResult(null) } } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - logD("Music changed, updating shared objects") - // Wipe possibly-invalidated outdated covers - imageLoader.memoryCache?.clear() - // Clear invalid models from PlaybackStateManager. This is not connected - // to a listener as it is bad practice for a shared object to attach to - // the listener system of another. - playbackManager.toSavedState()?.let { savedState -> - playbackManager.applySavedState( - savedState.copy( - heap = - savedState.heap.map { song -> - song?.let { deviceLibrary.findSong(it.uid) } - }), - true) - } - } - - override fun onIndexingSettingChanged() { - super.onIndexingSettingChanged() - musicRepository.requestIndex(true) - } - - override fun onObservingChanged() { - super.onObservingChanged() - // Make sure we don't override the service state with the observing - // notification if we were actively loading when the automatic rescanning - // setting changed. In such a case, the state will still be updated when - // the music loading process ends. - if (currentIndexJob == null) { - logD("Not loading, updating idle session") - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) - } - } - - /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ - private fun PowerManager.WakeLock.acquireSafe() { - // Avoid unnecessary acquire calls. - if (!wakeLock.isHeld) { - logD("Acquiring wake lock") - // Time out after a minute, which is the average music loading time for a medium-sized - // library. If this runs out, we will re-request the lock, and if music loading is - // shorter than the timeout, it will be released early. - acquire(WAKELOCK_TIMEOUT_MS) - } - } - - /** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */ - private fun PowerManager.WakeLock.releaseSafe() { - // Avoid unnecessary release calls. - if (wakeLock.isHeld) { - logD("Releasing wake lock") - release() - } - } - - companion object { - const val WAKELOCK_TIMEOUT_MS = 60 * 1000L - } } From 924e3d1801311e7031bd96ac6b467383824c1d3e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 16:46:44 -0600 Subject: [PATCH 26/87] music: re-add search browsing --- .../java/org/oxycblt/auxio/AuxioService.kt | 2 +- .../auxio/music/service/MusicBrowser.kt | 15 --- .../music/service/MusicServiceFragment.kt | 93 +++++++++++++++---- .../org/oxycblt/auxio/search/SearchEngine.kt | 4 +- 4 files changed, 79 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index b1a9da89b..c93006f18 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -105,7 +105,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServi extras: Bundle?, result: Result> ) { - super.onSearch(query, extras, result) + musicFragment.search(query, result) } @SuppressLint("RestrictedApi") diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 542095a90..5a2a37c05 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -307,21 +307,6 @@ constructor( // results // } - private fun List.paginate(page: Int, pageSize: Int): List? { - if (page == Int.MAX_VALUE) { - // I think if someone requests this page it more or less implies that I should - // return all of the pages. - return this - } - val start = page * pageSize - val end = min((page + 1) * pageSize, size) // Tolerate partial page queries - if (pageSize == 0 || start !in indices) { - // These pages are probably invalid. Hopefully this won't backfire. - return null - } - return subList(start, end).toMutableList() - } - private companion object { // TODO: Rely on detail item gen logic? val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index ae7f8a3ff..9dca63e64 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -15,47 +15,45 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.music.service import android.content.Context -import android.os.Bundle -import android.os.PowerManager -import androidx.media.MediaBrowserServiceCompat.BrowserRoot -import androidx.media.MediaBrowserServiceCompat -import androidx.media.utils.MediaConstants import android.support.v4.media.MediaBrowserCompat.MediaItem -import coil.ImageLoader +import androidx.media.MediaBrowserServiceCompat +import androidx.media.MediaBrowserServiceCompat.BrowserRoot import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import org.oxycblt.auxio.BuildConfig +import kotlinx.coroutines.launch import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.search.SearchEngine import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW +import javax.inject.Inject class MusicServiceFragment @Inject constructor( - @ApplicationContext context: Context, + @ApplicationContext private val context: Context, private val indexer: Indexer, - private val browser: MusicBrowser, + private val musicBrowser: MusicBrowser, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, + private val searchEngine: SearchEngine, private val contentObserver: SystemContentObserver, ) : MusicBrowser.Invalidator, MusicSettings.Listener { private val indexingNotification = IndexingNotification(context) private val observingNotification = ObservingNotification(context) private var invalidator: Invalidator? = null private var foregroundListener: ForegroundListener? = null + private val dispatchJob = Job() + private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) interface Invalidator { fun invalidateMusic(mediaId: String) @@ -64,7 +62,7 @@ constructor( fun attach(foregroundListener: ForegroundListener, invalidator: Invalidator) { this.invalidator = invalidator indexer.attach(foregroundListener) - browser.attach(this) + musicBrowser.attach(this) contentObserver.attach() musicSettings.registerListener(this) } @@ -72,7 +70,8 @@ constructor( fun release() { musicSettings.unregisterListener(this) contentObserver.release() - browser.release() + dispatchJob.cancel() + musicBrowser.release() indexer.release() invalidator = null } @@ -127,10 +126,53 @@ constructor( fun getRoot() = BrowserRoot(Category.ROOT.id, null) fun getItem(mediaId: String, result: MediaBrowserServiceCompat.Result) = - result.dispatch { browser.getItem(mediaId) } + result.dispatch { musicBrowser.getItem(mediaId) } - fun getChildren(mediaId: String, result: MediaBrowserServiceCompat.Result>) = - result.dispatch { browser.getChildren(mediaId)?.toMutableList() } + fun getChildren( + mediaId: String, + result: MediaBrowserServiceCompat.Result> + ) = + result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } + + fun search(query: String, result: MediaBrowserServiceCompat.Result>) = + result.dispatchAsync { + if (query.isEmpty()) { + return@dispatchAsync mutableListOf() + } + val deviceLibrary = + musicRepository.deviceLibrary ?: return@dispatchAsync mutableListOf() + val userLibrary = musicRepository.userLibrary ?: return@dispatchAsync mutableListOf() + val items = + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists + ) + searchEngine.search(items, query).concat() + } + + + private fun SearchEngine.Items.concat(): MutableList { + val music = mutableListOf() + if (songs != null) { + music.addAll(songs.map { it.toMediaItem(context, null) }) + } + if (albums != null) { + music.addAll(albums.map { it.toMediaItem(context) }) + } + if (artists != null) { + music.addAll(artists.map { it.toMediaItem(context) }) + } + if (genres != null) { + music.addAll(genres.map { it.toMediaItem(context) }) + } + if (playlists != null) { + music.addAll(playlists.map { it.toMediaItem(context) }) + } + return music + } private fun MediaBrowserServiceCompat.Result.dispatch(body: () -> T?) { try { @@ -144,4 +186,19 @@ constructor( sendResult(null) } } + + private fun MediaBrowserServiceCompat.Result.dispatchAsync(body: suspend () -> T?) { + dispatchScope.launch { + try { + val result = body() + if (result == null) { + logW("Result is null") + } + sendResult(result) + } catch (e: Exception) { + logD("Error while dispatching: $e") + sendResult(null) + } + } + } } 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 7853bcca3..3c8565f68 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -61,7 +61,9 @@ interface SearchEngine { val artists: Collection? = null, val genres: Collection? = null, val playlists: Collection? = null - ) + ) { + + } } class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) : From f0dda6c43ee5d1c4738a43d45def7a29fdbd1544 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 16:48:56 -0600 Subject: [PATCH 27/87] all: cleanup --- .../java/org/oxycblt/auxio/AuxioService.kt | 16 +- .../oxycblt/auxio/music/service/Indexer.kt | 3 +- .../music/service/MediaItemTranslation.kt | 160 ++++++++++-------- .../auxio/music/service/MusicBrowser.kt | 127 +------------- .../music/service/MusicServiceFragment.kt | 14 +- .../service/ExoPlaybackStateHolder.kt | 14 +- .../playback/service/MediaSessionHolder.kt | 34 ++-- .../service/PlaybackServiceFragment.kt | 4 +- .../org/oxycblt/auxio/search/SearchEngine.kt | 4 +- 9 files changed, 134 insertions(+), 242 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index c93006f18..09d6fd379 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -36,7 +36,8 @@ import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @AndroidEntryPoint -class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { +class AuxioService : + MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { @Inject lateinit var playbackFragment: PlaybackServiceFragment @Inject lateinit var musicFragment: MusicServiceFragment @@ -88,10 +89,8 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServi musicFragment.getItem(itemId, result) } - override fun onLoadChildren( - parentId: String, - result: Result> - ) = throw NotImplementedError() + override fun onLoadChildren(parentId: String, result: Result>) = + throw NotImplementedError() override fun onLoadChildren( parentId: String, @@ -99,12 +98,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServi options: Bundle ) = musicFragment.getChildren(parentId, result) - - override fun onSearch( - query: String, - extras: Bundle?, - result: Result> - ) { + override fun onSearch(query: String, extras: Bundle?, result: Result>) { musicFragment.search(query, result) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index 398afdbb5..427f653ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * IndexerServiceFragment.kt is part of Auxio. + * Indexer.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 @@ -28,7 +28,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.ForegroundListener -import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index afe6c3eda..1fc825185 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -78,16 +78,17 @@ sealed interface MediaSessionUID { } return when (parts[0]) { ID_CATEGORY -> - CategoryItem(when (parts[1]) { - Category.ROOT.id -> Category.ROOT - Category.MORE.id -> Category.MORE - Category.SONGS.id -> Category.SONGS - Category.ALBUMS.id -> Category.ALBUMS - Category.ARTISTS.id -> Category.ARTISTS - Category.GENRES.id -> Category.GENRES - Category.PLAYLISTS.id -> Category.PLAYLISTS - else -> return null - }) + CategoryItem( + when (parts[1]) { + Category.ROOT.id -> Category.ROOT + Category.MORE.id -> Category.MORE + Category.SONGS.id -> Category.SONGS + Category.ALBUMS.id -> Category.ALBUMS + Category.ARTISTS.id -> Category.ARTISTS + Category.GENRES.id -> Category.GENRES + Category.PLAYLISTS.id -> Category.PLAYLISTS + else -> return null + }) ID_ITEM -> { val uids = parts[1].split(">", limit = 2) if (uids.size == 1) { @@ -107,7 +108,8 @@ sealed interface MediaSessionUID { typealias Sugar = Bundle.(Context) -> Unit fun header(@StringRes nameRes: Int): Sugar = { - putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) } fun Category.toMediaItem(context: Context): MediaItem { @@ -119,10 +121,11 @@ fun Category.toMediaItem(context: Context): MediaItem { MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) } val mediaSessionUID = MediaSessionUID.CategoryItem(this) - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(context.getString(nameRes)) - .setExtras(extras) + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(context.getString(nameRes)) + .setExtras(extras) if (bitmapRes != null) { val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes) description.setIconBitmap(bitmap) @@ -130,7 +133,11 @@ fun Category.toMediaItem(context: Context): MediaItem { return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } -fun Song.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar: Sugar): MediaItem { +fun Song.toMediaItem( + context: Context, + parent: MusicParent? = null, + vararg sugar: Sugar +): MediaItem { val mediaSessionUID = if (parent == null) { MediaSessionUID.SingleItem(uid) @@ -138,88 +145,97 @@ fun Song.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar MediaSessionUID.ChildItem(parent.uid, uid) } val extras = Bundle().apply { sugar.forEach { this.it(context) } } - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setDescription(album.name.resolve(context)) - .setIconUri(album.cover.single.mediaStoreCoverUri) - .setMediaUri(uri) - .setExtras(extras) - .build() + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setDescription(album.name.resolve(context)) + .setIconUri(album.cover.single.mediaStoreCoverUri) + .setMediaUri(uri) + .setExtras(extras) + .build() return MediaItem(description, MediaItem.FLAG_PLAYABLE) } -fun Album.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar: Sugar): MediaItem { +fun Album.toMediaItem( + context: Context, + parent: MusicParent? = null, + vararg sugar: Sugar +): MediaItem { val mediaSessionUID = if (parent == null) { MediaSessionUID.SingleItem(uid) } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setIconUri(cover.single.mediaStoreCoverUri) - .build() + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = - context.getString( - R.string.fmt_two, - if (explicitAlbums.isNotEmpty()) { - context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) - } else { - context.getString(R.string.def_album_count) - }, - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - }) - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(counts) - .setIconUri(cover.single.mediaStoreCoverUri) - .build() + context.getString( + R.string.fmt_two, + if (explicitAlbums.isNotEmpty()) { + context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) + } else { + context.getString(R.string.def_album_count) + }, + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + }) + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - } - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(counts) - .setIconUri(cover.single.mediaStoreCoverUri) - .build() + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + } + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - } - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(counts) - .setIconUri(cover?.single?.mediaStoreCoverUri) - .build() + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + } + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover?.single?.mediaStoreCoverUri) + .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 5a2a37c05..7391632fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -15,20 +15,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.music.service import android.content.Context -import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem -import androidx.annotation.StringRes -import androidx.media.utils.MediaConstants import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async +import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.sort.Sort @@ -41,9 +34,6 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.user.UserLibrary -import org.oxycblt.auxio.search.SearchEngine -import javax.inject.Inject -import kotlin.math.min class MusicBrowser @Inject @@ -112,10 +102,8 @@ constructor( is MediaSessionUID.CategoryItem -> return uid.category.toMediaItem(context) is MediaSessionUID.SingleItem -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - is MediaSessionUID.ChildItem -> musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } - null -> null } ?: return null @@ -147,44 +135,33 @@ constructor( return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { is MediaSessionUID.CategoryItem -> { when (mediaSessionUID.category) { - Category.ROOT -> - Category.IMPORTANT.map { it.toMediaItem(context) } - + Category.ROOT -> Category.IMPORTANT.map { it.toMediaItem(context) } Category.MORE -> TODO() - Category.SONGS -> listSettings.songSort.songs(deviceLibrary.songs).map { it.toMediaItem(context, null) } - Category.ALBUMS -> listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } - Category.ARTISTS -> listSettings.artistSort.artists(deviceLibrary.artists).map { it.toMediaItem(context) } - Category.GENRES -> listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } - - Category.PLAYLISTS -> - userLibrary.playlists.map { it.toMediaItem(context) } + Category.PLAYLISTS -> userLibrary.playlists.map { it.toMediaItem(context) } } } - is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) } - is MediaSessionUID.ChildItem -> { getChildMediaItems(mediaSessionUID.childUid) } - null -> { return null } @@ -195,118 +172,28 @@ constructor( return when (val item = musicRepository.find(uid)) { is Album -> { val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs))} + songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } } - is Artist -> { val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val songs = listSettings.artistSongSort.songs(item.songs) albums.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } + - songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } + songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } } - is Genre -> { val artists = GENRE_ARTISTS_SORT.artists(item.artists) val songs = listSettings.genreSongSort.songs(item.songs) artists.map { it.toMediaItem(context, header(R.string.lbl_songs)) } + - songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } + songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } } - is Playlist -> { item.songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } } - is Song, null -> return null } } -// suspend fun prepareSearch(query: String, controller: String) { -// searchSubscribers[controller] = query -// val existing = searchResults[query] -// if (existing == null) { -// val new = searchTo(query) -// searchResults[query] = new -// new.await() -// } else { -// val items = existing.await() -// invalidator?.invalidate(controller, query, items.count()) -// } -// } -// -// suspend fun getSearchResult( -// query: String, -// page: Int, -// pageSize: Int, -// ): List? { -// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } -// return deferred.await().concat().paginate(page, pageSize) -// } -// -// private fun SearchEngine.Items.concat(): MutableList { -// val music = mutableListOf() -// if (songs != null) { -// music.addAll(songs.map { it.toMediaItem(context, null) }) -// } -// if (albums != null) { -// music.addAll(albums.map { it.toMediaItem(context) }) -// } -// if (artists != null) { -// music.addAll(artists.map { it.toMediaItem(context) }) -// } -// if (genres != null) { -// music.addAll(genres.map { it.toMediaItem(context) }) -// } -// if (playlists != null) { -// music.addAll(playlists.map { it.toMediaItem(context) }) -// } -// return music -// } -// -// private fun SearchEngine.Items.count(): Int { -// var count = 0 -// if (songs != null) { -// count += songs.size -// } -// if (albums != null) { -// count += albums.size -// } -// if (artists != null) { -// count += artists.size -// } -// if (genres != null) { -// count += genres.size -// } -// if (playlists != null) { -// count += playlists.size -// } -// return count -// } -// -// private fun searchTo(query: String) = -// searchScope.async { -// if (query.isEmpty()) { -// return@async SearchEngine.Items() -// } -// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() -// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() -// val items = -// SearchEngine.Items( -// deviceLibrary.songs, -// deviceLibrary.albums, -// deviceLibrary.artists, -// deviceLibrary.genres, -// userLibrary.playlists -// ) -// val results = searchEngine.search(items, query) -// for (entry in searchSubscribers.entries) { -// if (entry.value == query) { -// invalidator?.invalidate(entry.key, query, results.count()) -// } -// } -// results -// } - private companion object { // TODO: Rely on detail item gen logic? val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 9dca63e64..92ee54e60 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * IndexerServiceFragment.kt is part of Auxio. + * MusicServiceFragment.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 @@ -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.music.service import android.content.Context @@ -23,6 +23,7 @@ import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat.BrowserRoot import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -35,7 +36,6 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.search.SearchEngine import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW -import javax.inject.Inject class MusicServiceFragment @Inject @@ -76,7 +76,6 @@ constructor( invalidator = null } - override fun invalidateMusic(ids: Set) { ids.forEach { mediaId -> requireNotNull(invalidator) { "Invalidator not available" }.invalidateMusic(mediaId) @@ -131,8 +130,7 @@ constructor( fun getChildren( mediaId: String, result: MediaBrowserServiceCompat.Result> - ) = - result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } + ) = result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } fun search(query: String, result: MediaBrowserServiceCompat.Result>) = result.dispatchAsync { @@ -148,12 +146,10 @@ constructor( deviceLibrary.albums, deviceLibrary.artists, deviceLibrary.genres, - userLibrary.playlists - ) + userLibrary.playlists) searchEngine.search(items, query).concat() } - private fun SearchEngine.Items.concat(): MutableList { val music = mutableListOf() if (songs != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 1abc6bfb3..203176ebd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -46,7 +46,6 @@ import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.service.toMediaItem import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor @@ -141,10 +140,7 @@ class ExoPlaybackStateHolder( } else { emptyList() } - return RawQueue( - heap.mapNotNull { it.song }, - shuffledMapping, - player.currentMediaItemIndex) + return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex) } override fun handleDeferred(action: DeferredPlayback): Boolean { @@ -533,12 +529,10 @@ class ExoPlaybackStateHolder( currentSaveJob = saveScope.launch { block() } } - private fun Song.buildMediaItem() = MediaItem.Builder() - .setUri(uri) - .setTag(this) - .build() + private fun Song.buildMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build() - private val MediaItem.song: Song? get() = this.localConfiguration?.tag as? Song? + private val MediaItem.song: Song? + get() = this.localConfiguration?.tag as? Song? private fun Player.unscrambleQueueIndices(): List { val timeline = currentTimeline diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 63c281080..1cecbf938 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -88,14 +88,20 @@ private constructor( constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, - private val commandFactory: PlaybackCommand.Factory, + private val commandFactory: PlaybackCommand.Factory, private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings ) { fun create(context: Context) = MediaSessionHolder( - context, playbackManager, playbackSettings, commandFactory, musicRepository, bitmapProvider, imageSettings) + context, + playbackManager, + playbackSettings, + commandFactory, + musicRepository, + bitmapProvider, + imageSettings) } private val mediaSession = @@ -234,11 +240,13 @@ private constructor( super.onAddQueueItem(description) val deviceLibrary = musicRepository.deviceLibrary ?: return val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null - } ?: return + val song = + when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } + ?: return playbackManager.addToQueue(song) } @@ -246,11 +254,13 @@ private constructor( super.onRemoveQueueItem(description) val deviceLibrary = musicRepository.deviceLibrary ?: return val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null - } ?: return + val song = + when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } + ?: return val queueIndex = playbackManager.queue.indexOf(song) if (queueIndex > -1) { playbackManager.removeQueueItem(queueIndex) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 26c1eea01..b6ca0d721 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * MediaSessionServiceFragment.kt is part of Auxio. + * PlaybackServiceFragment.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 @@ -109,8 +109,6 @@ constructor( foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } - - // override fun onGetLibraryRoot( // session: MediaLibrarySession, // browser: MediaSession.ControllerInfo, 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 3c8565f68..32c6da8de 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -61,9 +61,7 @@ interface SearchEngine { val artists: Collection? = null, val genres: Collection? = null, val playlists: Collection? = null - ) { - - } + ) {} } class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) : From 35646d6a2dc093f126dee88e256cbd2b6277a1fe Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 16:50:54 -0600 Subject: [PATCH 28/87] playback: re-add headers to search --- .../auxio/music/service/MusicServiceFragment.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 92ee54e60..80c374b0e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.R import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings @@ -147,25 +148,25 @@ constructor( deviceLibrary.artists, deviceLibrary.genres, userLibrary.playlists) - searchEngine.search(items, query).concat() + searchEngine.search(items, query).toMediaItems() } - private fun SearchEngine.Items.concat(): MutableList { + private fun SearchEngine.Items.toMediaItems(): MutableList { val music = mutableListOf() if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null) }) + music.addAll(songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) }) } if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context) }) + music.addAll(albums.map { it.toMediaItem(context, null, header(R.string.lbl_albums)) }) } if (artists != null) { - music.addAll(artists.map { it.toMediaItem(context) }) + music.addAll(artists.map { it.toMediaItem(context, header(R.string.lbl_artists)) }) } if (genres != null) { - music.addAll(genres.map { it.toMediaItem(context) }) + music.addAll(genres.map { it.toMediaItem(context, header(R.string.lbl_genres)) }) } if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(context) }) + music.addAll(playlists.map { it.toMediaItem(context, header(R.string.lbl_playlists)) }) } return music } From f30c426c775c48adb3f881e47519cd34e6170544 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 16:52:22 -0600 Subject: [PATCH 29/87] music: apply headers to all mediaitems --- .../oxycblt/auxio/music/service/MediaItemTranslation.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 1fc825185..f86a539ec 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -169,12 +169,14 @@ fun Album.toMediaItem( } else { MediaSessionUID.ChildItem(parent.uid, uid) } + val extras = Bundle().apply { sugar.forEach { this.it(context) } } val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(artists.resolveNames(context)) .setIconUri(cover.single.mediaStoreCoverUri) + .setExtras(extras) .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } @@ -194,12 +196,14 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) }) + val extras = Bundle().apply { sugar.forEach { this.it(context) } } val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(counts) .setIconUri(cover.single.mediaStoreCoverUri) + .setExtras(extras) .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } @@ -212,6 +216,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } + val extras = Bundle().apply { sugar.forEach { this.it(context) } } val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -230,12 +235,14 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } + val extras = Bundle().apply { sugar.forEach { this.it(context) } } val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(counts) .setIconUri(cover?.single?.mediaStoreCoverUri) + .setExtras(extras) .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } From 30b3603cf1a9d6a6ec3ae52b1ed84158a1d4d491 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 08:42:14 -0600 Subject: [PATCH 30/87] music: move search/notif out of service fragment Generally cleaner this way --- .../oxycblt/auxio/music/service/Indexer.kt | 42 +++++++++- .../auxio/music/service/MusicBrowser.kt | 39 +++++++++ .../music/service/MusicServiceFragment.kt | 84 ++----------------- 3 files changed, 85 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index 427f653ea..4618a3fb2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings @@ -42,7 +43,8 @@ constructor( private val playbackManager: PlaybackStateManager, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, - private val imageLoader: ImageLoader + private val imageLoader: ImageLoader, + private val contentObserver: SystemContentObserver ) : MusicRepository.IndexingWorker, MusicRepository.IndexingListener, @@ -52,6 +54,8 @@ constructor( private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) private var currentIndexJob: Job? = null private var foregroundListener: ForegroundListener? = null + private val indexingNotification = IndexingNotification(workerContext) + private val observingNotification = ObservingNotification(workerContext) private val wakeLock = workerContext .getSystemServiceCompat(PowerManager::class) @@ -64,9 +68,11 @@ constructor( musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) musicRepository.registerWorker(this) + contentObserver.attach() } fun release() { + contentObserver.release() musicSettings.registerListener(this) musicRepository.addIndexingListener(this) musicRepository.addUpdateListener(this) @@ -118,6 +124,40 @@ constructor( musicRepository.requestIndex(true) } + override fun onObservingChanged() { + super.onObservingChanged() + // Make sure we don't override the service state with the observing + // notification if we were actively loading when the automatic rescanning + // setting changed. In such a case, the state will still be updated when + // the music loading process ends. + if (musicRepository.indexingState == null) { + logD("Not loading, updating idle session") + foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + } + } + + fun createNotification(post: (ForegroundServiceNotification?) -> Unit) { + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + // There are a few reasons why we stay in the foreground with automatic rescanning: + // 1. Newer versions of Android have become more and more restrictive regarding + // how a foreground service starts. Thus, it's best to go foreground now so that + // we can go foreground later. + // 2. If a non-foreground service is killed, the app will probably still be alive, + // and thus the music library will not be updated at all. + val changed = indexingNotification.updateIndexingState(state.progress) + if (changed) { + post(indexingNotification) + } + } else if (musicSettings.shouldBeObserving) { + // Not observing and done loading, exit foreground. + logD("Exiting foreground") + post(observingNotification) + } else { + post(null) + } + } + /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ private fun PowerManager.WakeLock.acquireSafe() { // Avoid unnecessary acquire calls. diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 7391632fd..eceff47d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -34,12 +34,14 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.search.SearchEngine class MusicBrowser @Inject constructor( @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine, private val listSettings: ListSettings ) : MusicRepository.UpdateListener { interface Invalidator { @@ -127,6 +129,43 @@ constructor( return getMediaItemList(parentId, deviceLibrary, userLibrary) } + suspend fun search(query: String): MutableList { + if (query.isEmpty()) { + return mutableListOf() + } + val deviceLibrary = + musicRepository.deviceLibrary ?: return mutableListOf() + val userLibrary = musicRepository.userLibrary ?: return mutableListOf() + val items = + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists) + return searchEngine.search(items, query).toMediaItems() + } + + private fun SearchEngine.Items.toMediaItems(): MutableList { + val music = mutableListOf() + if (songs != null) { + music.addAll(songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) }) + } + if (albums != null) { + music.addAll(albums.map { it.toMediaItem(context, null, header(R.string.lbl_albums)) }) + } + if (artists != null) { + music.addAll(artists.map { it.toMediaItem(context, header(R.string.lbl_artists)) }) + } + if (genres != null) { + music.addAll(genres.map { it.toMediaItem(context, header(R.string.lbl_genres)) }) + } + if (playlists != null) { + music.addAll(playlists.map { it.toMediaItem(context, header(R.string.lbl_playlists)) }) + } + return music + } + private fun getMediaItemList( id: String, deviceLibrary: DeviceLibrary, diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 80c374b0e..c1acf5144 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -41,18 +41,11 @@ import org.oxycblt.auxio.util.logW class MusicServiceFragment @Inject constructor( - @ApplicationContext private val context: Context, private val indexer: Indexer, private val musicBrowser: MusicBrowser, - private val musicRepository: MusicRepository, - private val musicSettings: MusicSettings, - private val searchEngine: SearchEngine, - private val contentObserver: SystemContentObserver, -) : MusicBrowser.Invalidator, MusicSettings.Listener { - private val indexingNotification = IndexingNotification(context) - private val observingNotification = ObservingNotification(context) + private val musicRepository: MusicRepository +) : MusicBrowser.Invalidator { private var invalidator: Invalidator? = null - private var foregroundListener: ForegroundListener? = null private val dispatchJob = Job() private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) @@ -64,13 +57,9 @@ constructor( this.invalidator = invalidator indexer.attach(foregroundListener) musicBrowser.attach(this) - contentObserver.attach() - musicSettings.registerListener(this) } fun release() { - musicSettings.unregisterListener(this) - contentObserver.release() dispatchJob.cancel() musicBrowser.release() indexer.release() @@ -83,17 +72,6 @@ constructor( } } - override fun onObservingChanged() { - super.onObservingChanged() - // Make sure we don't override the service state with the observing - // notification if we were actively loading when the automatic rescanning - // setting changed. In such a case, the state will still be updated when - // the music loading process ends. - if (musicRepository.indexingState == null) { - logD("Not loading, updating idle session") - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) - } - } fun start() { if (musicRepository.indexingState == null) { @@ -102,25 +80,7 @@ constructor( } fun createNotification(post: (ForegroundServiceNotification?) -> Unit) { - val state = musicRepository.indexingState - if (state is IndexingState.Indexing) { - // There are a few reasons why we stay in the foreground with automatic rescanning: - // 1. Newer versions of Android have become more and more restrictive regarding - // how a foreground service starts. Thus, it's best to go foreground now so that - // we can go foreground later. - // 2. If a non-foreground service is killed, the app will probably still be alive, - // and thus the music library will not be updated at all. - val changed = indexingNotification.updateIndexingState(state.progress) - if (changed) { - post(indexingNotification) - } - } else if (musicSettings.shouldBeObserving) { - // Not observing and done loading, exit foreground. - logD("Exiting foreground") - post(observingNotification) - } else { - post(null) - } + indexer.createNotification(post) } fun getRoot() = BrowserRoot(Category.ROOT.id, null) @@ -134,42 +94,7 @@ constructor( ) = result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } fun search(query: String, result: MediaBrowserServiceCompat.Result>) = - result.dispatchAsync { - if (query.isEmpty()) { - return@dispatchAsync mutableListOf() - } - val deviceLibrary = - musicRepository.deviceLibrary ?: return@dispatchAsync mutableListOf() - val userLibrary = musicRepository.userLibrary ?: return@dispatchAsync mutableListOf() - val items = - SearchEngine.Items( - deviceLibrary.songs, - deviceLibrary.albums, - deviceLibrary.artists, - deviceLibrary.genres, - userLibrary.playlists) - searchEngine.search(items, query).toMediaItems() - } - - private fun SearchEngine.Items.toMediaItems(): MutableList { - val music = mutableListOf() - if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) }) - } - if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context, null, header(R.string.lbl_albums)) }) - } - if (artists != null) { - music.addAll(artists.map { it.toMediaItem(context, header(R.string.lbl_artists)) }) - } - if (genres != null) { - music.addAll(genres.map { it.toMediaItem(context, header(R.string.lbl_genres)) }) - } - if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(context, header(R.string.lbl_playlists)) }) - } - return music - } + result.dispatchAsync { musicBrowser.search(query) } private fun MediaBrowserServiceCompat.Result.dispatch(body: () -> T?) { try { @@ -187,6 +112,7 @@ constructor( private fun MediaBrowserServiceCompat.Result.dispatchAsync(body: suspend () -> T?) { dispatchScope.launch { try { + detach() val result = body() if (result == null) { logW("Result is null") From 66c31f431807bee181db1ddbd77367fb84608235 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 09:21:03 -0600 Subject: [PATCH 31/87] playback: apply missing extras --- .../java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index f86a539ec..e0354ba3a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -223,6 +223,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { .setTitle(name.resolve(context)) .setSubtitle(counts) .setIconUri(cover.single.mediaStoreCoverUri) + .setExtras(extras) .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } From e23ac33b855db277b27a10aeb3fe8d42af6d695f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 09:21:23 -0600 Subject: [PATCH 32/87] music: reformat --- .../auxio/music/service/MusicBrowser.kt | 3 +-- .../music/service/MusicServiceFragment.kt | 19 ++++++------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index eceff47d3..a2008b0cc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -133,8 +133,7 @@ constructor( if (query.isEmpty()) { return mutableListOf() } - val deviceLibrary = - musicRepository.deviceLibrary ?: return mutableListOf() + val deviceLibrary = musicRepository.deviceLibrary ?: return mutableListOf() val userLibrary = musicRepository.userLibrary ?: return mutableListOf() val items = SearchEngine.Items( diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index c1acf5144..eb37dd0ec 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -18,11 +18,9 @@ package org.oxycblt.auxio.music.service -import android.content.Context import android.support.v4.media.MediaBrowserCompat.MediaItem -import androidx.media.MediaBrowserServiceCompat +import androidx.media.MediaBrowserServiceCompat.Result import androidx.media.MediaBrowserServiceCompat.BrowserRoot -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -30,11 +28,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundServiceNotification -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.search.SearchEngine import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -72,7 +66,6 @@ constructor( } } - fun start() { if (musicRepository.indexingState == null) { musicRepository.requestIndex(true) @@ -85,18 +78,18 @@ constructor( fun getRoot() = BrowserRoot(Category.ROOT.id, null) - fun getItem(mediaId: String, result: MediaBrowserServiceCompat.Result) = + fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } fun getChildren( mediaId: String, - result: MediaBrowserServiceCompat.Result> + result: Result> ) = result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } - fun search(query: String, result: MediaBrowserServiceCompat.Result>) = + fun search(query: String, result: Result>) = result.dispatchAsync { musicBrowser.search(query) } - private fun MediaBrowserServiceCompat.Result.dispatch(body: () -> T?) { + private fun Result.dispatch(body: () -> T?) { try { val result = body() if (result == null) { @@ -109,7 +102,7 @@ constructor( } } - private fun MediaBrowserServiceCompat.Result.dispatchAsync(body: suspend () -> T?) { + private fun Result.dispatchAsync(body: suspend () -> T?) { dispatchScope.launch { try { detach() From f1e1152e21b7c02d8256db4b4584f7ccb01670fc Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 10:11:30 -0600 Subject: [PATCH 33/87] music: make compat more menu This way we can make sure that external providers never truncate our MediaItem count. --- .../java/org/oxycblt/auxio/AuxioService.kt | 8 +- .../oxycblt/auxio/music/service/Category.kt | 94 +++++++++++++++++++ .../music/service/MediaItemTranslation.kt | 34 +------ .../auxio/music/service/MusicBrowser.kt | 54 ++++++----- .../music/service/MusicServiceFragment.kt | 3 +- 5 files changed, 139 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/Category.kt diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 09d6fd379..9c9ddd595 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -30,6 +30,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.music.service.MusicServiceFragment @@ -83,7 +84,12 @@ class AuxioService : clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot = musicFragment.getRoot() + ): BrowserRoot { + val maximumRootChildLimit = + rootHints?.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4 + return musicFragment.getRoot(maximumRootChildLimit) + } override fun onLoadItem(itemId: String, result: Result) { musicFragment.getItem(itemId, result) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt new file mode 100644 index 000000000..c2babde29 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt @@ -0,0 +1,94 @@ +package org.oxycblt.auxio.music.service + +import org.oxycblt.auxio.R + +sealed interface Category { + val id: String + val nameRes: Int + val bitmapRes: Int? + + data class Root(val amount: Int) : Category { + override val id = "root/$amount" + override val nameRes = R.string.info_app_name + override val bitmapRes = null + + companion object { + const val ID_PREFIX = "root" + + fun fromString(str: String): Root? { + val split = str.split("/", limit = 2) + if (split.size != 2) { + return null + } + val limit = split[1].toIntOrNull() ?: return null + return Root(limit) + } + } + } + + data class More(val remainder: Int) : Category { + override val id = "more/$remainder" + override val nameRes = R.string.lbl_more + override val bitmapRes = null + + companion object { + const val ID_PREFIX = "more" + + fun fromString(str: String): More? { + val split = str.split("/", limit = 2) + if (split.size != 2) { + return null + } + val remainder = split[1].toIntOrNull() ?: return null + return More(remainder) + } + } + } + + data object Songs : Category { + override val id = "songs" + override val nameRes = R.string.lbl_songs + override val bitmapRes = R.drawable.ic_song_bitmap_24 + } + + data object Albums : Category { + override val id = "albums" + override val nameRes = R.string.lbl_albums + override val bitmapRes = R.drawable.ic_album_bitmap_24 + } + + data object Artists : Category { + override val id = "artists" + override val nameRes = R.string.lbl_artists + override val bitmapRes = R.drawable.ic_artist_bitmap_24 + } + + data object Genres : Category { + override val id = "genres" + override val nameRes = R.string.lbl_genres + override val bitmapRes = R.drawable.ic_genre_bitmap_24 + } + + data object Playlists : Category { + override val id = "playlists" + override val nameRes = R.string.lbl_playlists + override val bitmapRes = R.drawable.ic_playlist_bitmap_24 + } + + companion object { + val MUSIC = arrayOf(Songs, Albums, Artists, Genres, Playlists) + val DEVICE_MUSIC = arrayOf(Songs, Albums, Artists, Genres) + val USER_MUSIC = arrayOf(Playlists) + fun fromString(str: String): Category? = + when { + str.startsWith(Root.ID_PREFIX) -> Root.fromString(str) + str.startsWith(More.ID_PREFIX) -> More.fromString(str) + str == Songs.id -> Songs + str == Albums.id -> Albums + str == Artists.id -> Artists + str == Genres.id -> Genres + str == Playlists.id -> Playlists + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index e0354ba3a..b04afbc23 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -23,7 +23,6 @@ import android.graphics.BitmapFactory import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat -import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.media.utils.MediaConstants import org.oxycblt.auxio.BuildConfig @@ -38,22 +37,6 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.getPlural -enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) { - ROOT("root", R.string.info_app_name, null), - MORE("more", R.string.lbl_more, R.drawable.ic_more_24), - SONGS("songs", R.string.lbl_songs, R.drawable.ic_song_bitmap_24), - ALBUMS("albums", R.string.lbl_albums, R.drawable.ic_album_bitmap_24), - ARTISTS("artists", R.string.lbl_artists, R.drawable.ic_artist_bitmap_24), - GENRES("genres", R.string.lbl_genres, R.drawable.ic_genre_bitmap_24), - PLAYLISTS("playlists", R.string.lbl_playlists, R.drawable.ic_playlist_bitmap_24); - - companion object { - val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES) - val USER_MUSIC = listOf(ROOT, PLAYLISTS) - val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) - } -} - sealed interface MediaSessionUID { data class CategoryItem(val category: Category) : MediaSessionUID { override fun toString() = "$ID_CATEGORY:$category" @@ -78,17 +61,7 @@ sealed interface MediaSessionUID { } return when (parts[0]) { ID_CATEGORY -> - CategoryItem( - when (parts[1]) { - Category.ROOT.id -> Category.ROOT - Category.MORE.id -> Category.MORE - Category.SONGS.id -> Category.SONGS - Category.ALBUMS.id -> Category.ALBUMS - Category.ARTISTS.id -> Category.ARTISTS - Category.GENRES.id -> Category.GENRES - Category.PLAYLISTS.id -> Category.PLAYLISTS - else -> return null - }) + CategoryItem(Category.fromString(parts[1]) ?: return null) ID_ITEM -> { val uids = parts[1].split(">", limit = 2) if (uids.size == 1) { @@ -113,7 +86,6 @@ fun header(@StringRes nameRes: Int): Sugar = { } fun Category.toMediaItem(context: Context): MediaItem { - // TODO: Make custom overflow menu for compat val extras = Bundle().apply { putInt( @@ -126,8 +98,8 @@ fun Category.toMediaItem(context: Context): MediaItem { .setMediaId(mediaSessionUID.toString()) .setTitle(context.getString(nameRes)) .setExtras(extras) - if (bitmapRes != null) { - val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes) + bitmapRes?.let { res -> + val bitmap = BitmapFactory.decodeResource(context.resources, res) description.setIconBitmap(bitmap) } return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index a2008b0cc..576454204 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -172,27 +172,7 @@ constructor( ): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { is MediaSessionUID.CategoryItem -> { - when (mediaSessionUID.category) { - Category.ROOT -> Category.IMPORTANT.map { it.toMediaItem(context) } - Category.MORE -> TODO() - Category.SONGS -> - listSettings.songSort.songs(deviceLibrary.songs).map { - it.toMediaItem(context, null) - } - Category.ALBUMS -> - listSettings.albumSort.albums(deviceLibrary.albums).map { - it.toMediaItem(context) - } - Category.ARTISTS -> - listSettings.artistSort.artists(deviceLibrary.artists).map { - it.toMediaItem(context) - } - Category.GENRES -> - listSettings.genreSort.genres(deviceLibrary.genres).map { - it.toMediaItem(context) - } - Category.PLAYLISTS -> userLibrary.playlists.map { it.toMediaItem(context) } - } + getCategoryMediaItems(mediaSessionUID.category, deviceLibrary, userLibrary) } is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) @@ -206,6 +186,38 @@ constructor( } } + private fun getCategoryMediaItems(category: Category, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) = + when (category) { + is Category.Root -> { + val base = Category.MUSIC.take(category.amount) + if (base.size < Category.MUSIC.size) { + base + Category.More(Category.MUSIC.size - base.size) + } else { + base + }.map { it.toMediaItem(context) } + } + is Category.More -> Category.MUSIC.takeLast(category.remainder).map { + it.toMediaItem(context) + } + is Category.Songs -> + listSettings.songSort.songs(deviceLibrary.songs).map { + it.toMediaItem(context, null) + } + is Category.Albums -> + listSettings.albumSort.albums(deviceLibrary.albums).map { + it.toMediaItem(context) + } + is Category.Artists -> + listSettings.artistSort.artists(deviceLibrary.artists).map { + it.toMediaItem(context) + } + is Category.Genres -> + listSettings.genreSort.genres(deviceLibrary.genres).map { + it.toMediaItem(context) + } + is Category.Playlists -> userLibrary.playlists.map { it.toMediaItem(context) } + } + private fun getChildMediaItems(uid: Music.UID): List? { return when (val item = musicRepository.find(uid)) { is Album -> { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index eb37dd0ec..8d439b2c4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -76,7 +76,8 @@ constructor( indexer.createNotification(post) } - fun getRoot() = BrowserRoot(Category.ROOT.id, null) + fun getRoot(maxItems: Int) = + BrowserRoot(MediaSessionUID.CategoryItem(Category.Root(maxItems)).toString(), null) fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } From 916c3c46df0e0d1c9b0cda852f6370d9bc546b99 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 13:26:52 -0600 Subject: [PATCH 34/87] playback: split up mediasession interface and holder --- app/build.gradle | 3 + .../java/org/oxycblt/auxio/AuxioService.kt | 10 - .../playback/service/MediaSessionHolder.kt | 193 ++--------------- .../playback/service/MediaSessionInterface.kt | 202 ++++++++++++++++++ 4 files changed, 218 insertions(+), 190 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt diff --git a/app/build.gradle b/app/build.gradle index 2312bcb38..ac4e8f729 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -153,6 +153,9 @@ dependencies { // Tasker integration implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10' + // Fuzzy search + implementation 'org.apache.commons:commons-text:1.9' + // 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/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 9c9ddd595..eb2c8dc43 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -108,16 +108,6 @@ class AuxioService : musicFragment.search(query, result) } - @SuppressLint("RestrictedApi") - override fun onSubscribe(id: String?, option: Bundle?) { - super.onSubscribe(id, option) - } - - @SuppressLint("RestrictedApi") - override fun onUnsubscribe(id: String?) { - super.onUnsubscribe(id) - } - override fun updateForeground(change: ForegroundListener.Change) { val mediaNotification = playbackFragment.notification if (mediaNotification != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 1cecbf938..6fcda1830 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -20,10 +20,8 @@ package org.oxycblt.auxio.playback.system import android.annotation.SuppressLint import android.content.Context -import android.content.Intent import android.graphics.Bitmap -import android.net.Uri -import android.os.Bundle +import android.media.session.MediaSession import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat @@ -39,25 +37,17 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames -import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.service.MediaSessionInterface import org.oxycblt.auxio.playback.service.PlaybackActions -import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.state.ShuffleMode import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newBroadcastPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent @@ -71,10 +61,9 @@ import org.oxycblt.auxio.util.newMainPendingIntent class MediaSessionHolder private constructor( private val context: Context, + private val sessionInterface: MediaSessionInterface, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, - private val commandFactory: PlaybackCommand.Factory, - private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings ) : @@ -86,29 +75,23 @@ private constructor( class Factory @Inject constructor( + private val sessionInterface: MediaSessionInterface, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, - private val commandFactory: PlaybackCommand.Factory, - private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, - private val imageSettings: ImageSettings + private val imageSettings: ImageSettings, ) { fun create(context: Context) = MediaSessionHolder( context, + sessionInterface, playbackManager, playbackSettings, - commandFactory, - musicRepository, bitmapProvider, imageSettings) } - private val mediaSession = - MediaSessionCompat(context, context.packageName).apply { - isActive = true - setQueueTitle(context.getString(R.string.lbl_queue)) - } + private val mediaSession = MediaSessionCompat(context, context.packageName) val token: MediaSessionCompat.Token get() = mediaSession.sessionToken @@ -119,6 +102,11 @@ private constructor( private var foregroundListener: ForegroundListener? = null fun attach(foregroundListener: ForegroundListener) { + mediaSession.apply { + isActive = true + setQueueTitle(context.getString(R.string.lbl_queue)) + setCallback(sessionInterface) + } this.foregroundListener = foregroundListener playbackManager.addListener(this) playbackSettings.registerListener(this) @@ -218,116 +206,6 @@ private constructor( // --- MEDIASESSION OVERRIDES --- - override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { - super.onPlayFromMediaId(mediaId, extras) - val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return - val command = expandIntoCommand(uid) - requireNotNull(command) { "Invalid playback configuration" } - playbackManager.play(command) - } - - override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { - super.onPlayFromUri(uri, extras) - // STUB - } - - override fun onPlayFromSearch(query: String?, extras: Bundle?) { - super.onPlayFromSearch(query, extras) - // STUB: Unimplemented, no search engine - } - - override fun onAddQueueItem(description: MediaDescriptionCompat) { - super.onAddQueueItem(description) - val deviceLibrary = musicRepository.deviceLibrary ?: return - val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = - when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null - } - ?: return - playbackManager.addToQueue(song) - } - - override fun onRemoveQueueItem(description: MediaDescriptionCompat) { - super.onRemoveQueueItem(description) - val deviceLibrary = musicRepository.deviceLibrary ?: return - val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = - when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null - } - ?: return - val queueIndex = playbackManager.queue.indexOf(song) - if (queueIndex > -1) { - playbackManager.removeQueueItem(queueIndex) - } - } - - override fun onPlay() { - playbackManager.playing(true) - } - - override fun onPause() { - playbackManager.playing(false) - } - - override fun onSkipToNext() { - playbackManager.next() - } - - override fun onSkipToPrevious() { - playbackManager.prev() - } - - override fun onSeekTo(position: Long) { - playbackManager.seekTo(position) - } - - override fun onFastForward() { - playbackManager.next() - } - - override fun onRewind() { - playbackManager.seekTo(0) - playbackManager.playing(true) - } - - override fun onSetRepeatMode(repeatMode: Int) { - playbackManager.repeatMode( - when (repeatMode) { - PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL - PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL - PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK - else -> RepeatMode.NONE - }) - } - - override fun onSetShuffleMode(shuffleMode: Int) { - playbackManager.shuffled( - shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || - shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) - } - - override fun onSkipToQueueItem(id: Long) { - playbackManager.goto(id.toInt()) - } - - override fun onCustomAction(action: String, extras: Bundle?) { - super.onCustomAction(action, extras) - // Service already handles intents from the old notification actions, easier to - // plug into that system. - context.sendBroadcast(Intent(action)) - } - - override fun onStop() { - // Get the service to shut down with the ACTION_EXIT intent - context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) - } - // --- INTERNAL --- /** @@ -435,40 +313,6 @@ private constructor( mediaSession.setQueue(queueItems) } - private fun expandIntoCommand(uid: MediaSessionUID): PlaybackCommand? { - val music: Music - var parent: MusicParent? = null - when (uid) { - is MediaSessionUID.SingleItem -> { - music = musicRepository.find(uid.uid) ?: return null - } - is MediaSessionUID.ChildItem -> { - music = musicRepository.find(uid.childUid) ?: return null - parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null - } - else -> return null - } - - return when (music) { - is Song -> inferSongFromParent(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) - } - } - - private fun inferSongFromParent(music: Song, parent: MusicParent?) = - when (parent) { - is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) - is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) - is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) - is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) - null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) - } - /** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */ private fun invalidateSessionState() { logD("Updating media session playback state") @@ -477,7 +321,7 @@ private constructor( // InternalPlayer.State handles position/state information. playbackManager.progression .intoPlaybackState(PlaybackStateCompat.Builder()) - .setActions(ACTIONS) + .setActions(MediaSessionInterface.ACTIONS) // Active queue ID corresponds to the indices we populated prior, use them here. .setActiveQueueItemId(playbackManager.index.toLong()) @@ -543,17 +387,6 @@ private constructor( companion object { private val emptyMetadata = MediaMetadataCompat.Builder().build() - private const val ACTIONS = - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_SET_REPEAT_MODE or - PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or - PlaybackStateCompat.ACTION_SEEK_TO or - PlaybackStateCompat.ACTION_STOP } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt new file mode 100644 index 000000000..2b0a45769 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -0,0 +1,202 @@ +package org.oxycblt.auxio.playback.service + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.playback.state.PlaybackCommand +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.ShuffleMode +import javax.inject.Inject + +class MediaSessionInterface @Inject constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, +) : MediaSessionCompat.Callback() { + + override fun onPrepareFromMediaId(mediaId: String?, extras: Bundle?) { + super.onPrepareFromMediaId(mediaId, extras) + } + + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + super.onPlayFromMediaId(mediaId, extras) + val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return + val command = expandIntoCommand(uid) + requireNotNull(command) { "Invalid playback configuration" } + playbackManager.play(command) + } + + override fun onPrepareFromSearch(query: String?, extras: Bundle?) { + super.onPrepareFromSearch(query, extras) + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + super.onPlayFromSearch(query, extras) + // STUB: Unimplemented, no search engine + } + + override fun onPrepareFromUri(uri: Uri?, extras: Bundle?) { + super.onPrepareFromUri(uri, extras) + } + + override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { + super.onPlayFromUri(uri, extras) + // STUB + } + + override fun onAddQueueItem(description: MediaDescriptionCompat) { + super.onAddQueueItem(description) + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val song = + when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } + ?: return + playbackManager.addToQueue(song) + } + + override fun onRemoveQueueItem(description: MediaDescriptionCompat) { + super.onRemoveQueueItem(description) + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val song = + when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } + ?: return + val queueIndex = playbackManager.queue.indexOf(song) + if (queueIndex > -1) { + playbackManager.removeQueueItem(queueIndex) + } + } + + override fun onPlay() { + playbackManager.playing(true) + } + + override fun onPause() { + playbackManager.playing(false) + } + + override fun onSkipToNext() { + playbackManager.next() + } + + override fun onSkipToPrevious() { + playbackManager.prev() + } + + override fun onSeekTo(position: Long) { + playbackManager.seekTo(position) + } + + override fun onFastForward() { + playbackManager.next() + } + + override fun onRewind() { + playbackManager.seekTo(0) + playbackManager.playing(true) + } + + override fun onSetRepeatMode(repeatMode: Int) { + playbackManager.repeatMode( + when (repeatMode) { + PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL + PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL + PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK + else -> RepeatMode.NONE + }) + } + + override fun onSetShuffleMode(shuffleMode: Int) { + playbackManager.shuffled( + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) + } + + override fun onSkipToQueueItem(id: Long) { + playbackManager.goto(id.toInt()) + } + + override fun onCustomAction(action: String, extras: Bundle?) { + super.onCustomAction(action, extras) + // Service already handles intents from the old notification actions, easier to + // plug into that system. + context.sendBroadcast(Intent(action)) + } + + override fun onStop() { + // Get the service to shut down with the ACTION_EXIT intent + context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) + } + + private fun expandIntoCommand(uid: MediaSessionUID): PlaybackCommand? { + val music: Music + var parent: MusicParent? = null + when (uid) { + is MediaSessionUID.SingleItem -> { + music = musicRepository.find(uid.uid) ?: return null + } + is MediaSessionUID.ChildItem -> { + music = musicRepository.find(uid.childUid) ?: return null + parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null + } + else -> return null + } + + return when (music) { + is Song -> inferSongFromParent(music, parent) + is Album -> commandFactory.album(music, ShuffleMode.OFF) + is Artist -> commandFactory.artist(music, ShuffleMode.OFF) + is Genre -> commandFactory.genre(music, ShuffleMode.OFF) + is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) + } + } + + private fun inferSongFromParent(music: Song, parent: MusicParent?) = + when (parent) { + is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) + null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) + } + + companion object { + const val ACTIONS = + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SET_REPEAT_MODE or + PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or + PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_STOP + } +} \ No newline at end of file From 44f9617307ed42003845f1f8fadce0964da0ddef Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 13:29:24 -0600 Subject: [PATCH 35/87] playback: add missing session actions --- .../playback/service/MediaSessionInterface.kt | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 2b0a45769..65b55b51f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -24,11 +24,14 @@ import org.oxycblt.auxio.playback.state.ShuffleMode import javax.inject.Inject class MediaSessionInterface @Inject constructor( - @ApplicationContext private val context: Context, + @ApplicationContext private val context: Context, private val playbackManager: PlaybackStateManager, private val commandFactory: PlaybackCommand.Factory, private val musicRepository: MusicRepository, ) : MediaSessionCompat.Callback() { + override fun onPrepare() { + super.onPrepare() + } override fun onPrepareFromMediaId(mediaId: String?, extras: Bundle?) { super.onPrepareFromMediaId(mediaId, extras) @@ -127,13 +130,15 @@ class MediaSessionInterface @Inject constructor( PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK else -> RepeatMode.NONE - }) + } + ) } override fun onSetShuffleMode(shuffleMode: Int) { playbackManager.shuffled( shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || - shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP + ) } override fun onSkipToQueueItem(id: Long) { @@ -159,10 +164,12 @@ class MediaSessionInterface @Inject constructor( is MediaSessionUID.SingleItem -> { music = musicRepository.find(uid.uid) ?: return null } + is MediaSessionUID.ChildItem -> { music = musicRepository.find(uid.childUid) ?: return null parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null } + else -> return null } @@ -179,16 +186,19 @@ class MediaSessionInterface @Inject constructor( when (parent) { is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) } companion object { const val ACTIONS = - PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SET_REPEAT_MODE or @@ -197,6 +207,7 @@ class MediaSessionInterface @Inject constructor( PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_REWIND or PlaybackStateCompat.ACTION_STOP } -} \ No newline at end of file +} From 3dea060a288f25907e61f545c23978001e1aac57 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 13:29:48 -0600 Subject: [PATCH 36/87] all: cleanup --- .../java/org/oxycblt/auxio/AuxioService.kt | 3 +- .../oxycblt/auxio/music/service/Category.kt | 21 ++++- .../music/service/MediaItemTranslation.kt | 3 +- .../auxio/music/service/MusicBrowser.kt | 28 +++---- .../music/service/MusicServiceFragment.kt | 8 +- .../playback/service/MediaSessionHolder.kt | 1 - .../playback/service/MediaSessionInterface.kt | 38 ++++++--- .../service/PlaybackServiceFragment.kt | 77 ------------------- 8 files changed, 65 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index eb2c8dc43..4f45fff6a 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -86,8 +86,7 @@ class AuxioService : rootHints: Bundle? ): BrowserRoot { val maximumRootChildLimit = - rootHints?.getInt( - MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4 + rootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4 return musicFragment.getRoot(maximumRootChildLimit) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt index c2babde29..46676d3cc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2024 Auxio Project + * Category.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.service import org.oxycblt.auxio.R @@ -79,6 +97,7 @@ sealed interface Category { val MUSIC = arrayOf(Songs, Albums, Artists, Genres, Playlists) val DEVICE_MUSIC = arrayOf(Songs, Albums, Artists, Genres) val USER_MUSIC = arrayOf(Playlists) + fun fromString(str: String): Category? = when { str.startsWith(Root.ID_PREFIX) -> Root.fromString(str) @@ -91,4 +110,4 @@ sealed interface Category { else -> null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index b04afbc23..be320131b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -60,8 +60,7 @@ sealed interface MediaSessionUID { return null } return when (parts[0]) { - ID_CATEGORY -> - CategoryItem(Category.fromString(parts[1]) ?: return null) + ID_CATEGORY -> CategoryItem(Category.fromString(parts[1]) ?: return null) ID_ITEM -> { val uids = parts[1].split(">", limit = 2) if (uids.size == 1) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 576454204..b62f9b248 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -186,35 +186,35 @@ constructor( } } - private fun getCategoryMediaItems(category: Category, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) = + private fun getCategoryMediaItems( + category: Category, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ) = when (category) { is Category.Root -> { val base = Category.MUSIC.take(category.amount) if (base.size < Category.MUSIC.size) { - base + Category.More(Category.MUSIC.size - base.size) - } else { - base - }.map { it.toMediaItem(context) } - } - is Category.More -> Category.MUSIC.takeLast(category.remainder).map { - it.toMediaItem(context) + base + Category.More(Category.MUSIC.size - base.size) + } else { + base + } + .map { it.toMediaItem(context) } } + is Category.More -> + Category.MUSIC.takeLast(category.remainder).map { it.toMediaItem(context) } is Category.Songs -> listSettings.songSort.songs(deviceLibrary.songs).map { it.toMediaItem(context, null) } is Category.Albums -> - listSettings.albumSort.albums(deviceLibrary.albums).map { - it.toMediaItem(context) - } + listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } is Category.Artists -> listSettings.artistSort.artists(deviceLibrary.artists).map { it.toMediaItem(context) } is Category.Genres -> - listSettings.genreSort.genres(deviceLibrary.genres).map { - it.toMediaItem(context) - } + listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } is Category.Playlists -> userLibrary.playlists.map { it.toMediaItem(context) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 8d439b2c4..51563a49b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -19,8 +19,8 @@ package org.oxycblt.auxio.music.service import android.support.v4.media.MediaBrowserCompat.MediaItem -import androidx.media.MediaBrowserServiceCompat.Result import androidx.media.MediaBrowserServiceCompat.BrowserRoot +import androidx.media.MediaBrowserServiceCompat.Result import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -82,10 +82,8 @@ constructor( fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } - fun getChildren( - mediaId: String, - result: Result> - ) = result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } + fun getChildren(mediaId: String, result: Result>) = + result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } fun search(query: String, result: Result>) = result.dispatchAsync { musicBrowser.search(query) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 6fcda1830..e561b8180 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -21,7 +21,6 @@ package org.oxycblt.auxio.playback.system import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap -import android.media.session.MediaSession import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 65b55b51f..6688ed956 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaSessionInterface.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.service import android.content.Context @@ -8,6 +26,7 @@ import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -21,9 +40,10 @@ import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.ShuffleMode -import javax.inject.Inject -class MediaSessionInterface @Inject constructor( +class MediaSessionInterface +@Inject +constructor( @ApplicationContext private val context: Context, private val playbackManager: PlaybackStateManager, private val commandFactory: PlaybackCommand.Factory, @@ -130,15 +150,13 @@ class MediaSessionInterface @Inject constructor( PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK else -> RepeatMode.NONE - } - ) + }) } override fun onSetShuffleMode(shuffleMode: Int) { playbackManager.shuffled( shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || - shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP - ) + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) } override fun onSkipToQueueItem(id: Long) { @@ -164,12 +182,10 @@ class MediaSessionInterface @Inject constructor( is MediaSessionUID.SingleItem -> { music = musicRepository.find(uid.uid) ?: return null } - is MediaSessionUID.ChildItem -> { music = musicRepository.find(uid.childUid) ?: return null parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null } - else -> return null } @@ -186,11 +202,9 @@ class MediaSessionInterface @Inject constructor( when (parent) { is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) - + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) - + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index b6ca0d721..42e60d7fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -108,81 +108,4 @@ constructor( override fun onSessionEnded() { foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } - - // override fun onGetLibraryRoot( - // session: MediaLibrarySession, - // browser: MediaSession.ControllerInfo, - // params: MediaLibraryService.LibraryParams? - // ): ListenableFuture> = - // Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params)) - // - // override fun onGetItem( - // session: MediaLibrarySession, - // browser: MediaSession.ControllerInfo, - // mediaId: String - // ): ListenableFuture> { - // val result = - // mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } - // ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - // return Futures.immediateFuture(result) - // } - // - // override fun onSetMediaItems( - // mediaSession: MediaSession, - // controller: MediaSession.ControllerInfo, - // mediaItems: MutableList, - // startIndex: Int, - // startPositionMs: Long - // ): ListenableFuture = - // Futures.immediateFuture( - // MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) - // - // override fun onGetChildren( - // session: MediaLibrarySession, - // browser: MediaSession.ControllerInfo, - // parentId: String, - // page: Int, - // pageSize: Int, - // params: MediaLibraryService.LibraryParams? - // ): ListenableFuture>> { - // val children = - // mediaItemBrowser.getChildren(parentId, page, pageSize)?.let { - // LibraryResult.ofItemList(it, params) - // } - // ?: LibraryResult.ofError>( - // LibraryResult.RESULT_ERROR_BAD_VALUE) - // return Futures.immediateFuture(children) - // } - // - // override fun onSearch( - // session: MediaLibrarySession, - // browser: MediaSession.ControllerInfo, - // query: String, - // params: MediaLibraryService.LibraryParams? - // ): ListenableFuture> = - // waitScope - // .async { - // mediaItemBrowser.prepareSearch(query, browser) - // // Invalidator will send the notify result - // LibraryResult.ofVoid() - // } - // .asListenableFuture() - // - // override fun onGetSearchResult( - // session: MediaLibrarySession, - // browser: MediaSession.ControllerInfo, - // query: String, - // page: Int, - // pageSize: Int, - // params: MediaLibraryService.LibraryParams? - // ) = - // waitScope - // .async { - // mediaItemBrowser.getSearchResult(query, page, pageSize)?.let { - // LibraryResult.ofItemList(it, params) - // } - // ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - // } - // .asListenableFuture() - } From ba5bccaa3703e6df77b1a19dd34a71e203751f46 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 13:31:29 -0600 Subject: [PATCH 37/87] playback: remove specific queue item in android auto --- .../playback/service/MediaSessionHolder.kt | 2 ++ .../playback/service/MediaSessionInterface.kt | 17 ++++------------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index e561b8180..9f301fcd3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.playback.system import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap +import android.os.Bundle import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat @@ -303,6 +304,7 @@ private constructor( // MediaStore URI instead of loading a bitmap. .setIconUri(song.album.cover.single.mediaStoreCoverUri) .setMediaUri(song.uri) + .setExtras(Bundle().apply { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) .build() // Store the item index so we can then use the analogous index in the // playback state. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 6688ed956..25c3078f5 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -26,6 +26,7 @@ import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import dagger.hilt.android.qualifiers.ApplicationContext +import org.oxycblt.auxio.BuildConfig import javax.inject.Inject import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -99,19 +100,8 @@ constructor( override fun onRemoveQueueItem(description: MediaDescriptionCompat) { super.onRemoveQueueItem(description) - val deviceLibrary = musicRepository.deviceLibrary ?: return - val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = - when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null - } - ?: return - val queueIndex = playbackManager.queue.indexOf(song) - if (queueIndex > -1) { - playbackManager.removeQueueItem(queueIndex) - } + val at = description.extras?.getInt(KEY_QUEUE_POS) ?: return + playbackManager.removeQueueItem(at) } override fun onPlay() { @@ -210,6 +200,7 @@ constructor( } companion object { + const val KEY_QUEUE_POS = BuildConfig.APPLICATION_ID + ".metadata.QUEUE_POS" const val ACTIONS = PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or PlaybackStateCompat.ACTION_PLAY or From cce33e14147e8136208d9c3328049e936857f311 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 14:08:58 -0600 Subject: [PATCH 38/87] playback: improve published playback metadata --- app/build.gradle | 3 +++ .../playback/service/MediaSessionHolder.kt | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ac4e8f729..a0198d737 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -114,6 +114,9 @@ dependencies { // Media implementation "androidx.media:media:1.7.0" + // Android Auto + implementation "androidx.car.app:app:1.4.0" + // Preferences implementation "androidx.preference:preference-ktx:1.2.1" diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 9f301fcd3..acc940110 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -27,6 +27,7 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import androidx.annotation.DrawableRes +import androidx.car.app.mediaextensions.MetadataExtras import androidx.core.app.NotificationCompat import androidx.media.app.NotificationCompat.MediaStyle import javax.inject.Inject @@ -40,6 +41,7 @@ import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.service.MediaSessionInterface @@ -230,10 +232,11 @@ private constructor( // several times. val title = song.name.resolve(context) val artist = song.artists.resolveNames(context) + val album = song.album.name.resolve(context) val builder = MediaMetadataCompat.Builder() .putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name.resolve(context)) + .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, album) // Note: We would leave the artist field null if it didn't exist and let downstream // consumers handle it, but that would break the notification display. .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) @@ -246,11 +249,12 @@ private constructor( .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genres.resolveNames(context)) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) - .putText( - MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, - parent?.run { name.resolve(context) } - ?: context.getString(R.string.lbl_all_songs)) + .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, album) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs) + .putText(PlaybackNotification.KEY_PARENT, + parent?.name?.resolve(context) ?: context.getString(R.string.lbl_all_songs)) + .putText(MetadataExtras.KEY_SUBTITLE_LINK_MEDIA_ID, MediaSessionUID.SingleItem(song.artists[0].uid).toString()) + .putText(MetadataExtras.KEY_DESCRIPTION_LINK_MEDIA_ID, MediaSessionUID.SingleItem(song.album.uid).toString()) // These fields are nullable and so we must check first before adding them to the fields. song.track?.let { logD("Adding track information") @@ -263,6 +267,7 @@ private constructor( song.date?.let { logD("Adding date information") builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) + builder.putString(MediaMetadataCompat.METADATA_KEY_YEAR, it.year.toString()) } // We are normally supposed to use URIs for album art, but that removes some of the @@ -510,9 +515,11 @@ private class PlaybackNotification( iconRes, actionName, context.newBroadcastPendingIntent(actionName)) .build() - private companion object { + companion object { + const val KEY_PARENT = BuildConfig.APPLICATION_ID + ".metadata.PARENT" + /** Notification channel used by solely the playback notification. */ - val CHANNEL_INFO = + private val CHANNEL_INFO = ChannelInfo( id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK", nameRes = R.string.lbl_playback) From fda4548515fd544a4a66636e464ebe48f2d9de8e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 14:11:19 -0600 Subject: [PATCH 39/87] music: apply descriptions everywhere --- .../org/oxycblt/auxio/music/service/MediaItemTranslation.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index be320131b..72b0f7e7e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -35,6 +35,7 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.playback.formatDurationDs import org.oxycblt.auxio.util.getPlural sealed interface MediaSessionUID { @@ -141,11 +142,13 @@ fun Album.toMediaItem( MediaSessionUID.ChildItem(parent.uid, uid) } val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val counts = context.getPlural(R.plurals.fmt_song_count, songs.size) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(artists.resolveNames(context)) + .setDescription(counts) .setIconUri(cover.single.mediaStoreCoverUri) .setExtras(extras) .build() @@ -173,6 +176,7 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(counts) + .setDescription(genres.resolveNames(context)) .setIconUri(cover.single.mediaStoreCoverUri) .setExtras(extras) .build() @@ -213,6 +217,7 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(counts) + .setDescription(durationMs.formatDurationDs(true)) .setIconUri(cover?.single?.mediaStoreCoverUri) .setExtras(extras) .build() From b2e7c1eb50f7cef89b380b43ed156dc0be3fe47f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 15:52:42 -0600 Subject: [PATCH 40/87] playback: basic play from search functionality --- .../playback/service/MediaSessionHolder.kt | 14 +- .../playback/service/MediaSessionInterface.kt | 122 +++++++++++++++--- 2 files changed, 111 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index acc940110..e133388b4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -251,10 +251,15 @@ private constructor( .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, album) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs) - .putText(PlaybackNotification.KEY_PARENT, + .putText( + PlaybackNotification.KEY_PARENT, parent?.name?.resolve(context) ?: context.getString(R.string.lbl_all_songs)) - .putText(MetadataExtras.KEY_SUBTITLE_LINK_MEDIA_ID, MediaSessionUID.SingleItem(song.artists[0].uid).toString()) - .putText(MetadataExtras.KEY_DESCRIPTION_LINK_MEDIA_ID, MediaSessionUID.SingleItem(song.album.uid).toString()) + .putText( + MetadataExtras.KEY_SUBTITLE_LINK_MEDIA_ID, + MediaSessionUID.SingleItem(song.artists[0].uid).toString()) + .putText( + MetadataExtras.KEY_DESCRIPTION_LINK_MEDIA_ID, + MediaSessionUID.SingleItem(song.album.uid).toString()) // These fields are nullable and so we must check first before adding them to the fields. song.track?.let { logD("Adding track information") @@ -309,7 +314,8 @@ private constructor( // MediaStore URI instead of loading a bitmap. .setIconUri(song.album.cover.single.mediaStoreCoverUri) .setMediaUri(song.uri) - .setExtras(Bundle().apply { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) + .setExtras( + Bundle().apply { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) .build() // Store the item index so we can then use the analogous index in the // playback state. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 25c3078f5..66805f801 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -22,12 +22,14 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle +import android.provider.MediaStore import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import dagger.hilt.android.qualifiers.ApplicationContext -import org.oxycblt.auxio.BuildConfig import javax.inject.Inject +import org.apache.commons.text.similarity.JaroWinklerSimilarity +import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -36,7 +38,9 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode @@ -50,39 +54,65 @@ constructor( private val commandFactory: PlaybackCommand.Factory, private val musicRepository: MusicRepository, ) : MediaSessionCompat.Callback() { + private val jaroWinkler = JaroWinklerSimilarity() + override fun onPrepare() { super.onPrepare() + // STUB, we already automatically prepare playback. } override fun onPrepareFromMediaId(mediaId: String?, extras: Bundle?) { super.onPrepareFromMediaId(mediaId, extras) + // STUB, can't tell when this is called + } + + override fun onPrepareFromUri(uri: Uri?, extras: Bundle?) { + super.onPrepareFromUri(uri, extras) + // STUB, can't tell when this is called + } + + override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { + super.onPlayFromUri(uri, extras) + // STUB, can't tell when this is called } override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return - val command = expandIntoCommand(uid) - requireNotNull(command) { "Invalid playback configuration" } - playbackManager.play(command) + val command = expandUidIntoCommand(uid) + playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) } override fun onPrepareFromSearch(query: String?, extras: Bundle?) { super.onPrepareFromSearch(query, extras) + // STUB, can't tell when this is called } - override fun onPlayFromSearch(query: String?, extras: Bundle?) { + override fun onPlayFromSearch(query: String, extras: Bundle) { super.onPlayFromSearch(query, extras) - // STUB: Unimplemented, no search engine + val deviceLibrary = musicRepository.deviceLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return + val queryBundle = + QueryBundle( + (extras.getString(MediaStore.EXTRA_MEDIA_TITLE) ?: query).ifBlank { null }, + extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)?.ifBlank { null }, + extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)?.ifBlank { null }, + extras.getString(MediaStore.EXTRA_MEDIA_GENRE)?.ifBlank { null }, + extras.getString(@Suppress("DEPRECATION") MediaStore.EXTRA_MEDIA_PLAYLIST)) + val command = expandSearchInfoCommand(queryBundle, deviceLibrary, userLibrary) + playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) } - override fun onPrepareFromUri(uri: Uri?, extras: Bundle?) { - super.onPrepareFromUri(uri, extras) - } + data class QueryBundle( + val title: String?, + val album: String?, + val artist: String?, + val genre: String?, + val playlist: String? + ) - override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { - super.onPlayFromUri(uri, extras) - // STUB - } + private fun Collection.fuzzyBest(query: String): T = + maxByOrNull { jaroWinkler.apply(it.name.resolve(context), query) } ?: first() override fun onAddQueueItem(description: MediaDescriptionCompat) { super.onAddQueueItem(description) @@ -120,6 +150,10 @@ constructor( playbackManager.prev() } + override fun onSkipToQueueItem(id: Long) { + playbackManager.goto(id.toInt()) + } + override fun onSeekTo(position: Long) { playbackManager.seekTo(position) } @@ -149,8 +183,9 @@ constructor( shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) } - override fun onSkipToQueueItem(id: Long) { - playbackManager.goto(id.toInt()) + override fun onStop() { + // Get the service to shut down with the ACTION_EXIT intent + context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) } override fun onCustomAction(action: String, extras: Bundle?) { @@ -160,12 +195,7 @@ constructor( context.sendBroadcast(Intent(action)) } - override fun onStop() { - // Get the service to shut down with the ACTION_EXIT intent - context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) - } - - private fun expandIntoCommand(uid: MediaSessionUID): PlaybackCommand? { + private fun expandUidIntoCommand(uid: MediaSessionUID): PlaybackCommand? { val music: Music var parent: MusicParent? = null when (uid) { @@ -188,6 +218,55 @@ constructor( } } + private fun expandSearchInfoCommand( + query: QueryBundle, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ): PlaybackCommand? { + if (query.album != null) { + val album = deviceLibrary.albums.fuzzyBest(query.album) + if (query.title == null) { + return commandFactory.album(album, ShuffleMode.OFF) + } + val song = album.songs.fuzzyBest(query.title) + return commandFactory.songFromAlbum(song, ShuffleMode.OFF) + } + + if (query.artist != null) { + val artist = deviceLibrary.artists.fuzzyBest(query.artist) + if (query.title == null) { + return commandFactory.artist(artist, ShuffleMode.OFF) + } + val song = artist.songs.fuzzyBest(query.title) + return commandFactory.songFromArtist(song, artist, ShuffleMode.OFF) + } + + if (query.genre != null) { + val genre = deviceLibrary.genres.fuzzyBest(query.genre) + if (query.title == null) { + return commandFactory.genre(genre, ShuffleMode.OFF) + } + val song = genre.songs.fuzzyBest(query.title) + return commandFactory.songFromGenre(song, genre, ShuffleMode.OFF) + } + + if (query.playlist != null) { + val playlist = userLibrary.playlists.fuzzyBest(query.playlist) + if (query.title == null) { + return commandFactory.playlist(playlist, ShuffleMode.OFF) + } + val song = playlist.songs.fuzzyBest(query.title) + return commandFactory.songFromPlaylist(song, playlist, ShuffleMode.OFF) + } + + if (query.title != null) { + val song = deviceLibrary.songs.fuzzyBest(query.title) + return commandFactory.songFromAll(song, ShuffleMode.OFF) + } + + return commandFactory.all(ShuffleMode.ON) + } + private fun inferSongFromParent(music: Song, parent: MusicParent?) = when (parent) { is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) @@ -203,6 +282,7 @@ constructor( const val KEY_QUEUE_POS = BuildConfig.APPLICATION_ID + ".metadata.QUEUE_POS" const val ACTIONS = PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE or From a712a773b080a041463e66eb73adfb8e7ba7c4dd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 16:35:06 -0600 Subject: [PATCH 41/87] playback: correctly voice search for music Completely misunderstood how "focus" worked. --- .../playback/service/MediaSessionHolder.kt | 1 + .../playback/service/MediaSessionInterface.kt | 143 ++++++++++-------- 2 files changed, 80 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index e133388b4..f89016f31 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -108,6 +108,7 @@ private constructor( isActive = true setQueueTitle(context.getString(R.string.lbl_queue)) setCallback(sessionInterface) + setFlags(MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS) } this.foregroundListener = foregroundListener playbackManager.addListener(this) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 66805f801..6ec93146d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -39,6 +39,7 @@ import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.playback.state.PlaybackCommand @@ -92,14 +93,11 @@ constructor( super.onPlayFromSearch(query, extras) val deviceLibrary = musicRepository.deviceLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return - val queryBundle = - QueryBundle( - (extras.getString(MediaStore.EXTRA_MEDIA_TITLE) ?: query).ifBlank { null }, - extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)?.ifBlank { null }, - extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)?.ifBlank { null }, - extras.getString(MediaStore.EXTRA_MEDIA_GENRE)?.ifBlank { null }, - extras.getString(@Suppress("DEPRECATION") MediaStore.EXTRA_MEDIA_PLAYLIST)) - val command = expandSearchInfoCommand(queryBundle, deviceLibrary, userLibrary) + val command = expandSearchInfoCommand( + query.ifBlank { null }, + extras, + deviceLibrary, + userLibrary) playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) } @@ -111,9 +109,6 @@ constructor( val playlist: String? ) - private fun Collection.fuzzyBest(query: String): T = - maxByOrNull { jaroWinkler.apply(it.name.resolve(context), query) } ?: first() - override fun onAddQueueItem(description: MediaDescriptionCompat) { super.onAddQueueItem(description) val deviceLibrary = musicRepository.deviceLibrary ?: return @@ -209,65 +204,85 @@ constructor( else -> return null } - return when (music) { - is Song -> inferSongFromParent(music, parent) + return expandMusicIntoCommand(music, parent) + } + + @Suppress("DEPRECATION") + private fun expandSearchInfoCommand( + query: String?, + extras: Bundle, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ): PlaybackCommand? { + if (query == null) { + // User just wanted to 'play some music', shuffle all + return commandFactory.all(ShuffleMode.ON) + } + + val bestCommand = when (extras.getString(MediaStore.EXTRA_MEDIA_FOCUS)) { + MediaStore.Audio.Media.ENTRY_CONTENT_TYPE -> { + val songQuery = extras.getString(MediaStore.EXTRA_MEDIA_TITLE) + val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) + val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) + val best = deviceLibrary.songs.maxByOrNull { + fuzzy(it.name, songQuery) + fuzzy(it.album.name, albumQuery) + + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } + } + best?.let { commandFactory.song(it, ShuffleMode.IMPLICIT) } + } + + MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> { + val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) + val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) + val best = deviceLibrary.albums.maxByOrNull { + fuzzy(it.name, albumQuery) + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } + } + best?.let { commandFactory.album(it, ShuffleMode.OFF) } + } + + MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> { + val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) + val best = deviceLibrary.artists.maxByOrNull { fuzzy(it.name, artistQuery) } + best?.let { commandFactory.artist(it, ShuffleMode.OFF) } + } + + MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> { + val genreQuery = extras.getString(MediaStore.EXTRA_MEDIA_GENRE) + val best = deviceLibrary.genres.maxByOrNull { fuzzy(it.name, genreQuery) } + best?.let { commandFactory.genre(it, ShuffleMode.OFF) } + } + + MediaStore.Audio.Playlists.ENTRY_CONTENT_TYPE -> { + val playlistQuery = extras.getString(MediaStore.EXTRA_MEDIA_PLAYLIST) + val best = userLibrary.playlists.maxByOrNull { fuzzy(it.name, playlistQuery) } + best?.let { commandFactory.playlist(it, ShuffleMode.OFF) } + } + + else -> null + } + + if (bestCommand != null) { + return bestCommand + } + + val bestMusic = (deviceLibrary.songs + deviceLibrary.albums + deviceLibrary.artists + deviceLibrary.genres + userLibrary.playlists) + .maxByOrNull { fuzzy(it.name, query) } + return bestMusic?.let { expandMusicIntoCommand(it, null) } + } + + private fun fuzzy(name: Name, query: String?): Double = + query?.let { jaroWinkler.apply(name.resolve(context), it) } ?: 0.0 + + private fun expandMusicIntoCommand(music: Music, parent: MusicParent?) = + when (music) { + is Song -> expandSongIntoCommand(music, parent) is Album -> commandFactory.album(music, ShuffleMode.OFF) is Artist -> commandFactory.artist(music, ShuffleMode.OFF) is Genre -> commandFactory.genre(music, ShuffleMode.OFF) is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) } - } - private fun expandSearchInfoCommand( - query: QueryBundle, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary - ): PlaybackCommand? { - if (query.album != null) { - val album = deviceLibrary.albums.fuzzyBest(query.album) - if (query.title == null) { - return commandFactory.album(album, ShuffleMode.OFF) - } - val song = album.songs.fuzzyBest(query.title) - return commandFactory.songFromAlbum(song, ShuffleMode.OFF) - } - - if (query.artist != null) { - val artist = deviceLibrary.artists.fuzzyBest(query.artist) - if (query.title == null) { - return commandFactory.artist(artist, ShuffleMode.OFF) - } - val song = artist.songs.fuzzyBest(query.title) - return commandFactory.songFromArtist(song, artist, ShuffleMode.OFF) - } - - if (query.genre != null) { - val genre = deviceLibrary.genres.fuzzyBest(query.genre) - if (query.title == null) { - return commandFactory.genre(genre, ShuffleMode.OFF) - } - val song = genre.songs.fuzzyBest(query.title) - return commandFactory.songFromGenre(song, genre, ShuffleMode.OFF) - } - - if (query.playlist != null) { - val playlist = userLibrary.playlists.fuzzyBest(query.playlist) - if (query.title == null) { - return commandFactory.playlist(playlist, ShuffleMode.OFF) - } - val song = playlist.songs.fuzzyBest(query.title) - return commandFactory.songFromPlaylist(song, playlist, ShuffleMode.OFF) - } - - if (query.title != null) { - val song = deviceLibrary.songs.fuzzyBest(query.title) - return commandFactory.songFromAll(song, ShuffleMode.OFF) - } - - return commandFactory.all(ShuffleMode.ON) - } - - private fun inferSongFromParent(music: Song, parent: MusicParent?) = + private fun expandSongIntoCommand(music: Song, parent: MusicParent?) = when (parent) { is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) From 130d30c70d72ae4e430173da86822984b05bc337 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 16:37:20 -0600 Subject: [PATCH 42/87] playback: immprove search error cases --- .../playback/service/MediaSessionInterface.kt | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 6ec93146d..dbe726dde 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -219,7 +219,7 @@ constructor( return commandFactory.all(ShuffleMode.ON) } - val bestCommand = when (extras.getString(MediaStore.EXTRA_MEDIA_FOCUS)) { + when (extras.getString(MediaStore.EXTRA_MEDIA_FOCUS)) { MediaStore.Audio.Media.ENTRY_CONTENT_TYPE -> { val songQuery = extras.getString(MediaStore.EXTRA_MEDIA_TITLE) val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) @@ -228,7 +228,9 @@ constructor( fuzzy(it.name, songQuery) + fuzzy(it.album.name, albumQuery) + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } } - best?.let { commandFactory.song(it, ShuffleMode.IMPLICIT) } + if (best != null) { + return expandSongIntoCommand(best, null) + } } MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> { @@ -237,37 +239,42 @@ constructor( val best = deviceLibrary.albums.maxByOrNull { fuzzy(it.name, albumQuery) + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } } - best?.let { commandFactory.album(it, ShuffleMode.OFF) } + if (best != null) { + return commandFactory.album(best, ShuffleMode.OFF) + } } MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> { val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) val best = deviceLibrary.artists.maxByOrNull { fuzzy(it.name, artistQuery) } - best?.let { commandFactory.artist(it, ShuffleMode.OFF) } + if (best != null) { + return commandFactory.artist(best, ShuffleMode.OFF) + } } MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> { val genreQuery = extras.getString(MediaStore.EXTRA_MEDIA_GENRE) val best = deviceLibrary.genres.maxByOrNull { fuzzy(it.name, genreQuery) } - best?.let { commandFactory.genre(it, ShuffleMode.OFF) } + if (best != null) { + return commandFactory.genre(best, ShuffleMode.OFF) + } } MediaStore.Audio.Playlists.ENTRY_CONTENT_TYPE -> { val playlistQuery = extras.getString(MediaStore.EXTRA_MEDIA_PLAYLIST) val best = userLibrary.playlists.maxByOrNull { fuzzy(it.name, playlistQuery) } - best?.let { commandFactory.playlist(it, ShuffleMode.OFF) } + if (best != null) { + return commandFactory.playlist(best, ShuffleMode.OFF) + } } - else -> null - } - - if (bestCommand != null) { - return bestCommand + else -> {} } val bestMusic = (deviceLibrary.songs + deviceLibrary.albums + deviceLibrary.artists + deviceLibrary.genres + userLibrary.playlists) .maxByOrNull { fuzzy(it.name, query) } - return bestMusic?.let { expandMusicIntoCommand(it, null) } + // TODO: Error out when we can't correctly resolve the query + return bestMusic?.let { expandMusicIntoCommand(it, null) } ?: commandFactory.all(ShuffleMode.ON) } private fun fuzzy(name: Name, query: String?): Double = From 889713d5e035fecef71ce8fe153c023c1343a39b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 09:29:46 -0600 Subject: [PATCH 43/87] playback: improve queue item setup - Use same media description code - Make queue removal more reliable --- .../music/service/MediaItemTranslation.kt | 35 +++++----- .../playback/service/MediaSessionHolder.kt | 17 +---- .../playback/service/MediaSessionInterface.kt | 66 +++++++++++-------- 3 files changed, 59 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 72b0f7e7e..cc250c342 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -105,11 +105,8 @@ fun Category.toMediaItem(context: Context): MediaItem { return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } -fun Song.toMediaItem( - context: Context, - parent: MusicParent? = null, - vararg sugar: Sugar -): MediaItem { +fun Song.toMediaDescription(context: Context, parent: MusicParent? = null, + vararg sugar: Sugar): MediaDescriptionCompat { val mediaSessionUID = if (parent == null) { MediaSessionUID.SingleItem(uid) @@ -117,17 +114,23 @@ fun Song.toMediaItem( MediaSessionUID.ChildItem(parent.uid, uid) } val extras = Bundle().apply { sugar.forEach { this.it(context) } } - val description = - MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setDescription(album.name.resolve(context)) - .setIconUri(album.cover.single.mediaStoreCoverUri) - .setMediaUri(uri) - .setExtras(extras) - .build() - return MediaItem(description, MediaItem.FLAG_PLAYABLE) + return MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setDescription(album.name.resolve(context)) + .setIconUri(cover.mediaStoreCoverUri) + .setMediaUri(uri) + .setExtras(extras) + .build() +} + +fun Song.toMediaItem( + context: Context, + parent: MusicParent? = null, + vararg sugar: Sugar +): MediaItem { + return MediaItem(toMediaDescription(context, parent, *sugar), MediaItem.FLAG_PLAYABLE) } fun Album.toMediaItem( diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index f89016f31..a07c2ad30 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -42,6 +42,8 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.music.service.toMediaDescription +import org.oxycblt.auxio.music.service.toMediaItem import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.service.MediaSessionInterface @@ -304,20 +306,7 @@ private constructor( private fun updateQueue(queue: List) { val queueItems = queue.mapIndexed { i, song -> - val description = - MediaDescriptionCompat.Builder() - // Media ID should not be the item index but rather the UID, - // as it's used to request a song to be played from the queue. - .setMediaId(song.uid.toString()) - .setTitle(song.name.resolve(context)) - .setSubtitle(song.artists.resolveNames(context)) - // Since we usually have to load many songs into the queue, use the - // MediaStore URI instead of loading a bitmap. - .setIconUri(song.album.cover.single.mediaStoreCoverUri) - .setMediaUri(song.uri) - .setExtras( - Bundle().apply { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) - .build() + val description = song.toMediaDescription(context, null, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) // Store the item index so we can then use the analogous index in the // playback state. MediaSessionCompat.QueueItem(description, i.toLong()) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index dbe726dde..3ab8eaa1e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -93,22 +93,11 @@ constructor( super.onPlayFromSearch(query, extras) val deviceLibrary = musicRepository.deviceLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return - val command = expandSearchInfoCommand( - query.ifBlank { null }, - extras, - deviceLibrary, - userLibrary) + val command = + expandSearchInfoCommand(query.ifBlank { null }, extras, deviceLibrary, userLibrary) playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) } - data class QueryBundle( - val title: String?, - val album: String?, - val artist: String?, - val genre: String?, - val playlist: String? - ) - override fun onAddQueueItem(description: MediaDescriptionCompat) { super.onAddQueueItem(description) val deviceLibrary = musicRepository.deviceLibrary ?: return @@ -125,8 +114,22 @@ constructor( override fun onRemoveQueueItem(description: MediaDescriptionCompat) { super.onRemoveQueueItem(description) - val at = description.extras?.getInt(KEY_QUEUE_POS) ?: return - playbackManager.removeQueueItem(at) + val at = description.extras?.getInt(KEY_QUEUE_POS) + if (at != null) { + // Direct queue item removal w/preserved extras, we can explicitly remove + // the correct item rather than a duplicate elsewhere. + playbackManager.removeQueueItem(at) + return + } + // Non-queue item or queue item lost it's extras in transit, remove the first item + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val songUid = when (uid) { + is MediaSessionUID.SingleItem -> uid.uid + is MediaSessionUID.ChildItem -> uid.childUid + else -> return + } + val firstAt = playbackManager.queue.indexOfFirst { it.uid == songUid } + playbackManager.removeQueueItem(firstAt) } override fun onPlay() { @@ -224,26 +227,28 @@ constructor( val songQuery = extras.getString(MediaStore.EXTRA_MEDIA_TITLE) val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) - val best = deviceLibrary.songs.maxByOrNull { - fuzzy(it.name, songQuery) + fuzzy(it.album.name, albumQuery) + + val best = + deviceLibrary.songs.maxByOrNull { + fuzzy(it.name, songQuery) + + fuzzy(it.album.name, albumQuery) + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } - } + } if (best != null) { return expandSongIntoCommand(best, null) } } - MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> { val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) - val best = deviceLibrary.albums.maxByOrNull { - fuzzy(it.name, albumQuery) + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } - } + val best = + deviceLibrary.albums.maxByOrNull { + fuzzy(it.name, albumQuery) + + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } + } if (best != null) { return commandFactory.album(best, ShuffleMode.OFF) } } - MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> { val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) val best = deviceLibrary.artists.maxByOrNull { fuzzy(it.name, artistQuery) } @@ -251,7 +256,6 @@ constructor( return commandFactory.artist(best, ShuffleMode.OFF) } } - MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> { val genreQuery = extras.getString(MediaStore.EXTRA_MEDIA_GENRE) val best = deviceLibrary.genres.maxByOrNull { fuzzy(it.name, genreQuery) } @@ -259,7 +263,6 @@ constructor( return commandFactory.genre(best, ShuffleMode.OFF) } } - MediaStore.Audio.Playlists.ENTRY_CONTENT_TYPE -> { val playlistQuery = extras.getString(MediaStore.EXTRA_MEDIA_PLAYLIST) val best = userLibrary.playlists.maxByOrNull { fuzzy(it.name, playlistQuery) } @@ -267,14 +270,19 @@ constructor( return commandFactory.playlist(best, ShuffleMode.OFF) } } - else -> {} } - val bestMusic = (deviceLibrary.songs + deviceLibrary.albums + deviceLibrary.artists + deviceLibrary.genres + userLibrary.playlists) - .maxByOrNull { fuzzy(it.name, query) } + val bestMusic = + (deviceLibrary.songs + + deviceLibrary.albums + + deviceLibrary.artists + + deviceLibrary.genres + + userLibrary.playlists) + .maxByOrNull { fuzzy(it.name, query) } // TODO: Error out when we can't correctly resolve the query - return bestMusic?.let { expandMusicIntoCommand(it, null) } ?: commandFactory.all(ShuffleMode.ON) + return bestMusic?.let { expandMusicIntoCommand(it, null) } + ?: commandFactory.all(ShuffleMode.ON) } private fun fuzzy(name: Name, query: String?): Double = From bf50867b372b09ce8937eb652bed7818527cf1e0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 09:31:15 -0600 Subject: [PATCH 44/87] all: various cleanup --- .../music/service/MediaItemTranslation.kt | 7 +++++-- .../playback/service/MediaSessionHolder.kt | 7 +++---- .../playback/service/MediaSessionInterface.kt | 21 ++++++++++--------- ...ackActionHandler.kt => PlaybackActions.kt} | 2 +- 4 files changed, 20 insertions(+), 17 deletions(-) rename app/src/main/java/org/oxycblt/auxio/playback/service/{PlaybackActionHandler.kt => PlaybackActions.kt} (96%) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index cc250c342..7cb6b4000 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -105,8 +105,11 @@ fun Category.toMediaItem(context: Context): MediaItem { return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } -fun Song.toMediaDescription(context: Context, parent: MusicParent? = null, - vararg sugar: Sugar): MediaDescriptionCompat { +fun Song.toMediaDescription( + context: Context, + parent: MusicParent? = null, + vararg sugar: Sugar +): MediaDescriptionCompat { val mediaSessionUID = if (parent == null) { MediaSessionUID.SingleItem(uid) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index a07c2ad30..c7b100792 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -21,8 +21,6 @@ package org.oxycblt.auxio.playback.system import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap -import android.os.Bundle -import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat @@ -43,7 +41,6 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.music.service.toMediaDescription -import org.oxycblt.auxio.music.service.toMediaItem import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.service.MediaSessionInterface @@ -306,7 +303,9 @@ private constructor( private fun updateQueue(queue: List) { val queueItems = queue.mapIndexed { i, song -> - val description = song.toMediaDescription(context, null, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) + val description = + song.toMediaDescription( + context, null, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) // Store the item index so we can then use the analogous index in the // playback state. MediaSessionCompat.QueueItem(description, i.toLong()) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 3ab8eaa1e..3f846ad4f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -102,13 +102,13 @@ constructor( super.onAddQueueItem(description) val deviceLibrary = musicRepository.deviceLibrary ?: return val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = + val songUid = when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null + is MediaSessionUID.SingleItem -> uid.uid + is MediaSessionUID.ChildItem -> uid.childUid + else -> return } - ?: return + val song = deviceLibrary.songs.find { it.uid == songUid } ?: return playbackManager.addToQueue(song) } @@ -123,11 +123,12 @@ constructor( } // Non-queue item or queue item lost it's extras in transit, remove the first item val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val songUid = when (uid) { - is MediaSessionUID.SingleItem -> uid.uid - is MediaSessionUID.ChildItem -> uid.childUid - else -> return - } + val songUid = + when (uid) { + is MediaSessionUID.SingleItem -> uid.uid + is MediaSessionUID.ChildItem -> uid.childUid + else -> return + } val firstAt = playbackManager.queue.indexOfFirst { it.uid == songUid } playbackManager.removeQueueItem(firstAt) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt index 441bf5253..484cb8541 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * PlaybackActionHandler.kt is part of Auxio. + * PlaybackActions.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 From b43dbb3e89c409e0a56da3096bdf5a2a1279cf89 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 09:55:02 -0600 Subject: [PATCH 45/87] playback: define menu options --- .../music/service/MediaItemTranslation.kt | 16 ++++++++++++++ .../music/service/MusicServiceFragment.kt | 22 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 7cb6b4000..821b8a961 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -78,6 +78,22 @@ sealed interface MediaSessionUID { } } +enum class MediaMenuItem(val id: String, val labelRes: Int, val iconRes: Int) { + PLAY(BuildConfig.APPLICATION_ID + ".menu.PLAY", R.string.lbl_play, R.drawable.ic_play_24), + SHUFFLE( + BuildConfig.APPLICATION_ID + ".menu.SHUFFLE", + R.string.lbl_shuffle, + R.drawable.ic_shuffle_off_24), + PLAY_NEXT( + BuildConfig.APPLICATION_ID + ".menu.PLAY_NEXT", + R.string.lbl_play_next, + R.drawable.ic_play_next_24), + ADD_TO_QUEUE( + BuildConfig.APPLICATION_ID + ".menu.ADD_TO_QUEUE", + R.string.lbl_queue_add, + R.drawable.ic_queue_add_24) +} + typealias Sugar = Bundle.(Context) -> Unit fun header(@StringRes nameRes: Int): Sugar = { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 51563a49b..8f24bcd5b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -18,9 +18,13 @@ package org.oxycblt.auxio.music.service +import android.content.Context +import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.media.MediaBrowserServiceCompat.BrowserRoot import androidx.media.MediaBrowserServiceCompat.Result +import androidx.media.utils.MediaConstants +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -35,6 +39,7 @@ import org.oxycblt.auxio.util.logW class MusicServiceFragment @Inject constructor( + @ApplicationContext private val context: Context, private val indexer: Indexer, private val musicBrowser: MusicBrowser, private val musicRepository: MusicRepository @@ -77,7 +82,22 @@ constructor( } fun getRoot(maxItems: Int) = - BrowserRoot(MediaSessionUID.CategoryItem(Category.Root(maxItems)).toString(), null) + BrowserRoot( + MediaSessionUID.CategoryItem(Category.Root(maxItems)).toString(), + Bundle().apply { + val actions = + MediaMenuItem.entries.mapTo(ArrayList()) { + Bundle().apply { + putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.id) + putString( + MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL, + context.getString(it.labelRes)) + } + } + putParcelableArrayList( + MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST, + actions) + }) fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } From a29f747341055539cbc635080d5f125a3155e56c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 16:39:07 -0600 Subject: [PATCH 46/87] music: build session menus from resources --- .../music/service/MediaItemTranslation.kt | 69 +++++++++++++++---- .../music/service/MusicServiceFragment.kt | 4 +- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 821b8a961..85c7d7675 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -18,12 +18,17 @@ package org.oxycblt.auxio.music.service +import android.annotation.SuppressLint import android.content.Context import android.graphics.BitmapFactory import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat +import android.view.MenuInflater +import androidx.annotation.MenuRes import androidx.annotation.StringRes +import androidx.appcompat.view.menu.MenuBuilder +import androidx.core.view.children import androidx.media.utils.MediaConstants import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R @@ -78,7 +83,7 @@ sealed interface MediaSessionUID { } } -enum class MediaMenuItem(val id: String, val labelRes: Int, val iconRes: Int) { +enum class BrowserOption(val actionId: String, val labelRes: Int, val iconRes: Int) { PLAY(BuildConfig.APPLICATION_ID + ".menu.PLAY", R.string.lbl_play, R.drawable.ic_play_24), SHUFFLE( BuildConfig.APPLICATION_ID + ".menu.SHUFFLE", @@ -91,7 +96,31 @@ enum class MediaMenuItem(val id: String, val labelRes: Int, val iconRes: Int) { ADD_TO_QUEUE( BuildConfig.APPLICATION_ID + ".menu.ADD_TO_QUEUE", R.string.lbl_queue_add, - R.drawable.ic_queue_add_24) + R.drawable.ic_queue_add_24), + DETAILS( + BuildConfig.APPLICATION_ID + ".menu.DETAILS", + R.string.lbl_parent_detail, + R.drawable.ic_details_24), + ALBUM_DETAILS( + BuildConfig.APPLICATION_ID + ".menu.ALBUM_DETAILS", + R.string.lbl_album_details, + R.drawable.ic_album_24), + ARTIST_DETAILS( + BuildConfig.APPLICATION_ID + ".menu.ARTIST_DETAILS", + R.string.lbl_artist_details, + R.drawable.ic_artist_24), + + companion object { + +val ITEM_ID_MAP = mapOf( + R.id.action_play to BrowserOption.PLAY, + R.id.action_shuffle to BrowserOption.SHUFFLE, + R.id.action_play_next to BrowserOption.PLAY_NEXT, + R.id.action_queue_add to BrowserOption.ADD_TO_QUEUE, + R.id.action_detail to BrowserOption.DETAILS, + R.id.action_album_details to BrowserOption.ALBUM_DETAILS, + R.id.action_artist_details to BrowserOption.ARTIST_DETAILS + )} } typealias Sugar = Bundle.(Context) -> Unit @@ -101,13 +130,29 @@ fun header(@StringRes nameRes: Int): Sugar = { MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) } +private fun style(style: Int): Sugar = { + putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style) +} + +private fun menu(@MenuRes res: Int): Sugar = { context -> + @SuppressLint("RestrictedApi") val builder = MenuBuilder(context) + MenuInflater(context).inflate(res, builder) + val menuIds = builder.children.mapNotNullTo(ArrayList()){ + BrowserOption.ITEM_ID_MAP[it.itemId]?.actionId + } + putStringArrayList( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, menuIds) +} + +private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle { + return Bundle().apply { sugars.forEach { this.it(context) } } +} + fun Category.toMediaItem(context: Context): MediaItem { val extras = - Bundle().apply { - putInt( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) - } + makeExtras( + context, + style(MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)) val mediaSessionUID = MediaSessionUID.CategoryItem(this) val description = MediaDescriptionCompat.Builder() @@ -132,7 +177,7 @@ fun Song.toMediaDescription( } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val extras = makeExtras(context, *sugar, menu(R.menu.song)) return MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) @@ -163,7 +208,7 @@ fun Album.toMediaItem( } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val extras = makeExtras(context, *sugar, menu(R.menu.album)) val counts = context.getPlural(R.plurals.fmt_song_count, songs.size) val description = MediaDescriptionCompat.Builder() @@ -192,7 +237,7 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) }) - val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val extras = makeExtras(context, *sugar, menu(R.menu.parent)) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -213,7 +258,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } - val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val extras = makeExtras(context, *sugar, menu(R.menu.parent)) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -233,7 +278,7 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } - val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val extras = makeExtras(context, *sugar, menu(R.menu.playlist)) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 8f24bcd5b..4b181e417 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -86,9 +86,9 @@ constructor( MediaSessionUID.CategoryItem(Category.Root(maxItems)).toString(), Bundle().apply { val actions = - MediaMenuItem.entries.mapTo(ArrayList()) { + BrowserOption.entries.mapTo(ArrayList()) { Bundle().apply { - putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.id) + putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.actionId) putString( MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL, context.getString(it.labelRes)) From 463b02f8715620a10d8d2e4eb41cf8beb1a8a179 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 20:59:32 -0600 Subject: [PATCH 47/87] service: remove external media3 support --- app/src/main/AndroidManifest.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cbf7ea304..308962b34 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -94,7 +94,6 @@ android:exported="true" android:roundIcon="@mipmap/ic_launcher"> - From 6ff2d55a6889b435c861dd18b82471552c507cf5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:00:13 -0600 Subject: [PATCH 48/87] music: fix category id --- .../org/oxycblt/auxio/music/service/MediaItemTranslation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 85c7d7675..d80613ea6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.getPlural sealed interface MediaSessionUID { data class CategoryItem(val category: Category) : MediaSessionUID { - override fun toString() = "$ID_CATEGORY:$category" + override fun toString() = "$ID_CATEGORY:${category.id}" } data class SingleItem(val uid: Music.UID) : MediaSessionUID { From 2bc4ed020b0e7553acc7b4cbd6e1667b0a0d6246 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:03:26 -0600 Subject: [PATCH 49/87] playback: fix broken mediasession lifecycle --- .../playback/service/MediaSessionHolder.kt | 48 ++++++++----------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index c7b100792..5646a0e0e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -43,8 +43,6 @@ import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.music.service.toMediaDescription import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.service.MediaSessionInterface -import org.oxycblt.auxio.playback.service.PlaybackActions import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.QueueChange @@ -62,34 +60,32 @@ import org.oxycblt.auxio.util.newMainPendingIntent class MediaSessionHolder private constructor( private val context: Context, - private val sessionInterface: MediaSessionInterface, + private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val bitmapProvider: BitmapProvider, - private val imageSettings: ImageSettings -) : - MediaSessionCompat.Callback(), - PlaybackStateManager.Listener, - ImageSettings.Listener, - PlaybackSettings.Listener { + private val imageSettings: ImageSettings, + private val mediaSessionInterface: MediaSessionInterface +) : PlaybackStateManager.Listener, ImageSettings.Listener, PlaybackSettings.Listener { class Factory @Inject constructor( - private val sessionInterface: MediaSessionInterface, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings, + private val mediaSessionInterface: MediaSessionInterface ) { - fun create(context: Context) = + fun create(context: Context, foregroundListener: ForegroundListener) = MediaSessionHolder( context, - sessionInterface, + foregroundListener, playbackManager, playbackSettings, bitmapProvider, - imageSettings) + imageSettings, + mediaSessionInterface) } private val mediaSession = MediaSessionCompat(context, context.packageName) @@ -100,20 +96,15 @@ private constructor( val notification: ForegroundServiceNotification get() = _notification - private var foregroundListener: ForegroundListener? = null - - fun attach(foregroundListener: ForegroundListener) { - mediaSession.apply { - isActive = true - setQueueTitle(context.getString(R.string.lbl_queue)) - setCallback(sessionInterface) - setFlags(MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS) - } - this.foregroundListener = foregroundListener + init { playbackManager.addListener(this) playbackSettings.registerListener(this) imageSettings.registerListener(this) - mediaSession.setCallback(this) + mediaSession.apply { + isActive = true + setQueueTitle(context.getString(R.string.lbl_queue)) + setCallback(mediaSessionInterface) + } } /** @@ -121,7 +112,6 @@ private constructor( * the [NotificationComponent]. */ fun release() { - foregroundListener = null bitmapProvider.release() playbackSettings.unregisterListener(this) imageSettings.unregisterListener(this) @@ -179,7 +169,7 @@ private constructor( invalidateSessionState() _notification.updatePlaying(playbackManager.progression.isPlaying) if (!bitmapProvider.isBusy) { - foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } } @@ -272,7 +262,7 @@ private constructor( song.date?.let { logD("Adding date information") builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) - builder.putString(MediaMetadataCompat.METADATA_KEY_YEAR, it.year.toString()) + builder.putLong(MediaMetadataCompat.METADATA_KEY_YEAR, it.year.toLong()) } // We are normally supposed to use URIs for album art, but that removes some of the @@ -290,7 +280,7 @@ private constructor( val metadata = builder.build() mediaSession.setMetadata(metadata) _notification.updateMetadata(metadata) - foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } }) } @@ -382,7 +372,7 @@ private constructor( if (!bitmapProvider.isBusy) { logD("Not loading a bitmap, post the notification") - foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } } From 4e4a99bbf3784f1882a4cbf4e968172e38ce4360 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:04:06 -0600 Subject: [PATCH 50/87] music: fix crash on browser child load --- app/src/main/java/org/oxycblt/auxio/AuxioService.kt | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 4f45fff6a..5c556844a 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -94,14 +94,9 @@ class AuxioService : musicFragment.getItem(itemId, result) } - override fun onLoadChildren(parentId: String, result: Result>) = - throw NotImplementedError() - - override fun onLoadChildren( - parentId: String, - result: Result>, - options: Bundle - ) = musicFragment.getChildren(parentId, result) + override fun onLoadChildren(parentId: String, result: Result>) { + musicFragment.getChildren(parentId, result) + } override fun onSearch(query: String, extras: Bundle?, result: Result>) { musicFragment.search(query, result) From 3af81404ac573ebec0ccdc5e5b599715bd2ed2e9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:05:35 -0600 Subject: [PATCH 51/87] playback: fix mediasessionholder instantiation --- .../oxycblt/auxio/playback/service/PlaybackServiceFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 42e60d7fd..ceed7bb58 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -59,7 +59,7 @@ constructor( foregroundListener = listener playbackManager.addListener(this) exoHolder.attach() - sessionHolder = sessionHolderFactory.create(context) + sessionHolder = sessionHolderFactory.create(context, listener) systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) ContextCompat.registerReceiver( context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) From 48568d2a1dde47ba2f390396de26617d99448f20 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:05:48 -0600 Subject: [PATCH 52/87] playback: fix mediasessionholder package --- .../org/oxycblt/auxio/playback/service/MediaSessionHolder.kt | 2 +- .../oxycblt/auxio/playback/service/PlaybackServiceFragment.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 5646a0e0e..f683876d2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.system +package org.oxycblt.auxio.playback.service import android.annotation.SuppressLint import android.content.Context diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index ceed7bb58..0bb5b9fda 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -31,7 +31,6 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.system.MediaSessionHolder import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent From 2857f7d92c80dce01e4eda15e16a2ebeaf7e82dd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:07:32 -0600 Subject: [PATCH 53/87] all: format/syntax fixes --- .../music/service/MediaItemTranslation.kt | 32 +++++++++---------- .../music/service/MusicServiceFragment.kt | 3 +- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index d80613ea6..8fb340a01 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -108,19 +108,19 @@ enum class BrowserOption(val actionId: String, val labelRes: Int, val iconRes: I ARTIST_DETAILS( BuildConfig.APPLICATION_ID + ".menu.ARTIST_DETAILS", R.string.lbl_artist_details, - R.drawable.ic_artist_24), + R.drawable.ic_artist_24); companion object { - -val ITEM_ID_MAP = mapOf( - R.id.action_play to BrowserOption.PLAY, - R.id.action_shuffle to BrowserOption.SHUFFLE, - R.id.action_play_next to BrowserOption.PLAY_NEXT, - R.id.action_queue_add to BrowserOption.ADD_TO_QUEUE, - R.id.action_detail to BrowserOption.DETAILS, - R.id.action_album_details to BrowserOption.ALBUM_DETAILS, - R.id.action_artist_details to BrowserOption.ARTIST_DETAILS - )} + val ITEM_ID_MAP = + mapOf( + R.id.action_play to PLAY, + R.id.action_shuffle to SHUFFLE, + R.id.action_play_next to PLAY_NEXT, + R.id.action_queue_add to ADD_TO_QUEUE, + R.id.action_detail to DETAILS, + R.id.action_album_details to ALBUM_DETAILS, + R.id.action_artist_details to ARTIST_DETAILS) + } } typealias Sugar = Bundle.(Context) -> Unit @@ -137,11 +137,11 @@ private fun style(style: Int): Sugar = { private fun menu(@MenuRes res: Int): Sugar = { context -> @SuppressLint("RestrictedApi") val builder = MenuBuilder(context) MenuInflater(context).inflate(res, builder) - val menuIds = builder.children.mapNotNullTo(ArrayList()){ - BrowserOption.ITEM_ID_MAP[it.itemId]?.actionId - } - putStringArrayList( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, menuIds) + val menuIds = + builder.children.mapNotNullTo(ArrayList()) { + BrowserOption.ITEM_ID_MAP[it.itemId]?.actionId + } + putStringArrayList(MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, menuIds) } private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 4b181e417..8b212a89c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -88,7 +88,8 @@ constructor( val actions = BrowserOption.entries.mapTo(ArrayList()) { Bundle().apply { - putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.actionId) + putString( + MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.actionId) putString( MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL, context.getString(it.labelRes)) From fd597ea16ad7a1591a9a9ccc995e7da55fde96b7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 30 Aug 2024 10:18:50 -0600 Subject: [PATCH 54/87] music: fix root menus shown --- .../main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index b62f9b248..9988a049b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -193,7 +193,7 @@ constructor( ) = when (category) { is Category.Root -> { - val base = Category.MUSIC.take(category.amount) + val base = Category.MUSIC.take(category.amount - 1) if (base.size < Category.MUSIC.size) { base + Category.More(Category.MUSIC.size - base.size) } else { From e4310cfe170e3579b8a02c967890e2145d7a8b22 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 30 Aug 2024 10:19:31 -0600 Subject: [PATCH 55/87] music: fix broken android auto search --- .../org/oxycblt/auxio/music/service/MusicServiceFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 8b212a89c..ba26d4778 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -123,9 +123,9 @@ constructor( } private fun Result.dispatchAsync(body: suspend () -> T?) { + detach() dispatchScope.launch { try { - detach() val result = body() if (result == null) { logW("Result is null") From 29d663f5000358ccc321f4bc855217f6b728f891 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:21 -0600 Subject: [PATCH 56/87] service: share home list logic between service/ui --- .../org/oxycblt/auxio/home/HomeSettings.kt | 4 +- .../org/oxycblt/auxio/home/HomeViewModel.kt | 78 +++++-------- .../auxio/home/list/HomeListGenerator.kt | 110 ++++++++++++++++++ .../org/oxycblt/auxio/list/ListSettings.kt | 22 +++- .../auxio/music/service/MusicBrowser.kt | 51 ++++---- 5 files changed, 187 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt index 5fc218cfe..ec54942f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt @@ -42,9 +42,9 @@ interface HomeSettings : Settings { interface Listener { /** Called when the [homeTabs] configuration changes. */ - fun onTabsChanged() + fun onTabsChanged() {} /** Called when the [shouldHideCollaborators] configuration changes. */ - fun onHideCollaboratorsChanged() + fun onHideCollaboratorsChanged() {} } } 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 51bc04976..d681d9ac1 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -23,6 +23,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.home.list.HomeListGenerator import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions @@ -52,8 +53,9 @@ constructor( private val homeSettings: HomeSettings, private val listSettings: ListSettings, private val playbackSettings: PlaybackSettings, - private val musicRepository: MusicRepository, -) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener { + homeGeneratorFactory: HomeListGenerator.Factory +) : ViewModel(), HomeSettings.Listener, HomeListGenerator.Invalidator { + private val generator = homeGeneratorFactory.create(this) private val _songList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ @@ -165,46 +167,37 @@ constructor( get() = _showOuter init { - musicRepository.addUpdateListener(this) homeSettings.registerListener(this) } override fun onCleared() { super.onCleared() - musicRepository.removeUpdateListener(this) homeSettings.unregisterListener(this) + generator.release() } - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary - if (changes.deviceLibrary && deviceLibrary != null) { - logD("Refreshing library") - // Get the each list of items in the library to use as our list data. - // Applying the preferred sorting to them. - _songInstructions.put(UpdateInstructions.Diff) - _songList.value = listSettings.songSort.songs(deviceLibrary.songs) - _albumInstructions.put(UpdateInstructions.Diff) - _albumList.value = listSettings.albumSort.albums(deviceLibrary.albums) - _artistInstructions.put(UpdateInstructions.Diff) - _artistList.value = - listSettings.artistSort.artists( - if (homeSettings.shouldHideCollaborators) { - logD("Filtering collaborator artists") - // Hide Collaborators is enabled, filter out collaborators. - deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() } - } else { - logD("Using all artists") - deviceLibrary.artists - }) - _genreInstructions.put(UpdateInstructions.Diff) - _genreList.value = listSettings.genreSort.genres(deviceLibrary.genres) - } - - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - logD("Refreshing playlists") - _playlistInstructions.put(UpdateInstructions.Diff) - _playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists) + override fun invalidate(type: MusicType, instructions: UpdateInstructions) { + when (type) { + MusicType.SONGS -> { + _songList.value = generator.songs() + _songInstructions.put(instructions) + } + MusicType.ALBUMS -> { + _albumList.value = generator.albums() + _albumInstructions.put(instructions) + } + MusicType.ARTISTS -> { + _artistList.value = generator.artists() + _artistInstructions.put(instructions) + } + MusicType.GENRES -> { + _genreList.value = generator.genres() + _genreInstructions.put(instructions) + } + MusicType.PLAYLISTS -> { + _playlistList.value = generator.playlists() + _playlistInstructions.put(instructions) + } } } @@ -215,13 +208,6 @@ constructor( _shouldRecreate.put(Unit) } - override fun onHideCollaboratorsChanged() { - // Changes in the hide collaborator setting will change the artist contents - // of the library, consider it a library update. - logD("Collaborator setting changed, forwarding update") - onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) - } - /** * Apply a new [Sort] to [songList]. * @@ -229,8 +215,6 @@ constructor( */ fun applySongSort(sort: Sort) { listSettings.songSort = sort - _songInstructions.put(UpdateInstructions.Replace(0)) - _songList.value = listSettings.songSort.songs(_songList.value) } /** @@ -240,8 +224,6 @@ constructor( */ fun applyAlbumSort(sort: Sort) { listSettings.albumSort = sort - _albumInstructions.put(UpdateInstructions.Replace(0)) - _albumList.value = listSettings.albumSort.albums(_albumList.value) } /** @@ -251,8 +233,6 @@ constructor( */ fun applyArtistSort(sort: Sort) { listSettings.artistSort = sort - _artistInstructions.put(UpdateInstructions.Replace(0)) - _artistList.value = listSettings.artistSort.artists(_artistList.value) } /** @@ -262,8 +242,6 @@ constructor( */ fun applyGenreSort(sort: Sort) { listSettings.genreSort = sort - _genreInstructions.put(UpdateInstructions.Replace(0)) - _genreList.value = listSettings.genreSort.genres(_genreList.value) } /** @@ -273,8 +251,6 @@ constructor( */ fun applyPlaylistSort(sort: Sort) { listSettings.playlistSort = sort - _playlistInstructions.put(UpdateInstructions.Replace(0)) - _playlistList.value = listSettings.playlistSort.playlists(_playlistList.value) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt new file mode 100644 index 000000000..faca4bb56 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt @@ -0,0 +1,110 @@ +package org.oxycblt.auxio.home.list + +import org.oxycblt.auxio.home.HomeSettings +import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.util.logD +import javax.inject.Inject + +interface HomeListGenerator { + fun songs(): List + fun albums(): List + fun artists(): List + fun genres(): List + fun playlists(): List + fun release() + + interface Invalidator { + fun invalidate(type: MusicType, instructions: UpdateInstructions) + } + + interface Factory { + fun create(invalidator: Invalidator): HomeListGenerator + } +} + +private class HomeListGeneratorImpl( + private val invalidator: HomeListGenerator.Invalidator, + private val homeSettings: HomeSettings, + private val listSettings: ListSettings, + private val musicRepository: MusicRepository, +) : HomeListGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { + override fun songs() = + musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() + override fun albums() = musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList() + override fun artists() = musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } ?: emptyList() + override fun genres() = musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList() + override fun playlists() = musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } ?: emptyList() + + init { + homeSettings.registerListener(this) + listSettings.registerListener(this) + musicRepository.addUpdateListener(this) + } + + override fun release() { + homeSettings.unregisterListener(this) + listSettings.unregisterListener(this) + musicRepository.removeUpdateListener(this) + } + + override fun onHideCollaboratorsChanged() { + // Changes in the hide collaborator setting will change the artist contents + // of the library, consider it a library update. + logD("Collaborator setting changed, forwarding update") + onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) + } + + override fun onSongSortChanged() { + super.onSongSortChanged() + invalidator.invalidate(MusicType.SONGS, UpdateInstructions.Replace(0)) + } + + override fun onAlbumSortChanged() { + super.onAlbumSortChanged() + invalidator.invalidate(MusicType.ALBUMS, UpdateInstructions.Replace(0)) + } + + override fun onArtistSortChanged() { + super.onArtistSortChanged() + invalidator.invalidate(MusicType.ARTISTS, UpdateInstructions.Replace(0)) + } + + override fun onGenreSortChanged() { + super.onGenreSortChanged() + invalidator.invalidate(MusicType.GENRES, UpdateInstructions.Replace(0)) + } + + override fun onPlaylistSortChanged() { + super.onPlaylistSortChanged() + invalidator.invalidate(MusicType.PLAYLISTS, UpdateInstructions.Replace(0)) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary + if (changes.deviceLibrary && deviceLibrary != null) { + logD("Refreshing library") + // Get the each list of items in the library to use as our list data. + // Applying the preferred sorting to them. + invalidator.invalidate(MusicType.SONGS, UpdateInstructions.Diff) + invalidator.invalidate(MusicType.ALBUMS, UpdateInstructions.Diff) + invalidator.invalidate(MusicType.ARTISTS, UpdateInstructions.Diff) + invalidator.invalidate(MusicType.GENRES, UpdateInstructions.Diff) + } + + val userLibrary = musicRepository.userLibrary + if (changes.userLibrary && userLibrary != null) { + logD("Refreshing playlists") + invalidator.invalidate(MusicType.PLAYLISTS, UpdateInstructions.Diff) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt index 3f3388b73..9b0bb7f4f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt @@ -26,7 +26,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.settings.Settings -interface ListSettings : Settings { +interface ListSettings : Settings { /** The [Sort] mode used in Song lists. */ var songSort: Sort /** The [Sort] mode used in Album lists. */ @@ -43,10 +43,18 @@ interface ListSettings : Settings { var artistSongSort: Sort /** The [Sort] mode used in a Genre's Song list. */ var genreSongSort: Sort + + interface Listener { + fun onSongSortChanged() {} + fun onAlbumSortChanged() {} + fun onArtistSortChanged() {} + fun onGenreSortChanged() {} + fun onPlaylistSortChanged() {} + } } class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) : - Settings.Impl(context), ListSettings { + Settings.Impl(context), ListSettings { override var songSort: Sort get() = Sort.fromIntCode( @@ -145,4 +153,14 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont apply() } } + + override fun onSettingChanged(key: String, listener: ListSettings.Listener) { + when (key) { + getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged() + getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged() + getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged() + getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged() + getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged() + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 9988a049b..c4d83f6f9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -23,13 +23,16 @@ import android.support.v4.media.MediaBrowserCompat.MediaItem import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R +import org.oxycblt.auxio.home.list.HomeListGenerator import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary @@ -42,12 +45,14 @@ constructor( @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, private val searchEngine: SearchEngine, - private val listSettings: ListSettings -) : MusicRepository.UpdateListener { + private val listSettings: ListSettings, + homeGeneratorFactory: HomeListGenerator.Factory +) : MusicRepository.UpdateListener, HomeListGenerator.Invalidator { interface Invalidator { fun invalidateMusic(ids: Set) } + private val generator = homeGeneratorFactory.create(this) private var invalidator: Invalidator? = null fun attach(invalidator: Invalidator) { @@ -59,6 +64,18 @@ constructor( musicRepository.removeUpdateListener(this) } + override fun invalidate(type: MusicType, instructions: UpdateInstructions) { + val category = when (type) { + MusicType.SONGS -> Category.Songs + MusicType.ALBUMS -> Category.Albums + MusicType.ARTISTS -> Category.Artists + MusicType.GENRES -> Category.Genres + MusicType.PLAYLISTS -> Category.Playlists + } + val id = MediaSessionUID.CategoryItem(category).toString() + invalidator?.invalidateMusic(setOf(id)) + } + override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary val invalidate = mutableSetOf() @@ -126,7 +143,7 @@ constructor( return listOf() } - return getMediaItemList(parentId, deviceLibrary, userLibrary) + return getMediaItemList(parentId) } suspend fun search(query: String): MutableList { @@ -166,13 +183,11 @@ constructor( } private fun getMediaItemList( - id: String, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary + id: String ): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { is MediaSessionUID.CategoryItem -> { - getCategoryMediaItems(mediaSessionUID.category, deviceLibrary, userLibrary) + getCategoryMediaItems(mediaSessionUID.category) } is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) @@ -187,9 +202,7 @@ constructor( } private fun getCategoryMediaItems( - category: Category, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary + category: Category ) = when (category) { is Category.Root -> { @@ -203,19 +216,11 @@ constructor( } is Category.More -> Category.MUSIC.takeLast(category.remainder).map { it.toMediaItem(context) } - is Category.Songs -> - listSettings.songSort.songs(deviceLibrary.songs).map { - it.toMediaItem(context, null) - } - is Category.Albums -> - listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } - is Category.Artists -> - listSettings.artistSort.artists(deviceLibrary.artists).map { - it.toMediaItem(context) - } - is Category.Genres -> - listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } - is Category.Playlists -> userLibrary.playlists.map { it.toMediaItem(context) } + is Category.Songs -> generator.songs().map { it.toMediaItem(context) } + is Category.Albums -> generator.albums().map { it.toMediaItem(context) } + is Category.Artists -> generator.artists().map { it.toMediaItem(context) } + is Category.Genres -> generator.genres().map { it.toMediaItem(context) } + is Category.Playlists -> generator.playlists().map { it.toMediaItem(context) } } private fun getChildMediaItems(uid: Music.UID): List? { From 3832c4e525257f29ff221d785bfd907e2d7a1136 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:37 -0600 Subject: [PATCH 57/87] home: mirror tabs to mediasession browser --- .../HomeListGenerator.kt => HomeGenerator.kt} | 51 +++++++------ .../org/oxycblt/auxio/home/HomeViewModel.kt | 46 ++++-------- .../auxio/home/tabs/AdaptiveTabStrategy.kt | 37 +++------- .../java/org/oxycblt/auxio/music/MusicType.kt | 11 +++ .../music/service/MediaItemTranslation.kt | 10 +-- .../auxio/music/service/MusicBrowser.kt | 74 +++++++++---------- .../oxycblt/auxio/music/service/TabNode.kt | 70 ++++++++++++++++++ 7 files changed, 175 insertions(+), 124 deletions(-) rename app/src/main/java/org/oxycblt/auxio/home/{list/HomeListGenerator.kt => HomeGenerator.kt} (67%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt similarity index 67% rename from app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt rename to app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt index faca4bb56..2da76e2e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt @@ -1,6 +1,6 @@ -package org.oxycblt.auxio.home.list +package org.oxycblt.auxio.home -import org.oxycblt.auxio.home.HomeSettings +import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Album @@ -10,39 +10,46 @@ import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD -import javax.inject.Inject -interface HomeListGenerator { +interface HomeGenerator { fun songs(): List fun albums(): List fun artists(): List fun genres(): List fun playlists(): List + fun tabs(): List fun release() interface Invalidator { - fun invalidate(type: MusicType, instructions: UpdateInstructions) + fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) + fun invalidateTabs() } interface Factory { - fun create(invalidator: Invalidator): HomeListGenerator + fun create(invalidator: Invalidator): HomeGenerator } } -private class HomeListGeneratorImpl( - private val invalidator: HomeListGenerator.Invalidator, +private class HomeGeneratorImpl( + private val invalidator: HomeGenerator.Invalidator, private val homeSettings: HomeSettings, private val listSettings: ListSettings, private val musicRepository: MusicRepository, -) : HomeListGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { +) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { override fun songs() = musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() override fun albums() = musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList() override fun artists() = musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } ?: emptyList() override fun genres() = musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList() override fun playlists() = musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } ?: emptyList() + override fun tabs() = + homeSettings.homeTabs.filterIsInstance().map { it.type } + + + override fun onTabsChanged() { + invalidator.invalidateTabs() + } init { homeSettings.registerListener(this) @@ -51,9 +58,9 @@ private class HomeListGeneratorImpl( } override fun release() { - homeSettings.unregisterListener(this) - listSettings.unregisterListener(this) musicRepository.removeUpdateListener(this) + listSettings.unregisterListener(this) + homeSettings.unregisterListener(this) } override fun onHideCollaboratorsChanged() { @@ -65,27 +72,27 @@ private class HomeListGeneratorImpl( override fun onSongSortChanged() { super.onSongSortChanged() - invalidator.invalidate(MusicType.SONGS, UpdateInstructions.Replace(0)) + invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Replace(0)) } override fun onAlbumSortChanged() { super.onAlbumSortChanged() - invalidator.invalidate(MusicType.ALBUMS, UpdateInstructions.Replace(0)) + invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Replace(0)) } override fun onArtistSortChanged() { super.onArtistSortChanged() - invalidator.invalidate(MusicType.ARTISTS, UpdateInstructions.Replace(0)) + invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Replace(0)) } override fun onGenreSortChanged() { super.onGenreSortChanged() - invalidator.invalidate(MusicType.GENRES, UpdateInstructions.Replace(0)) + invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Replace(0)) } override fun onPlaylistSortChanged() { super.onPlaylistSortChanged() - invalidator.invalidate(MusicType.PLAYLISTS, UpdateInstructions.Replace(0)) + invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Replace(0)) } override fun onMusicChanges(changes: MusicRepository.Changes) { @@ -94,16 +101,16 @@ private class HomeListGeneratorImpl( logD("Refreshing library") // Get the each list of items in the library to use as our list data. // Applying the preferred sorting to them. - invalidator.invalidate(MusicType.SONGS, UpdateInstructions.Diff) - invalidator.invalidate(MusicType.ALBUMS, UpdateInstructions.Diff) - invalidator.invalidate(MusicType.ARTISTS, UpdateInstructions.Diff) - invalidator.invalidate(MusicType.GENRES, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff) } val userLibrary = musicRepository.userLibrary if (changes.userLibrary && userLibrary != null) { logD("Refreshing playlists") - invalidator.invalidate(MusicType.PLAYLISTS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) } } 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 d681d9ac1..b2d62a6b6 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -23,15 +23,14 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.home.list.HomeListGenerator import org.oxycblt.auxio.home.tabs.Tab +import org.oxycblt.auxio.home.tabs.TabListGenerator import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song @@ -50,12 +49,11 @@ import org.oxycblt.auxio.util.logD class HomeViewModel @Inject constructor( - private val homeSettings: HomeSettings, private val listSettings: ListSettings, private val playbackSettings: PlaybackSettings, - homeGeneratorFactory: HomeListGenerator.Factory -) : ViewModel(), HomeSettings.Listener, HomeListGenerator.Invalidator { - private val generator = homeGeneratorFactory.create(this) + homeGeneratorFactory: HomeGenerator.Factory +) : ViewModel(), HomeGenerator.Invalidator { + private val homeGenerator = homeGeneratorFactory.create(this) private val _songList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ @@ -138,7 +136,7 @@ constructor( * A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible * [Tab]s. */ - var currentTabTypes = makeTabTypes() + var currentTabTypes = homeGenerator.tabs() private set private val _currentTabType = MutableStateFlow(currentTabTypes[0]) @@ -166,45 +164,38 @@ constructor( val showOuter: Event get() = _showOuter - init { - homeSettings.registerListener(this) - } - override fun onCleared() { super.onCleared() - homeSettings.unregisterListener(this) - generator.release() + homeGenerator.release() } - override fun invalidate(type: MusicType, instructions: UpdateInstructions) { + override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { when (type) { MusicType.SONGS -> { - _songList.value = generator.songs() + _songList.value = homeGenerator.songs() _songInstructions.put(instructions) } MusicType.ALBUMS -> { - _albumList.value = generator.albums() + _albumList.value = homeGenerator.albums() _albumInstructions.put(instructions) } MusicType.ARTISTS -> { - _artistList.value = generator.artists() + _artistList.value = homeGenerator.artists() _artistInstructions.put(instructions) } MusicType.GENRES -> { - _genreList.value = generator.genres() + _genreList.value = homeGenerator.genres() _genreInstructions.put(instructions) } MusicType.PLAYLISTS -> { - _playlistList.value = generator.playlists() + _playlistList.value = homeGenerator.playlists() _playlistInstructions.put(instructions) } } } - override fun onTabsChanged() { - // Tabs changed, update the current tabs and set up a re-create event. - currentTabTypes = makeTabTypes() - logD("Updating tabs: ${currentTabType.value}") + override fun invalidateTabs() { + currentTabTypes = homeGenerator.tabs() _shouldRecreate.put(Unit) } @@ -290,15 +281,6 @@ constructor( fun showAbout() { _showOuter.put(Outer.About) } - - /** - * Create a list of [MusicType]s representing a simpler version of the [Tab] configuration. - * - * @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in - * the same way as the configuration. - */ - private fun makeTabTypes() = - homeSettings.homeTabs.filterIsInstance().map { it.type } } sealed interface Outer { diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt index 73170ef4c..237d8edd6 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -37,40 +37,23 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) : private val width = context.resources.configuration.smallestScreenWidthDp override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { - val icon: Int - val string: Int - - when (tabs[position]) { - MusicType.SONGS -> { - icon = R.drawable.ic_song_24 - string = R.string.lbl_songs - } - MusicType.ALBUMS -> { - icon = R.drawable.ic_album_24 - string = R.string.lbl_albums - } - MusicType.ARTISTS -> { - icon = R.drawable.ic_artist_24 - string = R.string.lbl_artists - } - MusicType.GENRES -> { - icon = R.drawable.ic_genre_24 - string = R.string.lbl_genres - } - MusicType.PLAYLISTS -> { - icon = R.drawable.ic_playlist_24 - string = R.string.lbl_playlists - } + val homeTab = tabs[position] + val icon = when (homeTab) { + MusicType.SONGS -> R.drawable.ic_song_24 + MusicType.ALBUMS -> R.drawable.ic_album_24 + MusicType.ARTISTS -> R.drawable.ic_artist_24 + MusicType.GENRES -> R.drawable.ic_genre_24 + MusicType.PLAYLISTS -> R.drawable.ic_playlist_24 } // Use expected sw* size thresholds when choosing a configuration. when { // On small screens, only display an icon. - width < 370 -> tab.setIcon(icon).setContentDescription(string) + width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes) // On large screens, display an icon and text. - width < 600 -> tab.setText(string) + width < 600 -> tab.setText(homeTab.nameRes).setIcon(icon) // On medium-size screens, display text. - else -> tab.setIcon(icon).setText(string) + else -> tab.setIcon(icon).setText(homeTab.nameRes) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt index 19f535af1..572280f8e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.music import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R /** * General configuration enum to control what kind of music is being worked with. @@ -52,6 +53,16 @@ enum class MusicType { PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS } + val nameRes: Int + get() = + when (this) { + SONGS -> R.string.lbl_songs + ALBUMS -> R.string.lbl_albums + ARTISTS -> R.string.lbl_artists + GENRES -> R.string.lbl_genres + PLAYLISTS -> R.string.lbl_playlists + } + companion object { /** * Convert a [MusicType] integer representation into an instance. diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 8fb340a01..624247f8e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -44,8 +44,8 @@ import org.oxycblt.auxio.playback.formatDurationDs import org.oxycblt.auxio.util.getPlural sealed interface MediaSessionUID { - data class CategoryItem(val category: Category) : MediaSessionUID { - override fun toString() = "$ID_CATEGORY:${category.id}" + data class Tab(val node: TabNode) : MediaSessionUID { + override fun toString() = "$ID_CATEGORY:${node.id}" } data class SingleItem(val uid: Music.UID) : MediaSessionUID { @@ -66,7 +66,7 @@ sealed interface MediaSessionUID { return null } return when (parts[0]) { - ID_CATEGORY -> CategoryItem(Category.fromString(parts[1]) ?: return null) + ID_CATEGORY -> Tab(TabNode.fromString(parts[1]) ?: return null) ID_ITEM -> { val uids = parts[1].split(">", limit = 2) if (uids.size == 1) { @@ -148,12 +148,12 @@ private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle { return Bundle().apply { sugars.forEach { this.it(context) } } } -fun Category.toMediaItem(context: Context): MediaItem { +fun TabNode.toMediaItem(context: Context): MediaItem { val extras = makeExtras( context, style(MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)) - val mediaSessionUID = MediaSessionUID.CategoryItem(this) + val mediaSessionUID = MediaSessionUID.Tab(this) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index c4d83f6f9..0ab9db8db 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -23,7 +23,7 @@ import android.support.v4.media.MediaBrowserCompat.MediaItem import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R -import org.oxycblt.auxio.home.list.HomeListGenerator +import org.oxycblt.auxio.home.HomeGenerator import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.sort.Sort @@ -35,8 +35,6 @@ import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.search.SearchEngine class MusicBrowser @@ -46,13 +44,13 @@ constructor( private val musicRepository: MusicRepository, private val searchEngine: SearchEngine, private val listSettings: ListSettings, - homeGeneratorFactory: HomeListGenerator.Factory -) : MusicRepository.UpdateListener, HomeListGenerator.Invalidator { + homeGeneratorFactory: HomeGenerator.Factory +) : MusicRepository.UpdateListener, HomeGenerator.Invalidator { interface Invalidator { fun invalidateMusic(ids: Set) } - private val generator = homeGeneratorFactory.create(this) + private val homeGenerator = homeGeneratorFactory.create(this) private var invalidator: Invalidator? = null fun attach(invalidator: Invalidator) { @@ -64,26 +62,24 @@ constructor( musicRepository.removeUpdateListener(this) } - override fun invalidate(type: MusicType, instructions: UpdateInstructions) { - val category = when (type) { - MusicType.SONGS -> Category.Songs - MusicType.ALBUMS -> Category.Albums - MusicType.ARTISTS -> Category.Artists - MusicType.GENRES -> Category.Genres - MusicType.PLAYLISTS -> Category.Playlists - } - val id = MediaSessionUID.CategoryItem(category).toString() + override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { + val id = MediaSessionUID.Tab(TabNode.Home(type)).toString() invalidator?.invalidateMusic(setOf(id)) } + override fun invalidateTabs() { + for (i in 0..10) { + // TODO: Temporary bodge, move the amount parameter to a bundle extra + val rootId = MediaSessionUID.Tab(TabNode.Root(i)).toString() + val moreId = MediaSessionUID.Tab(TabNode.More(i)).toString() + invalidator?.invalidateMusic(setOf(rootId, moreId)) + } + } + override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary val invalidate = mutableSetOf() if (changes.deviceLibrary && deviceLibrary != null) { - Category.DEVICE_MUSIC.forEach { - invalidate.add(MediaSessionUID.CategoryItem(it).toString()) - } - deviceLibrary.albums.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() invalidate.add(id) @@ -101,9 +97,6 @@ constructor( } val userLibrary = musicRepository.userLibrary if (changes.userLibrary && userLibrary != null) { - Category.USER_MUSIC.forEach { - invalidate.add(MediaSessionUID.CategoryItem(it).toString()) - } userLibrary.playlists.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() invalidate.add(id) @@ -118,7 +111,7 @@ constructor( fun getItem(mediaId: String): MediaItem? { val music = when (val uid = MediaSessionUID.fromString(mediaId)) { - is MediaSessionUID.CategoryItem -> return uid.category.toMediaItem(context) + is MediaSessionUID.Tab -> return uid.node.toMediaItem(context) is MediaSessionUID.SingleItem -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } is MediaSessionUID.ChildItem -> @@ -186,8 +179,8 @@ constructor( id: String ): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { - is MediaSessionUID.CategoryItem -> { - getCategoryMediaItems(mediaSessionUID.category) + is MediaSessionUID.Tab -> { + getCategoryMediaItems(mediaSessionUID.node) } is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) @@ -202,25 +195,30 @@ constructor( } private fun getCategoryMediaItems( - category: Category + node: TabNode ) = - when (category) { - is Category.Root -> { - val base = Category.MUSIC.take(category.amount - 1) - if (base.size < Category.MUSIC.size) { - base + Category.More(Category.MUSIC.size - base.size) + when (node) { + is TabNode.Root -> { + val tabs = homeGenerator.tabs() + val base = tabs.take(node.amount - 1).map { TabNode.Home(it) } + if (base.size < tabs.size) { + base + TabNode.More(Category.MUSIC.size - base.size) } else { base } .map { it.toMediaItem(context) } } - is Category.More -> - Category.MUSIC.takeLast(category.remainder).map { it.toMediaItem(context) } - is Category.Songs -> generator.songs().map { it.toMediaItem(context) } - is Category.Albums -> generator.albums().map { it.toMediaItem(context) } - is Category.Artists -> generator.artists().map { it.toMediaItem(context) } - is Category.Genres -> generator.genres().map { it.toMediaItem(context) } - is Category.Playlists -> generator.playlists().map { it.toMediaItem(context) } + is TabNode.More -> + homeGenerator.tabs().takeLast(node.remainder).map { + TabNode.Home(it).toMediaItem(context) } + is TabNode.Home -> + when (node.type) { + MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context, null) } + MusicType.ALBUMS -> homeGenerator.albums().map { it.toMediaItem(context) } + MusicType.ARTISTS -> homeGenerator.artists().map { it.toMediaItem(context) } + MusicType.GENRES -> homeGenerator.genres().map { it.toMediaItem(context) } + MusicType.PLAYLISTS -> homeGenerator.playlists().map { it.toMediaItem(context) } + } } private fun getChildMediaItems(uid: Music.UID): List? { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt new file mode 100644 index 000000000..02a4aa595 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt @@ -0,0 +1,70 @@ +package org.oxycblt.auxio.music.service + +import org.oxycblt.auxio.R +import org.oxycblt.auxio.home.tabs.Tab +import org.oxycblt.auxio.music.MusicType + +sealed class TabNode { + abstract val id: String + abstract val data: Int + abstract val nameRes: Int + abstract val bitmapRes: Int? + + override fun toString() = "${id}/${data}" + + data class Root(val amount: Int) : TabNode() { + override val id = ID + override val data = amount + override val nameRes = R.string.info_app_name + override val bitmapRes = null + + companion object { + const val ID = "root" + } + } + + data class More(val remainder: Int) : TabNode() { + override val id = ID + override val data = remainder + override val nameRes = R.string.lbl_more + override val bitmapRes = null + + companion object { + const val ID = "more" + } + } + + data class Home(val type: MusicType) : TabNode() { + override val id = ID + override val data = type.intCode + override val bitmapRes: Int + get() = when (type) { + MusicType.SONGS -> R.drawable.ic_song_bitmap_24 + MusicType.ALBUMS -> R.drawable.ic_album_bitmap_24 + MusicType.ARTISTS -> R.drawable.ic_artist_bitmap_24 + MusicType.GENRES -> R.drawable.ic_genre_bitmap_24 + MusicType.PLAYLISTS -> R.drawable.ic_playlist_bitmap_24 + } + override val nameRes = type.nameRes + + companion object { + const val ID = "home" + } + } + + companion object { + fun fromString(str: String): TabNode? { + val split = str.split("/", limit = 2) + if (split.size != 2) { + return null + } + val data = split[1].toIntOrNull() ?: return null + return when (split[0]) { + Root.ID -> Root(data) + More.ID -> More(data) + Home.ID -> Home(MusicType.fromIntCode(data) ?: return null) + else -> null + } + } + } +} \ No newline at end of file From fcd4ef3dc8c61cbaac8896e7d75a111b9f0634d0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:39 -0600 Subject: [PATCH 58/87] all: build fixes --- .../org/oxycblt/auxio/home/HomeGenerator.kt | 63 ++++++++++++++++--- .../java/org/oxycblt/auxio/home/HomeModule.kt | 2 + .../org/oxycblt/auxio/home/HomeViewModel.kt | 1 - .../auxio/home/tabs/AdaptiveTabStrategy.kt | 15 ++--- .../org/oxycblt/auxio/list/ListSettings.kt | 4 ++ .../auxio/music/service/MusicBrowser.kt | 11 ++-- .../music/service/MusicServiceFragment.kt | 2 +- .../oxycblt/auxio/music/service/TabNode.kt | 37 ++++++++--- 8 files changed, 102 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt index 2da76e2e7..5c551db0e 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt @@ -1,5 +1,24 @@ +/* + * Copyright (c) 2024 Auxio Project + * HomeGenerator.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 javax.inject.Inject import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions @@ -14,15 +33,22 @@ import org.oxycblt.auxio.util.logD interface HomeGenerator { fun songs(): List + fun albums(): List + fun artists(): List + fun genres(): List + fun playlists(): List + fun tabs(): List + fun release() interface Invalidator { fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) + fun invalidateTabs() } @@ -31,6 +57,17 @@ interface HomeGenerator { } } +class HomeGeneratorFactoryImpl +@Inject +constructor( + private val homeSettings: HomeSettings, + private val listSettings: ListSettings, + private val musicRepository: MusicRepository, +) : HomeGenerator.Factory { + override fun create(invalidator: HomeGenerator.Invalidator): HomeGenerator = + HomeGeneratorImpl(invalidator, homeSettings, listSettings, musicRepository) +} + private class HomeGeneratorImpl( private val invalidator: HomeGenerator.Invalidator, private val homeSettings: HomeSettings, @@ -39,13 +76,24 @@ private class HomeGeneratorImpl( ) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { override fun songs() = musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() - override fun albums() = musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList() - override fun artists() = musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } ?: emptyList() - override fun genres() = musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList() - override fun playlists() = musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } ?: emptyList() - override fun tabs() = - homeSettings.homeTabs.filterIsInstance().map { it.type } + override fun albums() = + musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } + ?: emptyList() + + override fun artists() = + musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } + ?: emptyList() + + override fun genres() = + musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } + ?: emptyList() + + override fun playlists() = + musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } + ?: emptyList() + + override fun tabs() = homeSettings.homeTabs.filterIsInstance().map { it.type } override fun onTabsChanged() { invalidator.invalidateTabs() @@ -113,5 +161,4 @@ private class HomeGeneratorImpl( invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt index a578b6e07..e7e2f9118 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt @@ -27,4 +27,6 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) interface HomeModule { @Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings + + @Binds fun homeGeneratorFactory(factory: HomeGeneratorFactoryImpl): HomeGenerator.Factory } 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 b2d62a6b6..206b4bd0a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -24,7 +24,6 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.home.tabs.Tab -import org.oxycblt.auxio.home.tabs.TabListGenerator import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.sort.Sort diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt index 237d8edd6..45f63fd7d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -38,13 +38,14 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) : override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { val homeTab = tabs[position] - val icon = when (homeTab) { - MusicType.SONGS -> R.drawable.ic_song_24 - MusicType.ALBUMS -> R.drawable.ic_album_24 - MusicType.ARTISTS -> R.drawable.ic_artist_24 - MusicType.GENRES -> R.drawable.ic_genre_24 - MusicType.PLAYLISTS -> R.drawable.ic_playlist_24 - } + val icon = + when (homeTab) { + MusicType.SONGS -> R.drawable.ic_song_24 + MusicType.ALBUMS -> R.drawable.ic_album_24 + MusicType.ARTISTS -> R.drawable.ic_artist_24 + MusicType.GENRES -> R.drawable.ic_genre_24 + MusicType.PLAYLISTS -> R.drawable.ic_playlist_24 + } // Use expected sw* size thresholds when choosing a configuration. when { diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt index 9b0bb7f4f..c817dcf0e 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt @@ -46,9 +46,13 @@ interface ListSettings : Settings { interface Listener { fun onSongSortChanged() {} + fun onAlbumSortChanged() {} + fun onArtistSortChanged() {} + fun onGenreSortChanged() {} + fun onPlaylistSortChanged() {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 0ab9db8db..8fe591f98 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -175,9 +175,7 @@ constructor( return music } - private fun getMediaItemList( - id: String - ): List? { + private fun getMediaItemList(id: String): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { is MediaSessionUID.Tab -> { getCategoryMediaItems(mediaSessionUID.node) @@ -194,9 +192,7 @@ constructor( } } - private fun getCategoryMediaItems( - node: TabNode - ) = + private fun getCategoryMediaItems(node: TabNode) = when (node) { is TabNode.Root -> { val tabs = homeGenerator.tabs() @@ -210,7 +206,8 @@ constructor( } is TabNode.More -> homeGenerator.tabs().takeLast(node.remainder).map { - TabNode.Home(it).toMediaItem(context) } + TabNode.Home(it).toMediaItem(context) + } is TabNode.Home -> when (node.type) { MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context, null) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index ba26d4778..b3617ee5d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -83,7 +83,7 @@ constructor( fun getRoot(maxItems: Int) = BrowserRoot( - MediaSessionUID.CategoryItem(Category.Root(maxItems)).toString(), + MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(), Bundle().apply { val actions = BrowserOption.entries.mapTo(ArrayList()) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt index 02a4aa595..1e9705511 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt @@ -1,7 +1,24 @@ +/* + * Copyright (c) 2024 Auxio Project + * TabNode.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.service import org.oxycblt.auxio.R -import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.music.MusicType sealed class TabNode { @@ -38,13 +55,15 @@ sealed class TabNode { override val id = ID override val data = type.intCode override val bitmapRes: Int - get() = when (type) { - MusicType.SONGS -> R.drawable.ic_song_bitmap_24 - MusicType.ALBUMS -> R.drawable.ic_album_bitmap_24 - MusicType.ARTISTS -> R.drawable.ic_artist_bitmap_24 - MusicType.GENRES -> R.drawable.ic_genre_bitmap_24 - MusicType.PLAYLISTS -> R.drawable.ic_playlist_bitmap_24 - } + get() = + when (type) { + MusicType.SONGS -> R.drawable.ic_song_bitmap_24 + MusicType.ALBUMS -> R.drawable.ic_album_bitmap_24 + MusicType.ARTISTS -> R.drawable.ic_artist_bitmap_24 + MusicType.GENRES -> R.drawable.ic_genre_bitmap_24 + MusicType.PLAYLISTS -> R.drawable.ic_playlist_bitmap_24 + } + override val nameRes = type.nameRes companion object { @@ -67,4 +86,4 @@ sealed class TabNode { } } } -} \ No newline at end of file +} From d2aed8ee23540a9cc9bcb999345730390578abba Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:41 -0600 Subject: [PATCH 59/87] music: remove category --- .../oxycblt/auxio/music/service/Category.kt | 113 ------------------ .../auxio/music/service/MusicBrowser.kt | 2 +- 2 files changed, 1 insertion(+), 114 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/Category.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt deleted file mode 100644 index 46676d3cc..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * Category.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.service - -import org.oxycblt.auxio.R - -sealed interface Category { - val id: String - val nameRes: Int - val bitmapRes: Int? - - data class Root(val amount: Int) : Category { - override val id = "root/$amount" - override val nameRes = R.string.info_app_name - override val bitmapRes = null - - companion object { - const val ID_PREFIX = "root" - - fun fromString(str: String): Root? { - val split = str.split("/", limit = 2) - if (split.size != 2) { - return null - } - val limit = split[1].toIntOrNull() ?: return null - return Root(limit) - } - } - } - - data class More(val remainder: Int) : Category { - override val id = "more/$remainder" - override val nameRes = R.string.lbl_more - override val bitmapRes = null - - companion object { - const val ID_PREFIX = "more" - - fun fromString(str: String): More? { - val split = str.split("/", limit = 2) - if (split.size != 2) { - return null - } - val remainder = split[1].toIntOrNull() ?: return null - return More(remainder) - } - } - } - - data object Songs : Category { - override val id = "songs" - override val nameRes = R.string.lbl_songs - override val bitmapRes = R.drawable.ic_song_bitmap_24 - } - - data object Albums : Category { - override val id = "albums" - override val nameRes = R.string.lbl_albums - override val bitmapRes = R.drawable.ic_album_bitmap_24 - } - - data object Artists : Category { - override val id = "artists" - override val nameRes = R.string.lbl_artists - override val bitmapRes = R.drawable.ic_artist_bitmap_24 - } - - data object Genres : Category { - override val id = "genres" - override val nameRes = R.string.lbl_genres - override val bitmapRes = R.drawable.ic_genre_bitmap_24 - } - - data object Playlists : Category { - override val id = "playlists" - override val nameRes = R.string.lbl_playlists - override val bitmapRes = R.drawable.ic_playlist_bitmap_24 - } - - companion object { - val MUSIC = arrayOf(Songs, Albums, Artists, Genres, Playlists) - val DEVICE_MUSIC = arrayOf(Songs, Albums, Artists, Genres) - val USER_MUSIC = arrayOf(Playlists) - - fun fromString(str: String): Category? = - when { - str.startsWith(Root.ID_PREFIX) -> Root.fromString(str) - str.startsWith(More.ID_PREFIX) -> More.fromString(str) - str == Songs.id -> Songs - str == Albums.id -> Albums - str == Artists.id -> Artists - str == Genres.id -> Genres - str == Playlists.id -> Playlists - else -> null - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 8fe591f98..3a8647326 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -198,7 +198,7 @@ constructor( val tabs = homeGenerator.tabs() val base = tabs.take(node.amount - 1).map { TabNode.Home(it) } if (base.size < tabs.size) { - base + TabNode.More(Category.MUSIC.size - base.size) + base + TabNode.More(tabs.size - base.size) } else { base } From 8418dccdc6ca17d84ccbd36d0a46bec9f5301c3b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:43 -0600 Subject: [PATCH 60/87] music: use factory pattern in service components --- .../java/org/oxycblt/auxio/AuxioService.kt | 5 ++-- .../oxycblt/auxio/music/service/Indexer.kt | 28 +++++++++++------- .../auxio/music/service/MusicBrowser.kt | 25 ++++++++++------ .../music/service/MusicServiceFragment.kt | 29 +++++++++++-------- 4 files changed, 54 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 5c556844a..1b6a1a4c5 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -41,13 +41,14 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { @Inject lateinit var playbackFragment: PlaybackServiceFragment - @Inject lateinit var musicFragment: MusicServiceFragment + @Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory + lateinit var musicFragment: MusicServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { super.onCreate() sessionToken = playbackFragment.attach(this) - musicFragment.attach(this, this) + musicFragment = musicFragmentFactory.create(this, this, this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index 4618a3fb2..ef3ecc893 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -36,10 +36,9 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD -class Indexer -@Inject -constructor( - @ApplicationContext override val workerContext: Context, +class Indexer private constructor( + override val workerContext: Context, + private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, @@ -50,10 +49,21 @@ constructor( MusicRepository.IndexingListener, MusicRepository.UpdateListener, MusicSettings.Listener { + class Factory @Inject constructor( + private val playbackManager: PlaybackStateManager, + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings, + private val imageLoader: ImageLoader, + private val contentObserver: SystemContentObserver + ) { + fun create(context: Context, listener: ForegroundListener) = + Indexer(context, listener, playbackManager, + musicRepository, musicSettings, imageLoader, contentObserver) + } + private val indexJob = Job() private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) private var currentIndexJob: Job? = null - private var foregroundListener: ForegroundListener? = null private val indexingNotification = IndexingNotification(workerContext) private val observingNotification = ObservingNotification(workerContext) private val wakeLock = @@ -62,8 +72,7 @@ constructor( .newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") - fun attach(listener: ForegroundListener) { - foregroundListener = listener + init { musicSettings.registerListener(this) musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -77,7 +86,6 @@ constructor( musicRepository.addIndexingListener(this) musicRepository.addUpdateListener(this) musicRepository.removeIndexingListener(this) - foregroundListener = null } override fun requestIndex(withCache: Boolean) { @@ -91,7 +99,7 @@ constructor( override val scope = indexScope override fun onIndexingStateChanged() { - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + foregroundListener.updateForeground(ForegroundListener.Change.INDEXER) val state = musicRepository.indexingState if (state is IndexingState.Indexing) { wakeLock.acquireSafe() @@ -132,7 +140,7 @@ constructor( // the music loading process ends. if (musicRepository.indexingState == null) { logD("Not loading, updating idle session") - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + foregroundListener.updateForeground(ForegroundListener.Change.INDEXER) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 3a8647326..654712287 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -37,24 +37,31 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.search.SearchEngine -class MusicBrowser -@Inject -constructor( - @ApplicationContext private val context: Context, +class MusicBrowser private constructor( + private val context: Context, + private val invalidator: Invalidator, private val musicRepository: MusicRepository, private val searchEngine: SearchEngine, private val listSettings: ListSettings, homeGeneratorFactory: HomeGenerator.Factory ) : MusicRepository.UpdateListener, HomeGenerator.Invalidator { + + class Factory @Inject constructor( + private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine, + private val listSettings: ListSettings, + private val homeGeneratorFactory: HomeGenerator.Factory + ) { + fun create(context: Context, invalidator: Invalidator): MusicBrowser = + MusicBrowser(context, invalidator, musicRepository, searchEngine, listSettings, homeGeneratorFactory) + } interface Invalidator { fun invalidateMusic(ids: Set) } private val homeGenerator = homeGeneratorFactory.create(this) - private var invalidator: Invalidator? = null - fun attach(invalidator: Invalidator) { - this.invalidator = invalidator + init { musicRepository.addUpdateListener(this) } @@ -64,7 +71,7 @@ constructor( override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { val id = MediaSessionUID.Tab(TabNode.Home(type)).toString() - invalidator?.invalidateMusic(setOf(id)) + invalidator.invalidateMusic(setOf(id)) } override fun invalidateTabs() { @@ -72,7 +79,7 @@ constructor( // TODO: Temporary bodge, move the amount parameter to a bundle extra val rootId = MediaSessionUID.Tab(TabNode.Root(i)).toString() val moreId = MediaSessionUID.Tab(TabNode.More(i)).toString() - invalidator?.invalidateMusic(setOf(rootId, moreId)) + invalidator.invalidateMusic(setOf(rootId, moreId)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index b3617ee5d..6cd420928 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -39,35 +39,40 @@ import org.oxycblt.auxio.util.logW class MusicServiceFragment @Inject constructor( - @ApplicationContext private val context: Context, - private val indexer: Indexer, - private val musicBrowser: MusicBrowser, + private val context: Context, + foregroundListener: ForegroundListener, + private val invalidator: Invalidator, + indexerFactory: Indexer.Factory, + musicBrowserFactory: MusicBrowser.Factory, private val musicRepository: MusicRepository ) : MusicBrowser.Invalidator { - private var invalidator: Invalidator? = null + private val indexer = indexerFactory.create(context, foregroundListener) + private val musicBrowser = musicBrowserFactory.create(context, this) private val dispatchJob = Job() private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) - interface Invalidator { - fun invalidateMusic(mediaId: String) + class Factory @Inject constructor( + private val indexerFactory: Indexer.Factory, + private val musicBrowserFactory: MusicBrowser.Factory, + private val musicRepository: MusicRepository + ) { + fun create(context: Context, foregroundListener: ForegroundListener, invalidator: Invalidator): MusicServiceFragment = + MusicServiceFragment(context, foregroundListener, invalidator, indexerFactory, musicBrowserFactory, musicRepository) } - fun attach(foregroundListener: ForegroundListener, invalidator: Invalidator) { - this.invalidator = invalidator - indexer.attach(foregroundListener) - musicBrowser.attach(this) + interface Invalidator { + fun invalidateMusic(mediaId: String) } fun release() { dispatchJob.cancel() musicBrowser.release() indexer.release() - invalidator = null } override fun invalidateMusic(ids: Set) { ids.forEach { mediaId -> - requireNotNull(invalidator) { "Invalidator not available" }.invalidateMusic(mediaId) + invalidator.invalidateMusic(mediaId) } } From a3af24688a97653cef6baa4976abd9069fca811f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:46 -0600 Subject: [PATCH 61/87] playback: use factory pattern --- .../java/org/oxycblt/auxio/AuxioService.kt | 10 ++-- .../replaygain/ReplayGainAudioProcessor.kt | 13 +++-- .../service/ExoPlaybackStateHolder.kt | 6 +-- .../service/PlaybackServiceFragment.kt | 47 ++++++++++--------- .../service/SystemPlaybackReceiver.kt | 38 ++++++++++----- .../oxycblt/auxio/widgets/WidgetComponent.kt | 17 +++++-- 6 files changed, 81 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 1b6a1a4c5..fb0f2aaac 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -38,16 +38,18 @@ import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @AndroidEntryPoint class AuxioService : - MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { - @Inject lateinit var playbackFragment: PlaybackServiceFragment + MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { + @Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory + private lateinit var playbackFragment: PlaybackServiceFragment @Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory - lateinit var musicFragment: MusicServiceFragment + private lateinit var musicFragment: MusicServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { super.onCreate() - sessionToken = playbackFragment.attach(this) + playbackFragment = playbackFragmentFactory.create(this, this) + sessionToken = playbackFragment.token musicFragment = musicFragmentFactory.create(this, this, this) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 1152ef4e3..9c9b8e695 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -44,12 +44,17 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor -@Inject -constructor( +class ReplayGainAudioProcessor private constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings ) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { + class Factory @Inject constructor( + + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings + ) { + fun create() = ReplayGainAudioProcessor(playbackManager, playbackSettings) + } private var volume = 1f set(value) { field = value @@ -57,7 +62,7 @@ constructor( flush() } - fun attach() { + init { playbackManager.addListener(this) playbackSettings.registerListener(this) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 203176ebd..b705cf1ab 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -86,10 +86,9 @@ class ExoPlaybackStateHolder( var sessionOngoing = false private set - fun attach() { + init { imageSettings.registerListener(this) player.addListener(this) - replayGainProcessor.attach() playbackManager.registerStateHolder(this) playbackSettings.registerListener(this) musicRepository.addUpdateListener(this) @@ -582,13 +581,14 @@ class ExoPlaybackStateHolder( private val playbackSettings: PlaybackSettings, private val commandFactory: PlaybackCommand.Factory, private val mediaSourceFactory: MediaSource.Factory, - private val replayGainProcessor: ReplayGainAudioProcessor, + private val replayGainProcessorFactory: ReplayGainAudioProcessor.Factory, private val musicRepository: MusicRepository, private val imageSettings: ImageSettings, ) { fun create(): ExoPlaybackStateHolder { // Since Auxio is a music player, only specify an audio renderer to save // battery/apk size/cache size + val replayGainProcessor = replayGainProcessorFactory.create() val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> arrayOf( FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 0bb5b9fda..399aefa55 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -34,36 +34,38 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent -class PlaybackServiceFragment -@Inject -constructor( - @ApplicationContext private val context: Context, +class PlaybackServiceFragment private constructor( + private val context: Context, + private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings, - private val sessionHolderFactory: MediaSessionHolder.Factory, - private val widgetComponent: WidgetComponent, - exoHolderFactory: ExoPlaybackStateHolder.Factory + exoHolderFactory: ExoPlaybackStateHolder.Factory, + sessionHolderFactory: MediaSessionHolder.Factory, + widgetComponentFactory: WidgetComponent.Factory, + systemReceiverFactory: SystemPlaybackReceiver.Factory, ) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener { + class Factory @Inject constructor( + private val playbackManager: PlaybackStateManager, + private val exoHolderFactory: ExoPlaybackStateHolder.Factory, + private val sessionHolderFactory: MediaSessionHolder.Factory, + private val widgetComponentFactory: WidgetComponent.Factory, + private val systemReceiverFactory: SystemPlaybackReceiver.Factory, + ) { + fun create(context: Context, foregroundListener: ForegroundListener) = + PlaybackServiceFragment(context, foregroundListener, playbackManager, exoHolderFactory, sessionHolderFactory, widgetComponentFactory, systemReceiverFactory) + } + private val waitJob = Job() private val exoHolder = exoHolderFactory.create() - private var foregroundListener: ForegroundListener? = null + private val sessionHolder = sessionHolderFactory.create(context, foregroundListener) + private val widgetComponent = widgetComponentFactory.create(context) + private val systemReceiver = systemReceiverFactory.create(context) - private lateinit var sessionHolder: MediaSessionHolder - private lateinit var systemReceiver: SystemPlaybackReceiver + val token: MediaSessionCompat.Token get() = sessionHolder.token // --- MEDIASESSION CALLBACKS --- - @SuppressLint("WrongConstant") - fun attach(listener: ForegroundListener): MediaSessionCompat.Token { - foregroundListener = listener + init { playbackManager.addListener(this) - exoHolder.attach() - sessionHolder = sessionHolderFactory.create(context, listener) - systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) - ContextCompat.registerReceiver( - context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) - widgetComponent.attach() - return sessionHolder.token } fun handleTaskRemoved() { @@ -101,10 +103,9 @@ constructor( sessionHolder.release() exoHolder.release() playbackManager.removeListener(this) - foregroundListener = null } override fun onSessionEnded() { - foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index d671219a1..f7c7110f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -23,34 +23,36 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.media.AudioManager +import androidx.core.content.ContextCompat import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetProvider +import javax.inject.Inject /** * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an * active [IntentFilter] to be registered. */ -class SystemPlaybackReceiver( +class SystemPlaybackReceiver private constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val widgetComponent: WidgetComponent ) : BroadcastReceiver() { private var initialHeadsetPlugEventHandled = false - val intentFilter = - IntentFilter().apply { - addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) - addAction(AudioManager.ACTION_HEADSET_PLUG) - addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) - addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) - addAction(PlaybackActions.ACTION_SKIP_PREV) - addAction(PlaybackActions.ACTION_PLAY_PAUSE) - addAction(PlaybackActions.ACTION_SKIP_NEXT) - addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + class Factory @Inject constructor( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val widgetComponent: WidgetComponent + ) { + fun create(context: Context): SystemPlaybackReceiver { + val receiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) + ContextCompat.registerReceiver(context, receiver, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) + return receiver } + } override fun onReceive(context: Context, intent: Intent) { when (intent.action) { @@ -127,4 +129,18 @@ class SystemPlaybackReceiver( playbackManager.playing(false) } } + + private companion object { + val INTENT_FILTER = + IntentFilter().apply { + addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + addAction(AudioManager.ACTION_HEADSET_PLUG) + addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) + addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) + addAction(PlaybackActions.ACTION_SKIP_PREV) + addAction(PlaybackActions.ACTION_PLAY_PAUSE) + addAction(PlaybackActions.ACTION_SKIP_NEXT) + addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index bb8caf693..2afa5b177 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -46,18 +46,25 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class WidgetComponent -@Inject -constructor( - @ApplicationContext private val context: Context, +class WidgetComponent private constructor( + private val context: Context, private val imageSettings: ImageSettings, private val bitmapProvider: BitmapProvider, private val playbackManager: PlaybackStateManager, private val uiSettings: UISettings ) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { + class Factory @Inject constructor( + private val imageSettings: ImageSettings, + private val bitmapProvider: BitmapProvider, + private val playbackManager: PlaybackStateManager, + private val uiSettings: UISettings + ) { + fun create(context: Context) = WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings) + } + private val widgetProvider = WidgetProvider() - fun attach() { + init { playbackManager.addListener(this) uiSettings.registerListener(this) imageSettings.registerListener(this) From f4e1681044b6c11d491f8ad5b6a0203775922896 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:48 -0600 Subject: [PATCH 62/87] all: reformat --- .../java/org/oxycblt/auxio/AuxioService.kt | 2 +- .../oxycblt/auxio/music/service/Indexer.kt | 18 ++++++--- .../auxio/music/service/MusicBrowser.kt | 23 ++++++++---- .../music/service/MusicServiceFragment.kt | 23 ++++++++---- .../replaygain/ReplayGainAudioProcessor.kt | 13 ++++--- .../service/PlaybackServiceFragment.kt | 23 ++++++++---- .../service/SystemPlaybackReceiver.kt | 37 +++++++++++-------- .../oxycblt/auxio/widgets/WidgetComponent.kt | 11 ++++-- 8 files changed, 97 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index fb0f2aaac..aeb0e6db7 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -38,7 +38,7 @@ import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @AndroidEntryPoint class AuxioService : - MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { + MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { @Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory private lateinit var playbackFragment: PlaybackServiceFragment diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index ef3ecc893..de5969216 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -21,7 +21,6 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.os.PowerManager import coil.ImageLoader -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -36,7 +35,8 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD -class Indexer private constructor( +class Indexer +private constructor( override val workerContext: Context, private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, @@ -49,7 +49,9 @@ class Indexer private constructor( MusicRepository.IndexingListener, MusicRepository.UpdateListener, MusicSettings.Listener { - class Factory @Inject constructor( + class Factory + @Inject + constructor( private val playbackManager: PlaybackStateManager, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, @@ -57,8 +59,14 @@ class Indexer private constructor( private val contentObserver: SystemContentObserver ) { fun create(context: Context, listener: ForegroundListener) = - Indexer(context, listener, playbackManager, - musicRepository, musicSettings, imageLoader, contentObserver) + Indexer( + context, + listener, + playbackManager, + musicRepository, + musicSettings, + imageLoader, + contentObserver) } private val indexJob = Job() diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 654712287..ecb53daa9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.support.v4.media.MediaBrowserCompat.MediaItem -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.home.HomeGenerator @@ -37,7 +36,8 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.search.SearchEngine -class MusicBrowser private constructor( +class MusicBrowser +private constructor( private val context: Context, private val invalidator: Invalidator, private val musicRepository: MusicRepository, @@ -46,15 +46,24 @@ class MusicBrowser private constructor( homeGeneratorFactory: HomeGenerator.Factory ) : MusicRepository.UpdateListener, HomeGenerator.Invalidator { - class Factory @Inject constructor( - private val musicRepository: MusicRepository, - private val searchEngine: SearchEngine, - private val listSettings: ListSettings, + class Factory + @Inject + constructor( + private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine, + private val listSettings: ListSettings, private val homeGeneratorFactory: HomeGenerator.Factory ) { fun create(context: Context, invalidator: Invalidator): MusicBrowser = - MusicBrowser(context, invalidator, musicRepository, searchEngine, listSettings, homeGeneratorFactory) + MusicBrowser( + context, + invalidator, + musicRepository, + searchEngine, + listSettings, + homeGeneratorFactory) } + interface Invalidator { fun invalidateMusic(ids: Set) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 6cd420928..a36769d9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -24,7 +24,6 @@ import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.media.MediaBrowserServiceCompat.BrowserRoot import androidx.media.MediaBrowserServiceCompat.Result import androidx.media.utils.MediaConstants -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -51,13 +50,25 @@ constructor( private val dispatchJob = Job() private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) - class Factory @Inject constructor( + class Factory + @Inject + constructor( private val indexerFactory: Indexer.Factory, private val musicBrowserFactory: MusicBrowser.Factory, private val musicRepository: MusicRepository ) { - fun create(context: Context, foregroundListener: ForegroundListener, invalidator: Invalidator): MusicServiceFragment = - MusicServiceFragment(context, foregroundListener, invalidator, indexerFactory, musicBrowserFactory, musicRepository) + fun create( + context: Context, + foregroundListener: ForegroundListener, + invalidator: Invalidator + ): MusicServiceFragment = + MusicServiceFragment( + context, + foregroundListener, + invalidator, + indexerFactory, + musicBrowserFactory, + musicRepository) } interface Invalidator { @@ -71,9 +82,7 @@ constructor( } override fun invalidateMusic(ids: Set) { - ids.forEach { mediaId -> - invalidator.invalidateMusic(mediaId) - } + ids.forEach { mediaId -> invalidator.invalidateMusic(mediaId) } } fun start() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 9c9b8e695..1a1770736 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -44,17 +44,20 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor private constructor( +class ReplayGainAudioProcessor +private constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings ) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { - class Factory @Inject constructor( - - private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings + class Factory + @Inject + constructor( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings ) { fun create() = ReplayGainAudioProcessor(playbackManager, playbackSettings) } + private var volume = 1f set(value) { field = value diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 399aefa55..d0c419ae1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -18,23 +18,20 @@ package org.oxycblt.auxio.playback.service -import android.annotation.SuppressLint import android.content.Context import android.support.v4.media.session.MediaSessionCompat -import androidx.core.content.ContextCompat -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.Job import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.IntegerTable -import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent -class PlaybackServiceFragment private constructor( +class PlaybackServiceFragment +private constructor( private val context: Context, private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, @@ -43,7 +40,9 @@ class PlaybackServiceFragment private constructor( widgetComponentFactory: WidgetComponent.Factory, systemReceiverFactory: SystemPlaybackReceiver.Factory, ) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener { - class Factory @Inject constructor( + class Factory + @Inject + constructor( private val playbackManager: PlaybackStateManager, private val exoHolderFactory: ExoPlaybackStateHolder.Factory, private val sessionHolderFactory: MediaSessionHolder.Factory, @@ -51,7 +50,14 @@ class PlaybackServiceFragment private constructor( private val systemReceiverFactory: SystemPlaybackReceiver.Factory, ) { fun create(context: Context, foregroundListener: ForegroundListener) = - PlaybackServiceFragment(context, foregroundListener, playbackManager, exoHolderFactory, sessionHolderFactory, widgetComponentFactory, systemReceiverFactory) + PlaybackServiceFragment( + context, + foregroundListener, + playbackManager, + exoHolderFactory, + sessionHolderFactory, + widgetComponentFactory, + systemReceiverFactory) } private val waitJob = Job() @@ -60,7 +66,8 @@ class PlaybackServiceFragment private constructor( private val widgetComponent = widgetComponentFactory.create(context) private val systemReceiver = systemReceiverFactory.create(context) - val token: MediaSessionCompat.Token get() = sessionHolder.token + val token: MediaSessionCompat.Token + get() = sessionHolder.token // --- MEDIASESSION CALLBACKS --- diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index f7c7110f0..3a10039db 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -24,32 +24,37 @@ import android.content.Intent import android.content.IntentFilter import android.media.AudioManager import androidx.core.content.ContextCompat +import javax.inject.Inject import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetProvider -import javax.inject.Inject /** * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an * active [IntentFilter] to be registered. */ -class SystemPlaybackReceiver private constructor( +class SystemPlaybackReceiver +private constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val widgetComponent: WidgetComponent ) : BroadcastReceiver() { private var initialHeadsetPlugEventHandled = false - class Factory @Inject constructor( + class Factory + @Inject + constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val widgetComponent: WidgetComponent ) { fun create(context: Context): SystemPlaybackReceiver { - val receiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) - ContextCompat.registerReceiver(context, receiver, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) + val receiver = + SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) + ContextCompat.registerReceiver( + context, receiver, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) return receiver } } @@ -131,16 +136,16 @@ class SystemPlaybackReceiver private constructor( } private companion object { - val INTENT_FILTER = - IntentFilter().apply { - addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) - addAction(AudioManager.ACTION_HEADSET_PLUG) - addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) - addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) - addAction(PlaybackActions.ACTION_SKIP_PREV) - addAction(PlaybackActions.ACTION_PLAY_PAUSE) - addAction(PlaybackActions.ACTION_SKIP_NEXT) - addAction(WidgetProvider.ACTION_WIDGET_UPDATE) - } + val INTENT_FILTER = + IntentFilter().apply { + addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + addAction(AudioManager.ACTION_HEADSET_PLUG) + addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) + addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) + addAction(PlaybackActions.ACTION_SKIP_PREV) + addAction(PlaybackActions.ACTION_PLAY_PAUSE) + addAction(PlaybackActions.ACTION_SKIP_NEXT) + addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 2afa5b177..367b44f42 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -23,7 +23,6 @@ import android.graphics.Bitmap import android.os.Build import coil.request.ImageRequest import coil.size.Size -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider @@ -46,20 +45,24 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class WidgetComponent private constructor( +class WidgetComponent +private constructor( private val context: Context, private val imageSettings: ImageSettings, private val bitmapProvider: BitmapProvider, private val playbackManager: PlaybackStateManager, private val uiSettings: UISettings ) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { - class Factory @Inject constructor( + class Factory + @Inject + constructor( private val imageSettings: ImageSettings, private val bitmapProvider: BitmapProvider, private val playbackManager: PlaybackStateManager, private val uiSettings: UISettings ) { - fun create(context: Context) = WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings) + fun create(context: Context) = + WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings) } private val widgetProvider = WidgetProvider() From 26f27d0edd810b7ad48c82dcca916c376cab9ae6 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 18 Sep 2024 14:50:53 -0600 Subject: [PATCH 63/87] detail: split off detail list into generator --- .../oxycblt/auxio/detail/DetailGenerator.kt | 216 ++++++++++++ .../org/oxycblt/auxio/detail/DetailModule.kt | 30 ++ .../oxycblt/auxio/detail/DetailViewModel.kt | 320 +++++------------- .../org/oxycblt/auxio/list/ListSettings.kt | 10 +- .../music/service/MediaItemTranslation.kt | 4 + .../auxio/music/service/MusicBrowser.kt | 113 +++---- 6 files changed, 395 insertions(+), 298 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt new file mode 100644 index 000000000..bd5356df5 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -0,0 +1,216 @@ +package org.oxycblt.auxio.detail + +import android.content.Context +import androidx.annotation.StringRes +import org.oxycblt.auxio.R +import org.oxycblt.auxio.detail.list.DiscHeader +import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.music.info.ReleaseType +import org.oxycblt.auxio.util.logD +import java.util.SortedMap +import javax.inject.Inject + +interface DetailGenerator { + fun any(uid: Music.UID): Detail? + fun album(uid: Music.UID): Detail? + fun artist(uid: Music.UID): Detail? + fun genre(uid: Music.UID): Detail? + fun playlist(uid: Music.UID): Detail? + fun release() + + interface Factory { + fun create(invalidator: Invalidator): DetailGenerator + } + + interface Invalidator { + fun invalidate(type: MusicType, replace: Int?) + } +} + +class DetailGeneratorFactoryImpl @Inject constructor( + private val listSettings: ListSettings, + private val musicRepository: MusicRepository +) : DetailGenerator.Factory { + override fun create(invalidator: DetailGenerator.Invalidator): DetailGenerator = + DetailGeneratorImpl(invalidator, listSettings, musicRepository) +} + +private class DetailGeneratorImpl( + private val invalidator: DetailGenerator.Invalidator, + private val listSettings: ListSettings, + private val musicRepository: MusicRepository +) : DetailGenerator, MusicRepository.UpdateListener, ListSettings.Listener { + init { + listSettings.registerListener(this) + musicRepository.addUpdateListener(this) + } + + override fun onAlbumSongSortChanged() { + super.onAlbumSongSortChanged() + invalidator.invalidate(MusicType.ALBUMS, -1) + } + + override fun onArtistSongSortChanged() { + super.onArtistSongSortChanged() + invalidator.invalidate(MusicType.ARTISTS, -1) + } + + override fun onGenreSongSortChanged() { + super.onGenreSongSortChanged() + invalidator.invalidate(MusicType.GENRES, -1) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.deviceLibrary) { + invalidator.invalidate(MusicType.ALBUMS, null) + invalidator.invalidate(MusicType.ARTISTS, null) + invalidator.invalidate(MusicType.GENRES, null) + } + if (changes.userLibrary) { + invalidator.invalidate(MusicType.PLAYLISTS, null) + } + } + + override fun release() { + listSettings.unregisterListener(this) + musicRepository.removeUpdateListener(this) + } + + override fun any(uid: Music.UID): Detail? { + val music = musicRepository.find(uid) ?: return null + return when (music) { + is Album -> album(uid) + is Artist -> artist(uid) + is Genre -> genre(uid) + is Playlist -> playlist(uid) + else -> null + } + } + + override fun album(uid: Music.UID): Detail? { + val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null + val songs = listSettings.albumSongSort.songs(album.songs) + val discs = songs.groupBy { it.disc } + val section = if (discs.size > 1 || discs.keys.first() != null) { + DetailSection.Discs(discs) + } else { + DetailSection.Songs(songs) + } + return Detail(album, listOf(section)) + } + + override fun artist(uid: Music.UID): Detail? { + val artist = musicRepository.deviceLibrary?.findArtist(uid) ?: return null + val grouping = + artist.explicitAlbums.groupByTo(sortedMapOf()) { + // Remap the complicated ReleaseType data structure into detail sections + when (it.releaseType.refinement) { + ReleaseType.Refinement.LIVE -> DetailSection.Albums.Category.LIVE + ReleaseType.Refinement.REMIX -> DetailSection.Albums.Category.REMIXES + null -> + when (it.releaseType) { + is ReleaseType.Album -> DetailSection.Albums.Category.ALBUMS + is ReleaseType.EP -> DetailSection.Albums.Category.EPS + is ReleaseType.Single -> DetailSection.Albums.Category.SINGLES + is ReleaseType.Compilation -> DetailSection.Albums.Category.COMPILATIONS + is ReleaseType.Soundtrack -> DetailSection.Albums.Category.SOUNDTRACKS + is ReleaseType.Mix -> DetailSection.Albums.Category.DJ_MIXES + is ReleaseType.Mixtape -> DetailSection.Albums.Category.MIXTAPES + is ReleaseType.Demo -> DetailSection.Albums.Category.DEMOS + } + } + } + + if (artist.implicitAlbums.isNotEmpty()) { + // groupByTo normally returns a mapping to a MutableList mapping. Since MutableList + // inherits list, we can cast upwards and save a copy by directly inserting the + // implicit album list into the mapping. + logD("Implicit albums present, adding to list") + @Suppress("UNCHECKED_CAST") + (grouping as MutableMap>)[DetailSection.Albums.Category.APPEARANCES] = + artist.implicitAlbums + } + + val sections = grouping.mapTo(mutableListOf()) { (category, albums) -> + DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums)) + } + val songs = DetailSection.Songs(listSettings.artistSongSort.songs(artist.songs)) + sections.add(songs) + return Detail(artist, sections) + } + + override fun genre(uid: Music.UID): Detail? { + val genre = musicRepository.deviceLibrary?.findGenre(uid) ?: return null + val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists)) + val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs)) + return Detail(genre, listOf(artists, songs)) + } + + override fun playlist(uid: Music.UID): Detail? { + val playlist = musicRepository.userLibrary?.findPlaylist(uid) ?: return null + val songs = DetailSection.Songs(playlist.songs) + return Detail(playlist, listOf(songs)) + } + + private companion object { + val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) + val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + } +} + +data class Detail

(val parent: P, val sections: List) + +sealed interface DetailSection { + val order: Int + val stringRes: Int + + abstract class PlainSection : DetailSection { + abstract val items: List + } + + data class Artists(override val items: List) : PlainSection() { + override val order = 0 + override val stringRes = R.string.lbl_songs + } + + data class Albums(val category: Category, override val items: List) : PlainSection() { + override val order = 1 + category.ordinal + override val stringRes = category.stringRes + + enum class Category(@StringRes val stringRes: Int) { + ALBUMS(R.string.lbl_albums), + EPS(R.string.lbl_eps), + SINGLES(R.string.lbl_singles), + COMPILATIONS(R.string.lbl_compilations), + SOUNDTRACKS(R.string.lbl_soundtracks), + DJ_MIXES(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) + } + } + + + data class Songs(override val items: List) : PlainSection() { + override val order = 12 + override val stringRes = R.string.lbl_songs + } + + data class Discs(val discs: Map>) : DetailSection { + override val order = 13 + override val stringRes = R.string.lbl_songs + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt new file mode 100644 index 000000000..4d41529db --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 Auxio Project + * HomeModule.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.detail + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DetailModule { + @Binds fun detailGeneratorFactory(factory: DetailGeneratorFactoryImpl): DetailGenerator.Factory +} 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 3613d96c6..03390b2a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.detail -import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -43,10 +42,11 @@ import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.metadata.AudioProperties import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaybackSettings @@ -69,8 +69,12 @@ constructor( private val listSettings: ListSettings, private val musicRepository: MusicRepository, private val audioPropertiesFactory: AudioProperties.Factory, - private val playbackSettings: PlaybackSettings -) : ViewModel(), MusicRepository.UpdateListener { + private val playbackSettings: PlaybackSettings, + detailGeneratorFactory: DetailGenerator.Factory +) : ViewModel(), DetailGenerator.Invalidator { + private val detailGenerator = detailGeneratorFactory.create(this) + + private val _toShow = MutableEvent() /** * A [Show] command that is awaiting a view capable of responding to it. Null if none currently. @@ -133,13 +137,8 @@ constructor( get() = _artistSongInstructions /** The current [Sort] used for [Song]s in [artistSongList]. */ - var artistSongSort: Sort + val artistSongSort: Sort get() = listSettings.artistSongSort - set(value) { - listSettings.artistSongSort = value - // Refresh the artist list to reflect the new sort. - currentArtist.value?.let { refreshArtistList(it, true) } - } /** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */ val playInArtistWith @@ -162,13 +161,8 @@ constructor( get() = _genreSongInstructions /** The current [Sort] used for [Song]s in [genreSongList]. */ - var genreSongSort: Sort + val genreSongSort: Sort get() = listSettings.genreSongSort - set(value) { - listSettings.genreSongSort = value - // Refresh the genre list to reflect the new sort. - currentGenre.value?.let { refreshGenreList(it, true) } - } /** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */ val playInGenreWith @@ -204,54 +198,32 @@ constructor( playbackSettings.inParentPlaybackMode ?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value)) - init { - musicRepository.addUpdateListener(this) - } - override fun onCleared() { - musicRepository.removeUpdateListener(this) + detailGenerator.release() } - override fun onMusicChanges(changes: MusicRepository.Changes) { - // If we are showing any item right now, we will need to refresh it (and any information - // related to it) with the new library in order to prevent stale items from showing up - // in the UI. - val deviceLibrary = musicRepository.deviceLibrary - if (changes.deviceLibrary && deviceLibrary != null) { - val song = currentSong.value - if (song != null) { - _currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo) - logD("Updated song to ${currentSong.value}") + override fun invalidate(type: MusicType, replace: Int?) { + when (type) { + MusicType.ALBUMS -> { + val album = detailGenerator.album(currentAlbum.value?.uid ?: return) + refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace) } - val album = currentAlbum.value - if (album != null) { - _currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList) - logD("Updated album to ${currentAlbum.value}") + MusicType.ARTISTS -> { + val artist = detailGenerator.artist(currentArtist.value?.uid ?: return) + refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, replace) } - val artist = currentArtist.value - if (artist != null) { - _currentArtist.value = - deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList) - logD("Updated artist to ${currentArtist.value}") + MusicType.GENRES -> { + val genre = detailGenerator.genre(currentGenre.value?.uid ?: return) + refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace) } - val genre = currentGenre.value - if (genre != null) { - _currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList) - logD("Updated genre to ${currentGenre.value}") + MusicType.PLAYLISTS -> { + refreshPlaylist(currentPlaylist.value?.uid ?: return) } - } - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - val playlist = currentPlaylist.value - if (playlist != null) { - _currentPlaylist.value = - userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList) - logD("Updated playlist to ${currentPlaylist.value}") - } + else -> error("Unexpected music type $type") } } @@ -356,8 +328,11 @@ constructor( */ fun setAlbum(uid: Music.UID) { logD("Opening album $uid") - _currentAlbum.value = - musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList) + if (uid === _currentAlbum.value?.uid) { + return + } + val album = detailGenerator.album(uid) + refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null) if (_currentAlbum.value == null) { logW("Given album UID was invalid") } @@ -370,7 +345,6 @@ constructor( */ fun applyAlbumSongSort(sort: Sort) { listSettings.albumSongSort = sort - _currentAlbum.value?.let { refreshAlbumList(it, true) } } /** @@ -381,11 +355,11 @@ constructor( */ fun setArtist(uid: Music.UID) { logD("Opening artist $uid") - _currentArtist.value = - musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList) - if (_currentArtist.value == null) { - logW("Given artist UID was invalid") + if (uid === _currentArtist.value?.uid) { + return } + val artist = detailGenerator.artist(uid) + refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null) } /** @@ -395,7 +369,6 @@ constructor( */ fun applyArtistSongSort(sort: Sort) { listSettings.artistSongSort = sort - _currentArtist.value?.let { refreshArtistList(it, true) } } /** @@ -406,11 +379,11 @@ constructor( */ fun setGenre(uid: Music.UID) { logD("Opening genre $uid") - _currentGenre.value = - musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) - if (_currentGenre.value == null) { - logW("Given genre UID was invalid") + if (uid === _currentGenre.value?.uid) { + return } + val genre = detailGenerator.genre(uid) + refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null) } /** @@ -420,7 +393,6 @@ constructor( */ fun applyGenreSongSort(sort: Sort) { listSettings.genreSongSort = sort - _currentGenre.value?.let { refreshGenreList(it, true) } } /** @@ -431,11 +403,10 @@ constructor( */ fun setPlaylist(uid: Music.UID) { logD("Opening playlist $uid") - _currentPlaylist.value = - musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList) - if (_currentPlaylist.value == null) { - logW("Given playlist UID was invalid") + if (uid === _currentPlaylist.value?.uid) { + return } + refreshPlaylist(uid) } /** Start a playlist editing session. Does nothing if a playlist is not being shown. */ @@ -443,7 +414,7 @@ constructor( val playlist = _currentPlaylist.value ?: return logD("Starting playlist edit") _editedPlaylist.value = playlist.songs - refreshPlaylistList(playlist) + refreshPlaylist(playlist.uid) } /** @@ -474,9 +445,8 @@ constructor( // Nothing to do. return false } - logD("Discarding playlist edits") _editedPlaylist.value = null - refreshPlaylistList(playlist) + refreshPlaylist(playlist.uid) return true } @@ -488,7 +458,7 @@ constructor( fun applyPlaylistSongSort(sort: Sort) { val playlist = _currentPlaylist.value ?: return _editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return) - refreshPlaylistList(playlist, UpdateInstructions.Replace(2)) + refreshPlaylist(playlist.uid, UpdateInstructions.Replace(2)) } /** @@ -509,7 +479,7 @@ constructor( logD("Moving playlist song from $realFrom [$from] to $realTo [$to]") editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo)) _editedPlaylist.value = editedPlaylist - refreshPlaylistList(playlist, UpdateInstructions.Move(from, to)) + refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to)) return true } @@ -528,8 +498,8 @@ constructor( logD("Removing playlist song at $realAt [$at]") editedPlaylist.removeAt(realAt) _editedPlaylist.value = editedPlaylist - refreshPlaylistList( - playlist, + refreshPlaylist( + playlist.uid, if (editedPlaylist.isNotEmpty()) { UpdateInstructions.Remove(at, 1) } else { @@ -552,173 +522,69 @@ constructor( } } - private fun refreshAlbumList(album: Album, replace: Boolean = false) { - logD("Refreshing album list") - val list = mutableListOf() - val header = SortHeader(R.string.lbl_songs) - list.add(Divider(header)) - list.add(header) - val instructions = - if (replace) { - // Intentional so that the header item isn't replaced with the songs - UpdateInstructions.Replace(list.size) - } else { - UpdateInstructions.Diff - } - // To create a good user experience regarding disc numbers, we group the album's - // songs up by disc and then delimit the groups by a disc header. - val songs = albumSongSort.songs(album.songs) - val byDisc = songs.groupBy { it.disc } - if (byDisc.size > 1) { - logD("Album has more than one disc, interspersing headers") - for (entry in byDisc.entries) { - list.add(DiscHeader(entry.key)) - list.addAll(entry.value) - } - } else { - // Album only has one disc, don't add any redundant headers - list.addAll(songs) + private fun refreshDetail( + detail: Detail?, + parent: MutableStateFlow, + list: MutableStateFlow>, + instructions: MutableEvent, + replace: Int? + ) { + if (detail == null) { + parent.value = null + return } + val newList = mutableListOf() + var newInstructions: UpdateInstructions = UpdateInstructions.Diff + for ((i, section) in detail.sections.withIndex()) { + val items = when (section) { + is DetailSection.PlainSection<*> -> { + val header = if (section is DetailSection.Songs) + SortHeader(section.stringRes) else BasicHeader(section.stringRes) + newList.add(Divider(header)) + newList.add(header) + section.items + } - logD("Update album list to ${list.size} items with $instructions") - _albumSongInstructions.put(instructions) - _albumSongList.value = list - } - - private fun refreshArtistList(artist: Artist, replace: Boolean = false) { - logD("Refreshing artist list") - val list = mutableListOf() - - val grouping = - artist.explicitAlbums.groupByTo(sortedMapOf()) { - // Remap the complicated ReleaseType data structure into an easier - // "AlbumGrouping" enum that will automatically group and sort - // the artist's albums. - when (it.releaseType.refinement) { - ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE - ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES - null -> - when (it.releaseType) { - is ReleaseType.Album -> AlbumGrouping.ALBUMS - is ReleaseType.EP -> AlbumGrouping.EPS - is ReleaseType.Single -> AlbumGrouping.SINGLES - is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS - is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS - is ReleaseType.Mix -> AlbumGrouping.DJMIXES - is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES - is ReleaseType.Demo -> AlbumGrouping.DEMOS - } + is DetailSection.Discs -> { + val header = BasicHeader(section.stringRes) + newList.add(Divider(header)) + newList.add(header) + section.discs.flatMap { + listOf(DiscHeader(it.key)) + it.value + } } } - - if (artist.implicitAlbums.isNotEmpty()) { - // groupByTo normally returns a mapping to a MutableList mapping. Since MutableList - // inherits list, we can cast upwards and save a copy by directly inserting the - // implicit album list into the mapping. - logD("Implicit albums present, adding to list") - @Suppress("UNCHECKED_CAST") - (grouping as MutableMap>)[AlbumGrouping.APPEARANCES] = - artist.implicitAlbums - } - - logD("Release groups for this artist: ${grouping.keys}") - - for (entry in grouping.entries) { - val header = BasicHeader(entry.key.headerTitleRes) - list.add(Divider(header)) - list.add(header) - list.addAll(ARTIST_ALBUM_SORT.albums(entry.value)) - } - - // Artists may not be linked to any songs, only include a header entry if we have any. - var instructions: UpdateInstructions = UpdateInstructions.Diff - if (artist.songs.isNotEmpty()) { - logD("Songs present in this artist, adding header") - val header = SortHeader(R.string.lbl_songs) - list.add(Divider(header)) - list.add(header) - if (replace) { + // Currently only the final section (songs, which can be sorted) are invalidatable + // and thus need to be replaced. + if (replace == -1 && i == detail.sections.lastIndex) { // Intentional so that the header item isn't replaced with the songs - instructions = UpdateInstructions.Replace(list.size) + newInstructions = UpdateInstructions.Replace(newList.size) } - list.addAll(artistSongSort.songs(artist.songs)) + newList.addAll(items) } - - logD("Updating artist list to ${list.size} items with $instructions") - _artistSongInstructions.put(instructions) - _artistSongList.value = list.toList() + parent.value = detail.parent + list.value = newList + instructions.put(newInstructions) } - private fun refreshGenreList(genre: Genre, replace: Boolean = false) { - logD("Refreshing genre list") - val list = mutableListOf() - // Genre is guaranteed to always have artists and songs. - val artistHeader = BasicHeader(R.string.lbl_artists) - list.add(Divider(artistHeader)) - list.add(artistHeader) - list.addAll(GENRE_ARTIST_SORT.artists(genre.artists)) - - val songHeader = SortHeader(R.string.lbl_songs) - list.add(Divider(songHeader)) - list.add(songHeader) - val instructions = - if (replace) { - // Intentional so that the header item isn't replaced alongside the songs - UpdateInstructions.Replace(list.size) - } else { - UpdateInstructions.Diff - } - list.addAll(genreSongSort.songs(genre.songs)) - - logD("Updating genre list to ${list.size} items with $instructions") - _genreSongInstructions.put(instructions) - _genreSongList.value = list - } - - private fun refreshPlaylistList( - playlist: Playlist, - instructions: UpdateInstructions = UpdateInstructions.Diff - ) { + private fun refreshPlaylist(uid: Music.UID, instructions: UpdateInstructions = UpdateInstructions.Diff) { logD("Refreshing playlist list") + val edited = editedPlaylist.value + if (edited == null) { + val playlist = detailGenerator.playlist(uid) + refreshDetail(playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) + return + } val list = mutableListOf() - - val songs = editedPlaylist.value ?: playlist.songs - if (songs.isNotEmpty()) { + if (edited.isNotEmpty()) { val header = EditHeader(R.string.lbl_songs) list.add(Divider(header)) list.add(header) - list.addAll(songs) + list.addAll(edited) } - - logD("Updating playlist list to ${list.size} items with $instructions") - _playlistSongInstructions.put(instructions) _playlistSongList.value = list - } - - /** - * A simpler mapping of [ReleaseType] used for grouping and sorting songs. - * - * @param headerTitleRes The title string resource to use for a header created out of an - * instance of this enum. - */ - private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) { - ALBUMS(R.string.lbl_albums), - EPS(R.string.lbl_eps), - SINGLES(R.string.lbl_singles), - COMPILATIONS(R.string.lbl_compilations), - 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), - } - - private companion object { - val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) - val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + _playlistSongInstructions.put(instructions) } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt index c817dcf0e..37cb2cc23 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt @@ -46,13 +46,12 @@ interface ListSettings : Settings { interface Listener { fun onSongSortChanged() {} - fun onAlbumSortChanged() {} - + fun onAlbumSongSortChanged() {} fun onArtistSortChanged() {} - + fun onArtistSongSortChanged() {} fun onGenreSortChanged() {} - + fun onGenreSongSortChanged() {} fun onPlaylistSortChanged() {} } } @@ -162,8 +161,11 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont when (key) { getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged() getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged() + getString(R.string.set_key_album_songs_sort) -> listener.onAlbumSongSortChanged() getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged() + getString(R.string.set_key_artist_songs_sort) -> listener.onArtistSongSortChanged() getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged() + getString(R.string.set_key_genre_songs_sort) -> listener.onGenreSongSortChanged() getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged() } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 624247f8e..1d3c8ff02 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -130,6 +130,10 @@ fun header(@StringRes nameRes: Int): Sugar = { MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) } +fun header(name: String): Sugar = { + putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, name) +} + private fun style(style: Int): Sugar = { putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index ecb53daa9..2a109204a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -22,7 +22,12 @@ import android.content.Context import android.support.v4.media.MediaBrowserCompat.MediaItem import javax.inject.Inject import org.oxycblt.auxio.R +import org.oxycblt.auxio.detail.DetailGenerator +import org.oxycblt.auxio.detail.DetailSection +import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.home.HomeGenerator +import org.oxycblt.auxio.list.BasicHeader +import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.sort.Sort @@ -42,17 +47,17 @@ private constructor( private val invalidator: Invalidator, private val musicRepository: MusicRepository, private val searchEngine: SearchEngine, - private val listSettings: ListSettings, - homeGeneratorFactory: HomeGenerator.Factory -) : MusicRepository.UpdateListener, HomeGenerator.Invalidator { + homeGeneratorFactory: HomeGenerator.Factory, + detailGeneratorFactory: DetailGenerator.Factory +) : HomeGenerator.Invalidator, DetailGenerator.Invalidator { class Factory @Inject constructor( private val musicRepository: MusicRepository, private val searchEngine: SearchEngine, - private val listSettings: ListSettings, - private val homeGeneratorFactory: HomeGenerator.Factory + private val homeGeneratorFactory: HomeGenerator.Factory, + private val detailGeneratorFactory: DetailGenerator.Factory ) { fun create(context: Context, invalidator: Invalidator): MusicBrowser = MusicBrowser( @@ -60,8 +65,8 @@ private constructor( invalidator, musicRepository, searchEngine, - listSettings, - homeGeneratorFactory) + homeGeneratorFactory, + detailGeneratorFactory) } interface Invalidator { @@ -69,13 +74,11 @@ private constructor( } private val homeGenerator = homeGeneratorFactory.create(this) - - init { - musicRepository.addUpdateListener(this) - } + private val detailGenerator = detailGeneratorFactory.create(this) fun release() { - musicRepository.removeUpdateListener(this) + homeGenerator.release() + detailGenerator.release() } override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { @@ -92,36 +95,21 @@ private constructor( } } - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary - val invalidate = mutableSetOf() - if (changes.deviceLibrary && deviceLibrary != null) { - deviceLibrary.albums.forEach { - val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate.add(id) - } - - deviceLibrary.artists.forEach { - val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate.add(id) - } - - deviceLibrary.genres.forEach { - val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate.add(id) - } + override fun invalidate(type: MusicType, replace: Int?) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return + val music = when (type) { + MusicType.ALBUMS -> deviceLibrary.albums + MusicType.ARTISTS -> deviceLibrary.artists + MusicType.GENRES -> deviceLibrary.genres + MusicType.PLAYLISTS -> userLibrary.playlists + else -> return } - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - userLibrary.playlists.forEach { - val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate.add(id) - } - } - - if (invalidate.isNotEmpty()) { - invalidator?.invalidateMusic(invalidate) + if (music.isEmpty()) { + return } + val ids = music.map { MediaSessionUID.SingleItem(it.uid).toString() }.toSet() + invalidator.invalidateMusic(ids) } fun getItem(mediaId: String): MediaItem? { @@ -235,34 +223,25 @@ private constructor( } private fun getChildMediaItems(uid: Music.UID): List? { - return when (val item = musicRepository.find(uid)) { - is Album -> { - val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } + val detail = detailGenerator.any(uid) ?: return null + return detail.sections.flatMap { section -> + when (section) { + is DetailSection.Songs -> section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + is DetailSection.Albums -> section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + is DetailSection.Artists -> section.items.map { it.toMediaItem(context, header(section.stringRes)) } + is DetailSection.Discs -> section.discs.flatMap { + section.discs.flatMap { entry -> + val disc = entry.key + val discString = if (disc != null) { + context.getString(R.string.fmt_disc_no, disc.number) + } else { + context.getString(R.string.def_disc) + } + entry.value.map { it.toMediaItem(context, null, header(discString)) } + } + } + else -> error("Unknown section type: $section") } - is Artist -> { - val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) - val songs = listSettings.artistSongSort.songs(item.songs) - albums.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } + - songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } - } - is Genre -> { - val artists = GENRE_ARTISTS_SORT.artists(item.artists) - val songs = listSettings.genreSongSort.songs(item.songs) - artists.map { it.toMediaItem(context, header(R.string.lbl_songs)) } + - songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } - } - is Playlist -> { - item.songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } - } - is Song, - null -> return null } } - - private companion object { - // TODO: Rely on detail item gen logic? - val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) - val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - } } From cbdad3fe3981cb1bd2460cfd19e745cd0c1be62b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:04 -0600 Subject: [PATCH 64/87] all: reformat/fixes --- .../oxycblt/auxio/detail/DetailGenerator.kt | 62 +++++++++++++------ .../org/oxycblt/auxio/detail/DetailModule.kt | 2 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 48 +++++++------- .../org/oxycblt/auxio/list/ListSettings.kt | 7 +++ .../auxio/music/service/MusicBrowser.kt | 43 +++++++------ .../service/PlaybackServiceFragment.kt | 2 +- .../service/SystemPlaybackReceiver.kt | 5 +- media | 2 +- 8 files changed, 98 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt index bd5356df5..0b48b060c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -1,9 +1,26 @@ +/* + * Copyright (c) 2024 Auxio Project + * DetailGenerator.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.detail -import android.content.Context import androidx.annotation.StringRes +import javax.inject.Inject import org.oxycblt.auxio.R -import org.oxycblt.auxio.detail.list.DiscHeader import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album @@ -18,15 +35,18 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.util.logD -import java.util.SortedMap -import javax.inject.Inject interface DetailGenerator { fun any(uid: Music.UID): Detail? + fun album(uid: Music.UID): Detail? + fun artist(uid: Music.UID): Detail? + fun genre(uid: Music.UID): Detail? + fun playlist(uid: Music.UID): Detail? + fun release() interface Factory { @@ -38,10 +58,10 @@ interface DetailGenerator { } } -class DetailGeneratorFactoryImpl @Inject constructor( - private val listSettings: ListSettings, - private val musicRepository: MusicRepository -) : DetailGenerator.Factory { +class DetailGeneratorFactoryImpl +@Inject +constructor(private val listSettings: ListSettings, private val musicRepository: MusicRepository) : + DetailGenerator.Factory { override fun create(invalidator: DetailGenerator.Invalidator): DetailGenerator = DetailGeneratorImpl(invalidator, listSettings, musicRepository) } @@ -102,11 +122,12 @@ private class DetailGeneratorImpl( val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null val songs = listSettings.albumSongSort.songs(album.songs) val discs = songs.groupBy { it.disc } - val section = if (discs.size > 1 || discs.keys.first() != null) { - DetailSection.Discs(discs) - } else { - DetailSection.Songs(songs) - } + val section = + if (discs.size > 1 || discs.keys.first() != null) { + DetailSection.Discs(discs) + } else { + DetailSection.Songs(songs) + } return Detail(album, listOf(section)) } @@ -138,13 +159,14 @@ private class DetailGeneratorImpl( // implicit album list into the mapping. logD("Implicit albums present, adding to list") @Suppress("UNCHECKED_CAST") - (grouping as MutableMap>)[DetailSection.Albums.Category.APPEARANCES] = - artist.implicitAlbums + (grouping as MutableMap>)[ + DetailSection.Albums.Category.APPEARANCES] = artist.implicitAlbums } - val sections = grouping.mapTo(mutableListOf()) { (category, albums) -> - DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums)) - } + val sections = + grouping.mapTo(mutableListOf()) { (category, albums) -> + DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums)) + } val songs = DetailSection.Songs(listSettings.artistSongSort.songs(artist.songs)) sections.add(songs) return Detail(artist, sections) @@ -184,7 +206,8 @@ sealed interface DetailSection { override val stringRes = R.string.lbl_songs } - data class Albums(val category: Category, override val items: List) : PlainSection() { + data class Albums(val category: Category, override val items: List) : + PlainSection() { override val order = 1 + category.ordinal override val stringRes = category.stringRes @@ -203,7 +226,6 @@ sealed interface DetailSection { } } - data class Songs(override val items: List) : PlainSection() { override val order = 12 override val stringRes = R.string.lbl_songs diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt index 4d41529db..2fde9b6a6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * HomeModule.kt is part of Auxio. + * DetailModule.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 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 03390b2a1..d5fbc9806 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -74,7 +74,6 @@ constructor( ) : ViewModel(), DetailGenerator.Invalidator { private val detailGenerator = detailGeneratorFactory.create(this) - private val _toShow = MutableEvent() /** * A [Show] command that is awaiting a view capable of responding to it. Null if none currently. @@ -208,21 +207,18 @@ constructor( val album = detailGenerator.album(currentAlbum.value?.uid ?: return) refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace) } - MusicType.ARTISTS -> { val artist = detailGenerator.artist(currentArtist.value?.uid ?: return) - refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, replace) + refreshDetail( + artist, _currentArtist, _artistSongList, _artistSongInstructions, replace) } - MusicType.GENRES -> { val genre = detailGenerator.genre(currentGenre.value?.uid ?: return) refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace) } - MusicType.PLAYLISTS -> { refreshPlaylist(currentPlaylist.value?.uid ?: return) } - else -> error("Unexpected music type $type") } } @@ -522,7 +518,6 @@ constructor( } } - private fun refreshDetail( detail: Detail?, parent: MutableStateFlow, @@ -537,24 +532,23 @@ constructor( val newList = mutableListOf() var newInstructions: UpdateInstructions = UpdateInstructions.Diff for ((i, section) in detail.sections.withIndex()) { - val items = when (section) { - is DetailSection.PlainSection<*> -> { - val header = if (section is DetailSection.Songs) - SortHeader(section.stringRes) else BasicHeader(section.stringRes) - newList.add(Divider(header)) - newList.add(header) - section.items - } - - is DetailSection.Discs -> { - val header = BasicHeader(section.stringRes) - newList.add(Divider(header)) - newList.add(header) - section.discs.flatMap { - listOf(DiscHeader(it.key)) + it.value + val items = + when (section) { + is DetailSection.PlainSection<*> -> { + val header = + if (section is DetailSection.Songs) SortHeader(section.stringRes) + else BasicHeader(section.stringRes) + newList.add(Divider(header)) + newList.add(header) + section.items + } + is DetailSection.Discs -> { + val header = BasicHeader(section.stringRes) + newList.add(Divider(header)) + newList.add(header) + section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value } } } - } // Currently only the final section (songs, which can be sorted) are invalidatable // and thus need to be replaced. if (replace == -1 && i == detail.sections.lastIndex) { @@ -568,12 +562,16 @@ constructor( instructions.put(newInstructions) } - private fun refreshPlaylist(uid: Music.UID, instructions: UpdateInstructions = UpdateInstructions.Diff) { + private fun refreshPlaylist( + uid: Music.UID, + instructions: UpdateInstructions = UpdateInstructions.Diff + ) { logD("Refreshing playlist list") val edited = editedPlaylist.value if (edited == null) { val playlist = detailGenerator.playlist(uid) - refreshDetail(playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) + refreshDetail( + playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) return } val list = mutableListOf() diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt index 37cb2cc23..7dcbfa13f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt @@ -46,12 +46,19 @@ interface ListSettings : Settings { interface Listener { fun onSongSortChanged() {} + fun onAlbumSortChanged() {} + fun onAlbumSongSortChanged() {} + fun onArtistSortChanged() {} + fun onArtistSongSortChanged() {} + fun onGenreSortChanged() {} + fun onGenreSongSortChanged() {} + fun onPlaylistSortChanged() {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 2a109204a..0e82b5d86 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -24,13 +24,8 @@ import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.detail.DetailGenerator import org.oxycblt.auxio.detail.DetailSection -import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.home.HomeGenerator -import org.oxycblt.auxio.list.BasicHeader -import org.oxycblt.auxio.list.Divider -import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -98,13 +93,14 @@ private constructor( override fun invalidate(type: MusicType, replace: Int?) { val deviceLibrary = musicRepository.deviceLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return - val music = when (type) { - MusicType.ALBUMS -> deviceLibrary.albums - MusicType.ARTISTS -> deviceLibrary.artists - MusicType.GENRES -> deviceLibrary.genres - MusicType.PLAYLISTS -> userLibrary.playlists - else -> return - } + val music = + when (type) { + MusicType.ALBUMS -> deviceLibrary.albums + MusicType.ARTISTS -> deviceLibrary.artists + MusicType.GENRES -> deviceLibrary.genres + MusicType.PLAYLISTS -> userLibrary.playlists + else -> return + } if (music.isEmpty()) { return } @@ -226,20 +222,23 @@ private constructor( val detail = detailGenerator.any(uid) ?: return null return detail.sections.flatMap { section -> when (section) { - is DetailSection.Songs -> section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } - is DetailSection.Albums -> section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } - is DetailSection.Artists -> section.items.map { it.toMediaItem(context, header(section.stringRes)) } - is DetailSection.Discs -> section.discs.flatMap { + is DetailSection.Songs -> + section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + is DetailSection.Albums -> + section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + is DetailSection.Artists -> + section.items.map { it.toMediaItem(context, header(section.stringRes)) } + is DetailSection.Discs -> section.discs.flatMap { entry -> val disc = entry.key - val discString = if (disc != null) { - context.getString(R.string.fmt_disc_no, disc.number) - } else { - context.getString(R.string.def_disc) - } + val discString = + if (disc != null) { + context.getString(R.string.fmt_disc_no, disc.number) + } else { + context.getString(R.string.def_disc) + } entry.value.map { it.toMediaItem(context, null, header(discString)) } } - } else -> error("Unknown section type: $section") } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index d0c419ae1..9f77f714e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -64,7 +64,7 @@ private constructor( private val exoHolder = exoHolderFactory.create() private val sessionHolder = sessionHolderFactory.create(context, foregroundListener) private val widgetComponent = widgetComponentFactory.create(context) - private val systemReceiver = systemReceiverFactory.create(context) + private val systemReceiver = systemReceiverFactory.create(context, widgetComponent) val token: MediaSessionCompat.Token get() = sessionHolder.token diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index 3a10039db..4a846cdfe 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -47,10 +47,9 @@ private constructor( @Inject constructor( private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings, - private val widgetComponent: WidgetComponent + private val playbackSettings: PlaybackSettings ) { - fun create(context: Context): SystemPlaybackReceiver { + fun create(context: Context, widgetComponent: WidgetComponent): SystemPlaybackReceiver { val receiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) ContextCompat.registerReceiver( diff --git a/media b/media index 34b33175c..9fc2401b8 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 34b33175c00183dc95cdcb8c735033b6785041e1 +Subproject commit 9fc2401b8fdc2b23905402462e775c6db4e1527f From 14035956e6b38cee5b46345cdd72a37f5128dd65 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:04 -0600 Subject: [PATCH 65/87] music: tear down menus Only works on automotive OS, which I am not targeting right now. --- .../music/service/MediaItemTranslation.kt | 60 ++----------------- .../music/service/MusicServiceFragment.kt | 16 +---- 2 files changed, 6 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 1d3c8ff02..b2181e5c6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -83,46 +83,6 @@ sealed interface MediaSessionUID { } } -enum class BrowserOption(val actionId: String, val labelRes: Int, val iconRes: Int) { - PLAY(BuildConfig.APPLICATION_ID + ".menu.PLAY", R.string.lbl_play, R.drawable.ic_play_24), - SHUFFLE( - BuildConfig.APPLICATION_ID + ".menu.SHUFFLE", - R.string.lbl_shuffle, - R.drawable.ic_shuffle_off_24), - PLAY_NEXT( - BuildConfig.APPLICATION_ID + ".menu.PLAY_NEXT", - R.string.lbl_play_next, - R.drawable.ic_play_next_24), - ADD_TO_QUEUE( - BuildConfig.APPLICATION_ID + ".menu.ADD_TO_QUEUE", - R.string.lbl_queue_add, - R.drawable.ic_queue_add_24), - DETAILS( - BuildConfig.APPLICATION_ID + ".menu.DETAILS", - R.string.lbl_parent_detail, - R.drawable.ic_details_24), - ALBUM_DETAILS( - BuildConfig.APPLICATION_ID + ".menu.ALBUM_DETAILS", - R.string.lbl_album_details, - R.drawable.ic_album_24), - ARTIST_DETAILS( - BuildConfig.APPLICATION_ID + ".menu.ARTIST_DETAILS", - R.string.lbl_artist_details, - R.drawable.ic_artist_24); - - companion object { - val ITEM_ID_MAP = - mapOf( - R.id.action_play to PLAY, - R.id.action_shuffle to SHUFFLE, - R.id.action_play_next to PLAY_NEXT, - R.id.action_queue_add to ADD_TO_QUEUE, - R.id.action_detail to DETAILS, - R.id.action_album_details to ALBUM_DETAILS, - R.id.action_artist_details to ARTIST_DETAILS) - } -} - typealias Sugar = Bundle.(Context) -> Unit fun header(@StringRes nameRes: Int): Sugar = { @@ -138,16 +98,6 @@ private fun style(style: Int): Sugar = { putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style) } -private fun menu(@MenuRes res: Int): Sugar = { context -> - @SuppressLint("RestrictedApi") val builder = MenuBuilder(context) - MenuInflater(context).inflate(res, builder) - val menuIds = - builder.children.mapNotNullTo(ArrayList()) { - BrowserOption.ITEM_ID_MAP[it.itemId]?.actionId - } - putStringArrayList(MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, menuIds) -} - private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle { return Bundle().apply { sugars.forEach { this.it(context) } } } @@ -181,7 +131,7 @@ fun Song.toMediaDescription( } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val extras = makeExtras(context, *sugar, menu(R.menu.song)) + val extras = makeExtras(context, *sugar) return MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) @@ -212,7 +162,7 @@ fun Album.toMediaItem( } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val extras = makeExtras(context, *sugar, menu(R.menu.album)) + val extras = makeExtras(context, *sugar) val counts = context.getPlural(R.plurals.fmt_song_count, songs.size) val description = MediaDescriptionCompat.Builder() @@ -241,7 +191,7 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) }) - val extras = makeExtras(context, *sugar, menu(R.menu.parent)) + val extras = makeExtras(context, *sugar) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -262,7 +212,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } - val extras = makeExtras(context, *sugar, menu(R.menu.parent)) + val extras = makeExtras(context, *sugar) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -282,7 +232,7 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } - val extras = makeExtras(context, *sugar, menu(R.menu.playlist)) + val extras = makeExtras(context, *sugar) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index a36769d9c..2cc93401d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -98,21 +98,7 @@ constructor( fun getRoot(maxItems: Int) = BrowserRoot( MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(), - Bundle().apply { - val actions = - BrowserOption.entries.mapTo(ArrayList()) { - Bundle().apply { - putString( - MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.actionId) - putString( - MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL, - context.getString(it.labelRes)) - } - } - putParcelableArrayList( - MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST, - actions) - }) + Bundle()) fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } From e12ce82615a6f095242f676f7697a7b81ebd6f7e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:04 -0600 Subject: [PATCH 66/87] all: reformat --- .../org/oxycblt/auxio/music/service/MediaItemTranslation.kt | 5 ----- .../org/oxycblt/auxio/music/service/MusicServiceFragment.kt | 5 +---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index b2181e5c6..421405123 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -18,17 +18,12 @@ package org.oxycblt.auxio.music.service -import android.annotation.SuppressLint import android.content.Context import android.graphics.BitmapFactory import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat -import android.view.MenuInflater -import androidx.annotation.MenuRes import androidx.annotation.StringRes -import androidx.appcompat.view.menu.MenuBuilder -import androidx.core.view.children import androidx.media.utils.MediaConstants import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 2cc93401d..2d425e793 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -23,7 +23,6 @@ import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.media.MediaBrowserServiceCompat.BrowserRoot import androidx.media.MediaBrowserServiceCompat.Result -import androidx.media.utils.MediaConstants import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -96,9 +95,7 @@ constructor( } fun getRoot(maxItems: Int) = - BrowserRoot( - MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(), - Bundle()) + BrowserRoot(MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(), Bundle()) fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } From 751cd9473626bfa61238e53a7fb8d1a283c67cdb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:04 -0600 Subject: [PATCH 67/87] service: re-add attach pattern Turns out I can't actually couple creation/attach without creating a huge amount of variable issues. --- .../java/org/oxycblt/auxio/AuxioService.kt | 3 +- .../oxycblt/auxio/detail/DetailGenerator.kt | 4 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 4 +- .../org/oxycblt/auxio/home/HomeGenerator.kt | 64 +++++++++---------- .../org/oxycblt/auxio/home/HomeViewModel.kt | 4 +- .../oxycblt/auxio/music/service/Indexer.kt | 2 +- .../auxio/music/service/MusicBrowser.kt | 5 ++ .../music/service/MusicServiceFragment.kt | 5 ++ .../replaygain/ReplayGainAudioProcessor.kt | 14 +--- .../service/ExoPlaybackStateHolder.kt | 7 +- .../playback/service/MediaSessionHolder.kt | 2 +- .../service/PlaybackServiceFragment.kt | 10 +-- .../service/SystemPlaybackReceiver.kt | 19 ++++-- .../oxycblt/auxio/widgets/WidgetComponent.kt | 2 +- 14 files changed, 77 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index aeb0e6db7..ecfc404fe 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -49,8 +49,9 @@ class AuxioService : override fun onCreate() { super.onCreate() playbackFragment = playbackFragmentFactory.create(this, this) - sessionToken = playbackFragment.token + sessionToken = playbackFragment.attach() musicFragment = musicFragmentFactory.create(this, this, this) + musicFragment.attach() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt index 0b48b060c..5e8cc4ed8 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -47,6 +47,8 @@ interface DetailGenerator { fun playlist(uid: Music.UID): Detail? + fun attach() + fun release() interface Factory { @@ -71,7 +73,7 @@ private class DetailGeneratorImpl( private val listSettings: ListSettings, private val musicRepository: MusicRepository ) : DetailGenerator, MusicRepository.UpdateListener, ListSettings.Listener { - init { + override fun attach() { listSettings.registerListener(this) musicRepository.addUpdateListener(this) } 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 d5fbc9806..1ca0a7008 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -72,8 +72,6 @@ constructor( private val playbackSettings: PlaybackSettings, detailGeneratorFactory: DetailGenerator.Factory ) : ViewModel(), DetailGenerator.Invalidator { - private val detailGenerator = detailGeneratorFactory.create(this) - private val _toShow = MutableEvent() /** * A [Show] command that is awaiting a view capable of responding to it. Null if none currently. @@ -197,6 +195,8 @@ constructor( playbackSettings.inParentPlaybackMode ?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value)) + private val detailGenerator = detailGeneratorFactory.create(this) + override fun onCleared() { detailGenerator.release() } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt index 5c551db0e..6b8706cbf 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt @@ -32,6 +32,10 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.logD interface HomeGenerator { + fun attach() + + fun release() + fun songs(): List fun albums(): List @@ -44,8 +48,6 @@ interface HomeGenerator { fun tabs(): List - fun release() - interface Invalidator { fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) @@ -74,41 +76,14 @@ private class HomeGeneratorImpl( private val listSettings: ListSettings, private val musicRepository: MusicRepository, ) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { - override fun songs() = - musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() - - override fun albums() = - musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } - ?: emptyList() - - override fun artists() = - musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } - ?: emptyList() - - override fun genres() = - musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } - ?: emptyList() - - override fun playlists() = - musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } - ?: emptyList() - - override fun tabs() = homeSettings.homeTabs.filterIsInstance().map { it.type } - - override fun onTabsChanged() { - invalidator.invalidateTabs() - } - - init { + override fun attach() { homeSettings.registerListener(this) listSettings.registerListener(this) musicRepository.addUpdateListener(this) } - override fun release() { - musicRepository.removeUpdateListener(this) - listSettings.unregisterListener(this) - homeSettings.unregisterListener(this) + override fun onTabsChanged() { + invalidator.invalidateTabs() } override fun onHideCollaboratorsChanged() { @@ -161,4 +136,29 @@ private class HomeGeneratorImpl( invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) } } + override fun release() { + musicRepository.removeUpdateListener(this) + listSettings.unregisterListener(this) + homeSettings.unregisterListener(this) + } + override fun songs() = + musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() + + override fun albums() = + musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } + ?: emptyList() + + override fun artists() = + musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } + ?: emptyList() + + override fun genres() = + musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } + ?: emptyList() + + override fun playlists() = + musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } + ?: emptyList() + + override fun tabs() = homeSettings.homeTabs.filterIsInstance().map { it.type } } 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 206b4bd0a..eec145be1 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -52,8 +52,6 @@ constructor( private val playbackSettings: PlaybackSettings, homeGeneratorFactory: HomeGenerator.Factory ) : ViewModel(), HomeGenerator.Invalidator { - private val homeGenerator = homeGeneratorFactory.create(this) - private val _songList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ val songList: StateFlow> @@ -131,6 +129,8 @@ constructor( val playlistSort: Sort get() = listSettings.playlistSort + private val homeGenerator = homeGeneratorFactory.create(this) + /** * A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible * [Tab]s. diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index de5969216..6b3dfb29f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -80,7 +80,7 @@ private constructor( .newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") - init { + fun attach() { musicSettings.registerListener(this) musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 0e82b5d86..2fadb2dff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -71,6 +71,11 @@ private constructor( private val homeGenerator = homeGeneratorFactory.create(this) private val detailGenerator = detailGeneratorFactory.create(this) + fun attach() { + homeGenerator.attach() + detailGenerator.attach() + } + fun release() { homeGenerator.release() detailGenerator.release() diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 2d425e793..ebf91c001 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -74,6 +74,11 @@ constructor( fun invalidateMusic(mediaId: String) } + fun attach() { + indexer.attach() + musicBrowser.attach() + } + fun release() { dispatchJob.cancel() musicBrowser.release() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 1a1770736..5f2848508 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -44,20 +44,10 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor -private constructor( +class ReplayGainAudioProcessor @Inject constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings ) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { - class Factory - @Inject - constructor( - private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings - ) { - fun create() = ReplayGainAudioProcessor(playbackManager, playbackSettings) - } - private var volume = 1f set(value) { field = value @@ -65,7 +55,7 @@ private constructor( flush() } - init { + fun attach() { playbackManager.addListener(this) playbackSettings.registerListener(this) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index b705cf1ab..4e95e54d4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -86,7 +86,7 @@ class ExoPlaybackStateHolder( var sessionOngoing = false private set - init { + fun attach() { imageSettings.registerListener(this) player.addListener(this) playbackManager.registerStateHolder(this) @@ -581,14 +581,13 @@ class ExoPlaybackStateHolder( private val playbackSettings: PlaybackSettings, private val commandFactory: PlaybackCommand.Factory, private val mediaSourceFactory: MediaSource.Factory, - private val replayGainProcessorFactory: ReplayGainAudioProcessor.Factory, + private val replayGainProcessor: ReplayGainAudioProcessor, private val musicRepository: MusicRepository, private val imageSettings: ImageSettings, ) { fun create(): ExoPlaybackStateHolder { // Since Auxio is a music player, only specify an audio renderer to save - // battery/apk size/cache size - val replayGainProcessor = replayGainProcessorFactory.create() + // battery/apk size/cache size] val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> arrayOf( FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index f683876d2..9d3af9bab 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -96,7 +96,7 @@ private constructor( val notification: ForegroundServiceNotification get() = _notification - init { + fun attach() { playbackManager.addListener(this) playbackSettings.registerListener(this) imageSettings.registerListener(this) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 9f77f714e..04af2a40f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -66,13 +66,15 @@ private constructor( private val widgetComponent = widgetComponentFactory.create(context) private val systemReceiver = systemReceiverFactory.create(context, widgetComponent) - val token: MediaSessionCompat.Token - get() = sessionHolder.token - // --- MEDIASESSION CALLBACKS --- - init { + fun attach(): MediaSessionCompat.Token { + exoHolder.attach() + sessionHolder.attach() + widgetComponent.attach() + systemReceiver.attach() playbackManager.addListener(this) + return sessionHolder.token } fun handleTaskRemoved() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index 4a846cdfe..4e7c214e0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -37,6 +37,7 @@ import org.oxycblt.auxio.widgets.WidgetProvider */ class SystemPlaybackReceiver private constructor( + private val context: Context, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val widgetComponent: WidgetComponent @@ -49,13 +50,17 @@ private constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings ) { - fun create(context: Context, widgetComponent: WidgetComponent): SystemPlaybackReceiver { - val receiver = - SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) - ContextCompat.registerReceiver( - context, receiver, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) - return receiver - } + fun create(context: Context, widgetComponent: WidgetComponent) = + SystemPlaybackReceiver(context, playbackManager, playbackSettings, widgetComponent) + } + + fun attach() { + ContextCompat.registerReceiver( + context, this, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) + } + + fun release() { + context.unregisterReceiver(this) } override fun onReceive(context: Context, intent: Intent) { diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 367b44f42..909ff7e45 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -67,7 +67,7 @@ private constructor( private val widgetProvider = WidgetProvider() - init { + fun attach() { playbackManager.addListener(this) uiSettings.registerListener(this) imageSettings.registerListener(this) From 2bd468bce3f51097190aa8db35e01e79d3a19960 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:04 -0600 Subject: [PATCH 68/87] detail: fix incorrect disc section generation --- app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt index 5e8cc4ed8..0a4ffd379 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -125,7 +125,7 @@ private class DetailGeneratorImpl( val songs = listSettings.albumSongSort.songs(album.songs) val discs = songs.groupBy { it.disc } val section = - if (discs.size > 1 || discs.keys.first() != null) { + if (discs.size > 1) { DetailSection.Discs(discs) } else { DetailSection.Songs(songs) From 3afbedb6bd1bb1d27f5336a28332627c20960870 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 69/87] ui: attach to generators --- app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt | 4 ++++ app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt | 4 ++++ 2 files changed, 8 insertions(+) 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 1ca0a7008..7baa21554 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -197,6 +197,10 @@ constructor( private val detailGenerator = detailGeneratorFactory.create(this) + init { + detailGenerator.attach() + } + override fun onCleared() { detailGenerator.release() } 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 eec145be1..0894e0576 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -163,6 +163,10 @@ constructor( val showOuter: Event get() = _showOuter + init { + homeGenerator.attach() + } + override fun onCleared() { super.onCleared() homeGenerator.release() From 6f3fc5904a28af41b8a42a66233ad1c611276e09 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 70/87] detail: generate sort header w/discs --- app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7baa21554..a73e379ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -547,7 +547,7 @@ constructor( section.items } is DetailSection.Discs -> { - val header = BasicHeader(section.stringRes) + val header = SortHeader(section.stringRes) newList.add(Divider(header)) newList.add(header) section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value } From d6a0b756183bb4133812448fd720c23f23f49d26 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 71/87] detail: fix broken item refresh --- app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a73e379ea..285a21d6f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -562,8 +562,8 @@ constructor( newList.addAll(items) } parent.value = detail.parent - list.value = newList instructions.put(newInstructions) + list.value = newList } private fun refreshPlaylist( @@ -585,8 +585,8 @@ constructor( list.add(header) list.addAll(edited) } - _playlistSongList.value = list _playlistSongInstructions.put(instructions) + _playlistSongList.value = list } } From f84e3428f03aa14b180d343eb8f105bb78e6274b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 72/87] home: fix broken item refresh --- .../main/java/org/oxycblt/auxio/home/HomeViewModel.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 0894e0576..2eb6a7d68 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -175,24 +175,24 @@ constructor( override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { when (type) { MusicType.SONGS -> { - _songList.value = homeGenerator.songs() _songInstructions.put(instructions) + _songList.value = homeGenerator.songs() } MusicType.ALBUMS -> { - _albumList.value = homeGenerator.albums() _albumInstructions.put(instructions) + _albumList.value = homeGenerator.albums() } MusicType.ARTISTS -> { - _artistList.value = homeGenerator.artists() _artistInstructions.put(instructions) + _artistList.value = homeGenerator.artists() } MusicType.GENRES -> { - _genreList.value = homeGenerator.genres() _genreInstructions.put(instructions) + _genreList.value = homeGenerator.genres() } MusicType.PLAYLISTS -> { - _playlistList.value = homeGenerator.playlists() _playlistInstructions.put(instructions) + _playlistList.value = homeGenerator.playlists() } } } From c9664d75c090e8a9ca9ca79c3539495aa40ddb1e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 73/87] home: dont show tab icons in phone mode --- .../java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt index 45f63fd7d..7cf0c2a53 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -52,7 +52,7 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) : // On small screens, only display an icon. width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes) // On large screens, display an icon and text. - width < 600 -> tab.setText(homeTab.nameRes).setIcon(icon) + width < 600 -> tab.setText(homeTab.nameRes) // On medium-size screens, display text. else -> tab.setIcon(icon).setText(homeTab.nameRes) } From f4589616bea7b4caba2d6f46f78f104f0e0f41ee Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 74/87] music: simplify disc number resolution Introduce a resolveDisc extension function to share disc name resolution between detail/browser --- .../auxio/detail/list/AlbumDetailListAdapter.kt | 15 +++++---------- .../java/org/oxycblt/auxio/home/HomeGenerator.kt | 2 ++ .../java/org/oxycblt/auxio/music/info/Disc.kt | 5 +++++ .../oxycblt/auxio/music/service/MusicBrowser.kt | 13 ++++--------- .../replaygain/ReplayGainAudioProcessor.kt | 4 +++- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt index 63419e1e5..dc672d421 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt @@ -35,6 +35,7 @@ import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.music.info.resolveNumber import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater @@ -111,16 +112,10 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : */ fun bind(discHeader: DiscHeader) { val disc = discHeader.inner - if (disc != null) { - binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number) - binding.discName.apply { - text = disc.name - isGone = disc.name == null - } - } else { - logD("Disc is null, defaulting to no disc") - binding.discNumber.text = binding.context.getString(R.string.def_disc) - binding.discName.isGone = true + binding.discNumber.text = disc.resolveNumber(binding.context) + binding.discName.apply { + text = disc?.name + isGone = disc?.name == null } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt index 6b8706cbf..52135d3d4 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt @@ -136,11 +136,13 @@ private class HomeGeneratorImpl( invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) } } + override fun release() { musicRepository.removeUpdateListener(this) listSettings.unregisterListener(this) homeSettings.unregisterListener(this) } + override fun songs() = musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt index 2c8fd360b..b71bb65e9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt @@ -18,6 +18,8 @@ package org.oxycblt.auxio.music.info +import android.content.Context +import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Item /** @@ -34,3 +36,6 @@ class Disc(val number: Int, val name: String?) : Item, Comparable { override fun compareTo(other: Disc) = number.compareTo(other.number) } + + +fun Disc?.resolveNumber(context: Context) = this?.run { context.getString(R.string.fmt_disc_no, number) } ?: context.getString(R.string.def_disc) \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 2fadb2dff..1d8bb09d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -34,6 +34,7 @@ import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.info.resolveNumber import org.oxycblt.auxio.search.SearchEngine class MusicBrowser @@ -234,15 +235,9 @@ private constructor( is DetailSection.Artists -> section.items.map { it.toMediaItem(context, header(section.stringRes)) } is DetailSection.Discs -> - section.discs.flatMap { entry -> - val disc = entry.key - val discString = - if (disc != null) { - context.getString(R.string.fmt_disc_no, disc.number) - } else { - context.getString(R.string.def_disc) - } - entry.value.map { it.toMediaItem(context, null, header(discString)) } + section.discs.flatMap { (disc, songs) -> + val discString = disc.resolveNumber(context) + songs.map { it.toMediaItem(context, null, header(discString)) } } else -> error("Unknown section type: $section") } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 5f2848508..1152ef4e3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -44,7 +44,9 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor @Inject constructor( +class ReplayGainAudioProcessor +@Inject +constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings ) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { From cb43b0f074f3bc53787ea4db92dba32cb16abae4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 75/87] service: decouple maxtab handling and ids Simpler and more versatile. --- .../java/org/oxycblt/auxio/AuxioService.kt | 9 ++-- .../auxio/music/service/MusicBrowser.kt | 32 +++++++------- .../music/service/MusicServiceFragment.kt | 13 +++--- .../oxycblt/auxio/music/service/TabNode.kt | 42 +++++++------------ 4 files changed, 43 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index ecfc404fe..c5842f2f5 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -89,9 +89,7 @@ class AuxioService : clientUid: Int, rootHints: Bundle? ): BrowserRoot { - val maximumRootChildLimit = - rootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4 - return musicFragment.getRoot(maximumRootChildLimit) + return musicFragment.getRoot() } override fun onLoadItem(itemId: String, result: Result) { @@ -99,7 +97,10 @@ class AuxioService : } override fun onLoadChildren(parentId: String, result: Result>) { - musicFragment.getChildren(parentId, result) + val maximumRootChildLimit = + browserRootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) + ?: 4 + musicFragment.getChildren(parentId, maximumRootChildLimit, result) } override fun onSearch(query: String, extras: Bundle?, result: Result>) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 1d8bb09d1..bccdef87c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -88,12 +88,9 @@ private constructor( } override fun invalidateTabs() { - for (i in 0..10) { - // TODO: Temporary bodge, move the amount parameter to a bundle extra - val rootId = MediaSessionUID.Tab(TabNode.Root(i)).toString() - val moreId = MediaSessionUID.Tab(TabNode.More(i)).toString() - invalidator.invalidateMusic(setOf(rootId, moreId)) - } + val rootId = MediaSessionUID.Tab(TabNode.Root).toString() + val moreId = MediaSessionUID.Tab(TabNode.More).toString() + invalidator.invalidateMusic(setOf(rootId, moreId)) } override fun invalidate(type: MusicType, replace: Int?) { @@ -135,14 +132,13 @@ private constructor( } } - fun getChildren(parentId: String): List? { + fun getChildren(parentId: String, maxTabs: Int): List? { val deviceLibrary = musicRepository.deviceLibrary val userLibrary = musicRepository.userLibrary if (deviceLibrary == null || userLibrary == null) { return listOf() } - - return getMediaItemList(parentId) + return getMediaItemList(parentId, maxTabs) } suspend fun search(query: String): MutableList { @@ -181,10 +177,10 @@ private constructor( return music } - private fun getMediaItemList(id: String): List? { + private fun getMediaItemList(id: String, maxTabs: Int): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { is MediaSessionUID.Tab -> { - getCategoryMediaItems(mediaSessionUID.node) + getCategoryMediaItems(mediaSessionUID.node, maxTabs) } is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) @@ -198,22 +194,22 @@ private constructor( } } - private fun getCategoryMediaItems(node: TabNode) = + private fun getCategoryMediaItems(node: TabNode, maxTabs: Int) = when (node) { is TabNode.Root -> { val tabs = homeGenerator.tabs() - val base = tabs.take(node.amount - 1).map { TabNode.Home(it) } + val base = tabs.take(maxTabs - 1).map { TabNode.Home(it) } if (base.size < tabs.size) { - base + TabNode.More(tabs.size - base.size) + base + TabNode.More } else { base } .map { it.toMediaItem(context) } } - is TabNode.More -> - homeGenerator.tabs().takeLast(node.remainder).map { - TabNode.Home(it).toMediaItem(context) - } + is TabNode.More -> { + val tabs = homeGenerator.tabs() + tabs.takeLast(tabs.size - maxTabs).map { TabNode.Home(it).toMediaItem(context) } + } is TabNode.Home -> when (node.type) { MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context, null) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index ebf91c001..a076e3c7a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -99,14 +99,17 @@ constructor( indexer.createNotification(post) } - fun getRoot(maxItems: Int) = - BrowserRoot(MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(), Bundle()) + fun getRoot() = BrowserRoot(MediaSessionUID.Tab(TabNode.Root).toString(), Bundle()) fun getItem(mediaId: String, result: Result) = - result.dispatch { musicBrowser.getItem(mediaId) } + result.dispatch { + musicBrowser.getItem( + mediaId, + ) + } - fun getChildren(mediaId: String, result: Result>) = - result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } + fun getChildren(mediaId: String, maxTabs: Int, result: Result>) = + result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.toMutableList() } fun search(query: String, result: Result>) = result.dispatchAsync { musicBrowser.search(query) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt index 1e9705511..0c972659a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt @@ -23,37 +23,27 @@ import org.oxycblt.auxio.music.MusicType sealed class TabNode { abstract val id: String - abstract val data: Int abstract val nameRes: Int abstract val bitmapRes: Int? - override fun toString() = "${id}/${data}" + override fun toString() = id - data class Root(val amount: Int) : TabNode() { - override val id = ID - override val data = amount + data object Root : TabNode() { + override val id = "root" override val nameRes = R.string.info_app_name override val bitmapRes = null - companion object { - const val ID = "root" - } + override fun toString() = id } - data class More(val remainder: Int) : TabNode() { - override val id = ID - override val data = remainder + data object More : TabNode() { + override val id = "more" override val nameRes = R.string.lbl_more override val bitmapRes = null - - companion object { - const val ID = "more" - } } data class Home(val type: MusicType) : TabNode() { - override val id = ID - override val data = type.intCode + override val id = "$ID/${type.intCode}" override val bitmapRes: Int get() = when (type) { @@ -73,15 +63,15 @@ sealed class TabNode { companion object { fun fromString(str: String): TabNode? { - val split = str.split("/", limit = 2) - if (split.size != 2) { - return null - } - val data = split[1].toIntOrNull() ?: return null - return when (split[0]) { - Root.ID -> Root(data) - More.ID -> More(data) - Home.ID -> Home(MusicType.fromIntCode(data) ?: return null) + return when { + str == Root.id -> Root + str == More.id -> More + str.startsWith(Home.ID) -> { + val split = str.split("/") + if (split.size != 2) return null + val intCode = split[1].toIntOrNull() ?: return null + Home(MusicType.fromIntCode(intCode) ?: return null) + } else -> null } } From f0bda0c99f3a02ade1ae661c6ed635dea04e9399 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 76/87] service: avoid crash on death --- app/src/main/java/org/oxycblt/auxio/AuxioService.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index c5842f2f5..0d1eb0bbc 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -81,7 +81,6 @@ class AuxioService : super.onDestroy() musicFragment.release() playbackFragment.release() - sessionToken = null } override fun onGetRoot( From c236a449c860e49b6bfe6a744d16c3b0156e81f2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 77/87] music: introduce icon for backport more tab --- .../org/oxycblt/auxio/music/service/TabNode.kt | 2 +- .../main/res/drawable-hdpi/ic_more_bitmap_24.png | Bin 0 -> 123 bytes .../main/res/drawable-mdpi/ic_more_bitmap_24.png | Bin 0 -> 97 bytes .../main/res/drawable-xhdpi/ic_more_bitmap_24.png | Bin 0 -> 141 bytes .../res/drawable-xxhdpi/ic_more_bitmap_24.png | Bin 0 -> 179 bytes .../res/drawable-xxxhdpi/ic_more_bitmap_24.png | Bin 0 -> 217 bytes 6 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png create mode 100644 app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt index 0c972659a..d13dd42be 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt @@ -39,7 +39,7 @@ sealed class TabNode { data object More : TabNode() { override val id = "more" override val nameRes = R.string.lbl_more - override val bitmapRes = null + override val bitmapRes = R.drawable.ic_more_bitmap_24 } data class Home(val type: MusicType) : TabNode() { diff --git a/app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..ee3339aa0de870bb172759efbc4b64b9e5250326 GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k0wldT1B8K;lc$Sgh{y4_7Yz9r6gZp%#n$Vo zw`O1QRA=`o_?@5QF*%8iq1MD?s+VtXRgrd4Zk&IfYhmA_c{RFOv#)*4VW`;7;M}$M U;AStwT%c(Tp00i_>zopr04)t<8 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..fabe49c281cb36636c9083f7c428c0fb26a58ff8 GIT binary patch literal 97 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_*14Nn)x5R22v2@ u`(b0Csm(8brv48bHLp0Vn(TJafSutHdr0Cg%`JjJJq(_%elF{r5}E*(&K!FH literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..16c932bde39cf7e4794547af9ce4dd84d5e55d79 GIT binary patch literal 141 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA0wn)(8}b0D5KkA!kch)?FKy&Kpupo2_${HX zpH0*{MvFx=rsagu(fX&J&s1guwH{a+lKSeE(N+7M|NmStxpw0E9esnhZ%n>(-^yMv o_gN?FL+viRq95k03=EobS9Dw_c$R;01Dem^>FVdQ&MBb@04kv}^Z)<= literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..37710f06b803e66e1e45c21077eddded86d70bb1 GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY0wn)GsXhawsytmBLo)8Yy}DblK|!D;QK@@@ z>l$GtjW)JxA+k-}du9gz*T0uIt=tQ!76|q)_1(Q{*|Jq-x$`8w=dM|}dEuL>8`t_> zkKKRn-O8S|0T;bKp18ra^{K;I>CC?UXS}&vZ*MASx4N~J|GNR(A1jDKb`Kbq W-M;yKPu@IlkbtMFpUXO@geCwvEJfb{ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..b9280995377874d5ddea4f74ebdd83f9bd52fcd3 GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD0wg^q?%xcg7I?ZihE&{od+i`+i-7=ZfWBGs z#-?e>vbQ?YAHFY53_anf`mXvp|KvtbBVT5qdJwR_wDkNW)9h(qUd}r=U3TBu8M^nk z^<>UJHC?{`)V$37N3Q<-#MUMwIq$^u^Z&{s5`NFUy881|-Y=Gy@6XlGPq5Q90MoX* z8}7~hf0%8R{K8lIg>TPie3ie+9oG43`cCmRmmwB0>^a7MPW%(2c6X0GNW|0C&t;uc GLK6T^d|CGZ literal 0 HcmV?d00001 From 437d3391e71dfeee03d3ef0c4841021ecbcb2dd5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 78/87] all: reformat --- .../org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt | 1 - app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt index dc672d421..c1134b207 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt @@ -39,7 +39,6 @@ import org.oxycblt.auxio.music.info.resolveNumber import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater -import org.oxycblt.auxio.util.logD /** * An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view. diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt index b71bb65e9..5f2b52bd6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt @@ -37,5 +37,6 @@ class Disc(val number: Int, val name: String?) : Item, Comparable { override fun compareTo(other: Disc) = number.compareTo(other.number) } - -fun Disc?.resolveNumber(context: Context) = this?.run { context.getString(R.string.fmt_disc_no, number) } ?: context.getString(R.string.def_disc) \ No newline at end of file +fun Disc?.resolveNumber(context: Context) = + this?.run { context.getString(R.string.fmt_disc_no, number) } + ?: context.getString(R.string.def_disc) From 1a78e973d765108f1b3148a881b0e8c2dc9b8931 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 79/87] playback: use implicit shuffle in detail playback --- .../auxio/playback/service/MediaSessionInterface.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 3f846ad4f..38153be4a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -292,10 +292,10 @@ constructor( private fun expandMusicIntoCommand(music: Music, parent: MusicParent?) = when (music) { is Song -> expandSongIntoCommand(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) + is Album -> commandFactory.album(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.artist(music, ShuffleMode.IMPLICIT) + is Genre -> commandFactory.genre(music, ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.playlist(music, ShuffleMode.IMPLICIT) } private fun expandSongIntoCommand(music: Song, parent: MusicParent?) = From adfed98b715a1976181d154f48b331b05dd9ec4e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 80/87] music: paginate browser results Hopefully now that I'm self-rolling this it'll actually work. --- .../java/org/oxycblt/auxio/AuxioService.kt | 30 +++++++++++++++---- .../music/service/MusicServiceFragment.kt | 22 +++++++++++--- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 0d1eb0bbc..cd1fe2e91 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -23,6 +23,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.IBinder +import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.annotation.StringRes import androidx.core.app.NotificationChannelCompat @@ -32,6 +33,7 @@ import androidx.core.app.ServiceCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.music.service.MusicBrowser import javax.inject.Inject import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @@ -96,14 +98,32 @@ class AuxioService : } override fun onLoadChildren(parentId: String, result: Result>) { - val maximumRootChildLimit = - browserRootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) - ?: 4 - musicFragment.getChildren(parentId, maximumRootChildLimit, result) + val maximumRootChildLimit = getRootChildrenLimit() + musicFragment.getChildren(parentId, maximumRootChildLimit, result, null) + } + + override fun onLoadChildren( + parentId: String, + result: Result>, + options: Bundle + ) { + val maximumRootChildLimit = getRootChildrenLimit() + musicFragment.getChildren(parentId, maximumRootChildLimit, result, options.getPage()) } override fun onSearch(query: String, extras: Bundle?, result: Result>) { - musicFragment.search(query, result) + musicFragment.search(query, result, extras?.getPage()) + } + + private fun getRootChildrenLimit(): Int { + return browserRootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) + ?: 4 + } + + private fun Bundle.getPage(): MusicServiceFragment.Page? { + val page = getInt(MediaBrowserCompat.EXTRA_PAGE, -1).takeIf { it >= 0 } ?: return null + val pageSize = getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null + return MusicServiceFragment.Page(page, pageSize) } override fun updateForeground(change: ForegroundListener.Change) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index a076e3c7a..5f597d50c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -49,6 +49,8 @@ constructor( private val dispatchJob = Job() private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) + data class Page(val num: Int, val size: Int) + class Factory @Inject constructor( @@ -108,11 +110,12 @@ constructor( ) } - fun getChildren(mediaId: String, maxTabs: Int, result: Result>) = - result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.toMutableList() } + fun getChildren(mediaId: String, maxTabs: Int, result: Result>, page: Page?) = + result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.expose(page) } - fun search(query: String, result: Result>) = - result.dispatchAsync { musicBrowser.search(query) } + + fun search(query: String, result: Result>, page: Page?) = + result.dispatchAsync { musicBrowser.search(query).expose(page) } private fun Result.dispatch(body: () -> T?) { try { @@ -142,4 +145,15 @@ constructor( } } } + + private fun List.expose(page: Page?): MutableList { + if (page == null) return toMutableList() + val start = page.num * page.size + val end = start + page.size + return if (start >= size) { + mutableListOf() + } else { + subList(start, end.coerceAtMost(size)).toMutableList() + } + } } From 0b3a136320c8e8d1f55adc2245db4ce4f80984e1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 81/87] all: reformat --- app/src/main/java/org/oxycblt/auxio/AuxioService.kt | 7 ++++--- .../oxycblt/auxio/music/service/MusicServiceFragment.kt | 9 ++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index cd1fe2e91..5f8cbbe6f 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -33,7 +33,6 @@ import androidx.core.app.ServiceCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants import dagger.hilt.android.AndroidEntryPoint -import org.oxycblt.auxio.music.service.MusicBrowser import javax.inject.Inject import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @@ -116,13 +115,15 @@ class AuxioService : } private fun getRootChildrenLimit(): Int { - return browserRootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) + return browserRootHints?.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4 } private fun Bundle.getPage(): MusicServiceFragment.Page? { val page = getInt(MediaBrowserCompat.EXTRA_PAGE, -1).takeIf { it >= 0 } ?: return null - val pageSize = getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null + val pageSize = + getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null return MusicServiceFragment.Page(page, pageSize) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 5f597d50c..7cdd3fb9e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -110,9 +110,12 @@ constructor( ) } - fun getChildren(mediaId: String, maxTabs: Int, result: Result>, page: Page?) = - result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.expose(page) } - + fun getChildren( + mediaId: String, + maxTabs: Int, + result: Result>, + page: Page? + ) = result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.expose(page) } fun search(query: String, result: Result>, page: Page?) = result.dispatchAsync { musicBrowser.search(query).expose(page) } From 19f3e07c8e2875bf2ba2daa4bf9b0926c5365fec Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 82/87] service: bundle parent info into extras Instead of using mediaId. This makes it so that there is only really one mediaId to work with, with an optional extra for playback that I desperately hope is preserved on all instances of Android Auto. --- .../java/org/oxycblt/auxio/AuxioService.kt | 2 + .../oxycblt/auxio/detail/DetailGenerator.kt | 2 +- .../music/service/MediaItemTranslation.kt | 53 ++++--------------- .../auxio/music/service/MusicBrowser.kt | 28 +++++----- .../playback/service/MediaSessionHolder.kt | 2 +- .../playback/service/MediaSessionInterface.kt | 31 +++++------ 6 files changed, 44 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 5f8cbbe6f..da79b8a11 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -36,6 +36,7 @@ import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment +import org.oxycblt.auxio.util.logD @AndroidEntryPoint class AuxioService : @@ -149,6 +150,7 @@ class AuxioService : } override fun invalidateMusic(mediaId: String) { + logD(mediaId) notifyChildrenChanged(mediaId) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt index 0a4ffd379..348badcdb 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -205,7 +205,7 @@ sealed interface DetailSection { data class Artists(override val items: List) : PlainSection() { override val order = 0 - override val stringRes = R.string.lbl_songs + override val stringRes = R.string.lbl_artists } data class Albums(val category: Category, override val items: List) : diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 421405123..48488a376 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -47,10 +47,6 @@ sealed interface MediaSessionUID { override fun toString() = "$ID_ITEM:$uid" } - data class ChildItem(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { - override fun toString() = "$ID_ITEM:$parentUid>$childUid" - } - companion object { const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category" const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item" @@ -62,16 +58,7 @@ sealed interface MediaSessionUID { } return when (parts[0]) { ID_CATEGORY -> Tab(TabNode.fromString(parts[1]) ?: return null) - ID_ITEM -> { - val uids = parts[1].split(">", limit = 2) - if (uids.size == 1) { - Music.UID.fromString(uids[0])?.let { SingleItem(it) } - } else { - Music.UID.fromString(uids[0])?.let { parent -> - Music.UID.fromString(uids[1])?.let { child -> ChildItem(parent, child) } - } - } - } + ID_ITEM -> SingleItem(Music.UID.fromString(parts[1]) ?: return null) else -> return null } } @@ -89,6 +76,10 @@ fun header(name: String): Sugar = { putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, name) } +fun child(of: MusicParent): Sugar = { + putString(MusicBrowser.KEY_CHILD_OF, MediaSessionUID.SingleItem(of.uid).toString()) +} + private fun style(style: Int): Sugar = { putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style) } @@ -115,17 +106,8 @@ fun TabNode.toMediaItem(context: Context): MediaItem { return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } -fun Song.toMediaDescription( - context: Context, - parent: MusicParent? = null, - vararg sugar: Sugar -): MediaDescriptionCompat { - val mediaSessionUID = - if (parent == null) { - MediaSessionUID.SingleItem(uid) - } else { - MediaSessionUID.ChildItem(parent.uid, uid) - } +fun Song.toMediaDescription(context: Context, vararg sugar: Sugar): MediaDescriptionCompat { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) val extras = makeExtras(context, *sugar) return MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -138,25 +120,12 @@ fun Song.toMediaDescription( .build() } -fun Song.toMediaItem( - context: Context, - parent: MusicParent? = null, - vararg sugar: Sugar -): MediaItem { - return MediaItem(toMediaDescription(context, parent, *sugar), MediaItem.FLAG_PLAYABLE) +fun Song.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { + return MediaItem(toMediaDescription(context, *sugar), MediaItem.FLAG_PLAYABLE) } -fun Album.toMediaItem( - context: Context, - parent: MusicParent? = null, - vararg sugar: Sugar -): MediaItem { - val mediaSessionUID = - if (parent == null) { - MediaSessionUID.SingleItem(uid) - } else { - MediaSessionUID.ChildItem(parent.uid, uid) - } +fun Album.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) val extras = makeExtras(context, *sugar) val counts = context.getPlural(R.plurals.fmt_song_count, songs.size) val description = diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index bccdef87c..caffe0688 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.support.v4.media.MediaBrowserCompat.MediaItem import javax.inject.Inject +import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.detail.DetailGenerator import org.oxycblt.auxio.detail.DetailSection @@ -117,8 +118,6 @@ private constructor( is MediaSessionUID.Tab -> return uid.node.toMediaItem(context) is MediaSessionUID.SingleItem -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - is MediaSessionUID.ChildItem -> - musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } null -> null } ?: return null @@ -128,7 +127,7 @@ private constructor( is Artist -> music.toMediaItem(context) is Genre -> music.toMediaItem(context) is Playlist -> music.toMediaItem(context) - is Song -> music.toMediaItem(context, null) + is Song -> music.toMediaItem(context) } } @@ -160,10 +159,10 @@ private constructor( private fun SearchEngine.Items.toMediaItems(): MutableList { val music = mutableListOf() if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) }) + music.addAll(songs.map { it.toMediaItem(context, header(R.string.lbl_songs)) }) } if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context, null, header(R.string.lbl_albums)) }) + music.addAll(albums.map { it.toMediaItem(context, header(R.string.lbl_albums)) }) } if (artists != null) { music.addAll(artists.map { it.toMediaItem(context, header(R.string.lbl_artists)) }) @@ -185,9 +184,6 @@ private constructor( is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) } - is MediaSessionUID.ChildItem -> { - getChildMediaItems(mediaSessionUID.childUid) - } null -> { return null } @@ -208,11 +204,11 @@ private constructor( } is TabNode.More -> { val tabs = homeGenerator.tabs() - tabs.takeLast(tabs.size - maxTabs).map { TabNode.Home(it).toMediaItem(context) } + tabs.takeLast(tabs.size - maxTabs + 1).map { TabNode.Home(it).toMediaItem(context) } } is TabNode.Home -> when (node.type) { - MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context, null) } + MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context) } MusicType.ALBUMS -> homeGenerator.albums().map { it.toMediaItem(context) } MusicType.ARTISTS -> homeGenerator.artists().map { it.toMediaItem(context) } MusicType.GENRES -> homeGenerator.genres().map { it.toMediaItem(context) } @@ -225,18 +221,24 @@ private constructor( return detail.sections.flatMap { section -> when (section) { is DetailSection.Songs -> - section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + section.items.map { + it.toMediaItem(context, header(section.stringRes), child(detail.parent)) + } is DetailSection.Albums -> - section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + section.items.map { it.toMediaItem(context, header(section.stringRes)) } is DetailSection.Artists -> section.items.map { it.toMediaItem(context, header(section.stringRes)) } is DetailSection.Discs -> section.discs.flatMap { (disc, songs) -> val discString = disc.resolveNumber(context) - songs.map { it.toMediaItem(context, null, header(discString)) } + songs.map { it.toMediaItem(context, header(discString)) } } else -> error("Unknown section type: $section") } } } + + companion object { + const val KEY_CHILD_OF = BuildConfig.APPLICATION_ID + ".key.CHILD_OF" + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 9d3af9bab..b5724f6b4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -295,7 +295,7 @@ private constructor( queue.mapIndexed { i, song -> val description = song.toMediaDescription( - context, null, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) + context, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) // Store the item index so we can then use the analogous index in the // playback state. MediaSessionCompat.QueueItem(description, i.toLong()) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 38153be4a..2ea4f8db2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -41,11 +41,13 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.music.service.MusicBrowser import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.ShuffleMode +import org.oxycblt.auxio.util.logD class MediaSessionInterface @Inject @@ -80,7 +82,10 @@ constructor( override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return - val command = expandUidIntoCommand(uid) + val parentUid = + extras?.getString(MusicBrowser.KEY_CHILD_OF)?.let { MediaSessionUID.fromString(it) } + val command = expandUidIntoCommand(uid, parentUid) + logD(extras?.getString(MusicBrowser.KEY_CHILD_OF)) playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) } @@ -105,7 +110,6 @@ constructor( val songUid = when (uid) { is MediaSessionUID.SingleItem -> uid.uid - is MediaSessionUID.ChildItem -> uid.childUid else -> return } val song = deviceLibrary.songs.find { it.uid == songUid } ?: return @@ -126,7 +130,6 @@ constructor( val songUid = when (uid) { is MediaSessionUID.SingleItem -> uid.uid - is MediaSessionUID.ChildItem -> uid.childUid else -> return } val firstAt = playbackManager.queue.indexOfFirst { it.uid == songUid } @@ -194,20 +197,14 @@ constructor( context.sendBroadcast(Intent(action)) } - private fun expandUidIntoCommand(uid: MediaSessionUID): PlaybackCommand? { - val music: Music - var parent: MusicParent? = null - when (uid) { - is MediaSessionUID.SingleItem -> { - music = musicRepository.find(uid.uid) ?: return null - } - is MediaSessionUID.ChildItem -> { - music = musicRepository.find(uid.childUid) ?: return null - parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null - } - else -> return null - } - + private fun expandUidIntoCommand( + uid: MediaSessionUID, + parentUid: MediaSessionUID? + ): PlaybackCommand? { + val unwrappedUid = (uid as? MediaSessionUID.SingleItem)?.uid ?: return null + val unwrappedParentUid = (parentUid as? MediaSessionUID.SingleItem)?.uid + val music = musicRepository.find(unwrappedUid) ?: return null + val parent = unwrappedParentUid?.let { musicRepository.find(it) as? MusicParent } return expandMusicIntoCommand(music, parent) } From bed1dc43cd7fd4ba8eeb7f66bf84a3475fff86f2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 83/87] playback: fix gaps on playlist change --- .../service/ExoPlaybackStateHolder.kt | 22 ++++++++++++++----- .../playback/state/PlaybackStateHolder.kt | 8 ++++++- .../playback/state/PlaybackStateManager.kt | 18 ++++++++++----- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 4e95e54d4..2947052c2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -47,6 +47,7 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.msToSecs import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.state.DeferredPlayback @@ -363,13 +364,15 @@ class ExoPlaybackStateHolder( override fun applySavedState( parent: MusicParent?, rawQueue: RawQueue, + positionMs: Long, + repeatMode: RepeatMode, ack: StateAck.NewPlayback? ) { - logD("Applying saved state") - var sendEvent = false + var sendNewPlaybackEvent = false + var shouldSeek = false if (this.parent != parent) { this.parent = parent - sendEvent = true + sendNewPlaybackEvent = true } if (rawQueue != resolveQueue()) { player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() }) @@ -382,9 +385,18 @@ class ExoPlaybackStateHolder( player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) player.prepare() player.pause() - sendEvent = true + sendNewPlaybackEvent = true + shouldSeek = true } - if (sendEvent) { + + repeatMode(repeatMode) + // Positions in milliseconds will drift during tight restores (i.e what the music loader + // does to sanitize the state), compare by seconds instead. + if (positionMs.msToSecs() != player.currentPosition.msToSecs() || shouldSeek) { + player.seekTo(positionMs) + } + + if (sendNewPlaybackEvent) { ack?.let { playbackManager.ack(this, it) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index c2c51f0ce..4d1cdca98 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -145,7 +145,13 @@ interface PlaybackStateHolder { * @param ack The [StateAck] to return to [PlaybackStateManager]. If null, do not return any * ack. */ - fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) + fun applySavedState( + parent: MusicParent?, + rawQueue: RawQueue, + positionMs: Long, + repeatMode: RepeatMode, + ack: StateAck.NewPlayback? + ) /** End whatever ongoing playback session may be going on */ fun endSession() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 494ab2c0e..283a3e6f7 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -416,9 +416,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { this.stateHolder = stateHolder if (isInitialized && currentSong != null) { - stateHolder.applySavedState(stateMirror.parent, stateMirror.rawQueue, null) - stateHolder.seekTo(stateMirror.progression.calculateElapsedPositionMs()) - stateHolder.playing(false) + stateHolder.applySavedState( + stateMirror.parent, + stateMirror.rawQueue, + stateMirror.progression.calculateElapsedPositionMs(), + stateMirror.repeatMode, + null) } pendingDeferredPlayback?.let(stateHolder::handleDeferred) } @@ -795,9 +798,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { index }) - stateHolder.applySavedState(savedState.parent, rawQueue, StateAck.NewPlayback) - stateHolder.seekTo(savedState.positionMs) - stateHolder.repeatMode(savedState.repeatMode) + stateHolder.applySavedState( + savedState.parent, + rawQueue, + savedState.positionMs, + savedState.repeatMode, + StateAck.NewPlayback) isInitialized = true } From 10d7f5d1977cb50d93b15e1d0713da6b2885478b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 84/87] actions: add ninja requirement --- .github/workflows/android.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 61ec47e0c..65221f0a5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -11,6 +11,8 @@ jobs: runs-on: ubuntu-latest steps: + - name: Install xmllint + run: sudo apt-get install -y ninja-build - name: Clone repository uses: actions/checkout@v3 - name: Clone submodules From 29e29d3cab12685182b5a5fe930fc8fc3df3b8e4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:07 -0600 Subject: [PATCH 85/87] actions: fix ninja-build install step name --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 65221f0a5..9b7aae1cd 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - name: Install xmllint + - name: Install ninja-build run: sudo apt-get install -y ninja-build - name: Clone repository uses: actions/checkout@v3 From 344a49532b6f26262aac4d4a1cec8ec99df0f0b6 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:07 -0600 Subject: [PATCH 86/87] music: fix more tab compat --- .../oxycblt/auxio/music/service/MusicBrowser.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index caffe0688..35a43c7b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -194,17 +194,15 @@ private constructor( when (node) { is TabNode.Root -> { val tabs = homeGenerator.tabs() - val base = tabs.take(maxTabs - 1).map { TabNode.Home(it) } - if (base.size < tabs.size) { - base + TabNode.More - } else { - base - } - .map { it.toMediaItem(context) } + if (maxTabs < tabs.size) { + tabs.take(maxTabs - 1).map { TabNode.Home(it).toMediaItem(context) } + + TabNode.More.toMediaItem(context) + } else { + tabs.map { TabNode.Home(it).toMediaItem(context) } + } } is TabNode.More -> { - val tabs = homeGenerator.tabs() - tabs.takeLast(tabs.size - maxTabs + 1).map { TabNode.Home(it).toMediaItem(context) } + homeGenerator.tabs().drop(maxTabs - 1).map { TabNode.Home(it).toMediaItem(context) } } is TabNode.Home -> when (node.type) { From e23643f3aba1a37750a47d08661a872c96c7f3c8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:07 -0600 Subject: [PATCH 87/87] build: bump to 3.6.0 --- CHANGELOG.md | 18 ++++++++++++++++++ README.md | 4 ++-- app/build.gradle | 4 ++-- .../metadata/android/en-US/changelogs/50.txt | 2 ++ 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/50.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 840e6e0a8..699da229b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 3.6.0 + +#### What's New +- Added support for playback from google assistant + +#### What's Improved +- Home and detail UIs in Android Auto now reflect app sort settings +- Album view now shows discs in android auto + +#### What's Fixed +- Fixed playback briefly pausing when adding songs to playlist +- Fixed media lists in Android Auto being truncated in some cases +- Possibly fixed duplicated song items depending on album/all children +- Possibly fixed truncated tab lists in android auto + +#### Dev/Meta +- Moved to raw media session apis rather than media3 session + ## 3.5.3 #### What's New diff --git a/README.md b/README.md index 191ca51ec..68a4139fe 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 a0198d737..2cd8a66fe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.5.3" - versionCode 49 + versionName "3.6.0" + versionCode 50 minSdk 24 targetSdk 34 diff --git a/fastlane/metadata/android/en-US/changelogs/50.txt b/fastlane/metadata/android/en-US/changelogs/50.txt new file mode 100644 index 000000000..bd58c45f8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/50.txt @@ -0,0 +1,2 @@ +Auxio 3.6.0 improves support for android auto and fixes several small regressions. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.3