diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index e32f32ffc..7b277dce3 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1 +1,2 @@
github: [OxygenCobalt]
+custom: ["https://paypal.me/oxycblt"]
diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml
index 7b94b9916..abc516401 100644
--- a/.github/ISSUE_TEMPLATE/bug-crash-report.yml
+++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml
@@ -57,6 +57,13 @@ body:
placeholder: OnePlus 7T (LineageOS)
validations:
required: true
+ - type: textarea
+ id: sample-file
+ attributes:
+ label: Provide a sample file
+ description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors may indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. *Upload a ZIP file containing the files or share a link to a file hosted on the cloud.*
+ validations:
+ required: true
- type: textarea
id: logs
attributes:
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index f106a3aac..61ec47e0c 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -2,9 +2,9 @@ name: Android CI
on:
push:
- branches: [ "dev" ]
+ branches: []
pull_request:
- branches: [ "dev" ]
+ branches: []
jobs:
build:
@@ -14,7 +14,7 @@ jobs:
- name: Clone repository
uses: actions/checkout@v3
- name: Clone submodules
- run: git submodule update --init --recursive
+ run: git submodule update --init --recursive --remote
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da465569a..70f8b8257 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,43 @@
# Changelog
-## dev
+## 3.5.0
+
+#### What's New
+- Android Auto support
+- Full media browser implementation
+
+#### What's Improved
+- Album covers are now loaded on a per-song basis
+- MP4 sort tags are now correctly interpreted
+- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
+- M3U paths are now interpreted both as relative and absolute regardless of the format
+- Added support for M3U paths starting with /storage/
+- Queue no longer scrolls as quickly when dragging items
+
+#### What's Fixed
+- Fixed repeat mode not restoring on startup
+- Fixed rewinding not occuring when skipping back at the beginning of the queue if
+rewind before skipping was turned off
+- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
+
+#### What's Changed
+- For the time being, the media notification will not follow Album Covers or 1:1 Covers settings
+- Playback will close automatically after some time left idle
+
+#### Dev/Meta
+- Use WEBP instead of PNG icons
+
+#### dev -> release changes
+- Re-added ability to open app from clicking on notification
+- Removed tasker plugin
+- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
+- M3U paths are now interpreted both as relative and absolute regardless of the format
+- Added support for M3U paths starting with /storage/
+- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
+- Made album cover keying more efficient at the cost of resillience
+- Fixed android auto queue not respecting shuffle
+
+## 3.4.3
#### What's Improved
- Added back option disable ReplayGain for poorly tagged libraries
diff --git a/app/build.gradle b/app/build.gradle
index 147514045..9d1778b5a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -21,8 +21,8 @@ android {
defaultConfig {
applicationId namespace
- versionName "3.4.3"
- versionCode 44
+ versionName "3.5.0"
+ versionCode 46
minSdk 24
targetSdk 34
@@ -101,7 +101,7 @@ dependencies {
implementation "androidx.viewpager2:viewpager2:1.0.0"
// Lifecycle
- def lifecycle_version = "2.6.2"
+ def lifecycle_version = "2.7.0"
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@@ -126,6 +126,7 @@ 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"
@@ -154,7 +155,7 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2"
testImplementation "io.mockk:mockk:1.13.7"
- testImplementation "org.robolectric:robolectric:4.9"
+ testImplementation "org.robolectric:robolectric:4.11"
testImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f7fa7b198..960d2c8ae 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -37,6 +37,12 @@
android:enableOnBackInvokedCallback="true"
tools:ignore="UnusedAttribute">
+
+
+
-
-
+ android:exported="true"
+ android:roundIcon="@mipmap/ic_launcher">
+
+
+
+
+
@@ -133,5 +134,6 @@
android:name="android.appwidget.provider"
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
new file mode 100644
index 000000000..64121e6d1
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * AuxioService.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
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.os.IBinder
+import androidx.core.app.ServiceCompat
+import androidx.media3.session.MediaLibraryService
+import androidx.media3.session.MediaSession
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+import org.oxycblt.auxio.music.service.IndexerServiceFragment
+import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment
+
+@AndroidEntryPoint
+class AuxioService : MediaLibraryService(), ForegroundListener {
+ @Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
+
+ @Inject lateinit var indexingFragment: IndexerServiceFragment
+
+ @SuppressLint("WrongConstant")
+ override fun onCreate() {
+ super.onCreate()
+ mediaSessionFragment.attach(this, this)
+ indexingFragment.attach(this)
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ handleIntent(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)
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ private fun handleIntent(intent: Intent?) {
+ val nativeStart = intent?.getBooleanExtra(INTENT_KEY_NATIVE_START, false) ?: false
+ if (!nativeStart) {
+ // Some foreign code started us, no guarantees about foreground stability. Figure
+ // out what to do.
+ mediaSessionFragment.handleNonNativeStart()
+ }
+ }
+
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ super.onTaskRemoved(rootIntent)
+ mediaSessionFragment.handleTaskRemoved()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ indexingFragment.release()
+ mediaSessionFragment.release()
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
+ mediaSessionFragment.mediaSession
+
+ override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
+ updateForeground(ForegroundListener.Change.MEDIA_SESSION)
+ }
+
+ override fun updateForeground(change: ForegroundListener.Change) {
+ if (mediaSessionFragment.hasNotification()) {
+ if (change == ForegroundListener.Change.MEDIA_SESSION) {
+ mediaSessionFragment.createNotification {
+ startForeground(it.notificationId, it.notification)
+ }
+ }
+ // Nothing changed, but don't show anything music related since we can always
+ // index during playback.
+ } else {
+ indexingFragment.createNotification {
+ if (it != null) {
+ startForeground(it.code, it.build())
+ } else {
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ }
+ }
+ }
+ }
+
+ companion object {
+ // 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"
+ }
+}
+
+interface ForegroundListener {
+ fun updateForeground(change: Change)
+
+ enum class Change {
+ MEDIA_SESSION,
+ INDEXER
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
index 271de0969..86f3d1984 100644
--- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
+++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
@@ -133,4 +133,7 @@ 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 3fa2ad852..42ad2a134 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
@@ -29,10 +29,8 @@ import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.databinding.ActivityMainBinding
-import org.oxycblt.auxio.music.system.IndexerService
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.DeferredPlayback
-import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD
@@ -71,8 +69,9 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
- startService(Intent(this, IndexerService::class.java))
- startService(Intent(this, PlaybackService::class.java))
+ startService(
+ Intent(this, AuxioService::class.java)
+ .putExtra(AuxioService.INTENT_KEY_NATIVE_START, true))
if (!startIntentAction(intent)) {
// No intent action to do, just restore the previously saved state.
diff --git a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt
index ad81c25a9..59dcb877d 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt
@@ -94,7 +94,7 @@ constructor(
target
.onConfigRequest(
ImageRequest.Builder(context)
- .data(listOf(song))
+ .data(listOf(song.cover))
// Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL))
.target(
diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt
index 792755dc7..4d0057c40 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt
@@ -48,6 +48,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.R
+import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
import org.oxycblt.auxio.music.Album
@@ -101,14 +102,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
- private data class Cover(
- val songs: Collection,
- val desc: String,
- @DrawableRes val errorRes: Int
- )
-
- private var currentCover: Cover? = null
-
init {
// Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable")
@@ -342,8 +335,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param song The [Song] to bind to the view.
*/
fun bind(song: Song) =
- bind(
- listOf(song),
+ bindImpl(
+ listOf(song.cover),
context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24)
@@ -353,8 +346,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param album The [Album] to bind to the view.
*/
fun bind(album: Album) =
- bind(
- album.songs,
+ bindImpl(
+ album.cover.all,
context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24)
@@ -364,8 +357,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param artist The [Artist] to bind to the view.
*/
fun bind(artist: Artist) =
- bind(
- artist.songs,
+ bindImpl(
+ artist.cover.all,
context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24)
@@ -375,8 +368,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param genre The [Genre] to bind to the view.
*/
fun bind(genre: Genre) =
- bind(
- genre.songs,
+ bindImpl(
+ genre.cover.all,
context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24)
@@ -386,8 +379,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param playlist the [Playlist] to bind.
*/
fun bind(playlist: Playlist) =
- bind(
- playlist.songs,
+ bindImpl(
+ playlist.cover?.all ?: emptyList(),
context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
@@ -398,10 +391,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param desc The content description to describe the bound data.
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
*/
- fun bind(songs: Collection, desc: String, @DrawableRes errorRes: Int) {
+ fun bind(songs: List, desc: String, @DrawableRes errorRes: Int) =
+ bindImpl(Cover.order(songs), desc, errorRes)
+
+ private fun bindImpl(covers: List, desc: String, @DrawableRes errorRes: Int) {
val request =
ImageRequest.Builder(context)
- .data(songs)
+ .data(covers)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
.target(image)
@@ -417,7 +413,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
CoilUtils.dispose(image)
imageLoader.enqueue(request.build())
contentDescription = desc
- currentCover = Cover(songs, desc, errorRes)
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
index b7a7183db..f38d00695 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
@@ -24,25 +24,23 @@ import coil.key.Keyer
import coil.request.Options
import coil.size.Size
import javax.inject.Inject
-import org.oxycblt.auxio.music.Song
-class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
- Keyer> {
- override fun key(data: Collection, options: Options) =
- "${coverExtractor.computeCoverOrdering(data).hashCode()}"
+class CoverKeyer @Inject constructor() : Keyer> {
+ override fun key(data: Collection, options: Options) =
+ "${data.map { it.key }.hashCode()}"
}
-class SongCoverFetcher
+class CoverFetcher
private constructor(
- private val songs: Collection,
+ private val covers: Collection,
private val size: Size,
private val coverExtractor: CoverExtractor,
) : Fetcher {
- override suspend fun fetch() = coverExtractor.extract(songs, size)
+ override suspend fun fetch() = coverExtractor.extract(covers, size)
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
- Fetcher.Factory> {
- override fun create(data: Collection, options: Options, imageLoader: ImageLoader) =
- SongCoverFetcher(data, options.size, coverExtractor)
+ Fetcher.Factory> {
+ override fun create(data: Collection, options: Options, imageLoader: ImageLoader) =
+ CoverFetcher(data, options.size, coverExtractor)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt
new file mode 100644
index 000000000..bf3cf97cf
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ * Cover.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.image.extractor
+
+import android.net.Uri
+import org.oxycblt.auxio.list.sort.Sort
+import org.oxycblt.auxio.music.Song
+
+sealed interface Cover {
+ val key: String
+ val mediaStoreCoverUri: Uri
+
+ /**
+ * The song has an embedded cover art we support, so we can operate with it on a per-song basis.
+ */
+ data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
+ Cover {
+ override val mediaStoreCoverUri = songCoverUri
+ override val key = perceptualHash
+ }
+
+ /**
+ * We couldn't find any embedded cover art ourselves, but the android system might have some
+ * through a cover.jpg file or something similar.
+ */
+ data class External(val albumCoverUri: Uri) : Cover {
+ override val mediaStoreCoverUri = albumCoverUri
+ override val key = albumCoverUri.toString()
+ }
+
+ companion object {
+ private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
+
+ fun order(songs: Collection) =
+ FALLBACK_SORT.songs(songs)
+ .map { it.cover }
+ .groupBy { it.key }
+ .entries
+ .sortedByDescending { it.value.size }
+ .map { it.value.first() }
+ }
+}
+
+data class ParentCover(val single: Cover, val all: List) {
+ companion object {
+ fun from(song: Song, songs: Collection) = from(song.cover, songs)
+
+ fun from(src: Cover, songs: Collection) = ParentCover(src, Cover.order(songs))
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt
index 899867eb0..f1be38db3 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt
@@ -27,6 +27,7 @@ import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
+import androidx.media3.common.Metadata
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame
@@ -50,8 +51,6 @@ import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
-import org.oxycblt.auxio.list.sort.Sort
-import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logE
@@ -70,17 +69,16 @@ constructor(
/**
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
*
- * @param songs The [Song]s to load.
+ * @param covers The [Cover]s to load.
* @param size The [Size] of the image to load.
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
* will be returned of a mosaic composed of four album covers ordered by
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
*/
- suspend fun extract(songs: Collection, size: Size): FetchResult? {
- val albums = computeCoverOrdering(songs)
+ suspend fun extract(covers: Collection, size: Size): FetchResult? {
val streams = mutableListOf()
- for (album in albums) {
- openCoverInputStream(album)?.let(streams::add)
+ for (cover in covers) {
+ openCoverInputStream(cover)?.let(streams::add)
// We don't immediately check for mosaic feasibility from album count alone, as that
// does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use.
@@ -108,71 +106,7 @@ constructor(
dataSource = DataSource.DISK)
}
- /**
- * Creates an [Album] list representing the order that album covers would be used in [extract].
- *
- * @param songs A hypothetical list of [Song]s that would be used in [extract].
- * @return A list of [Album]s first ordered by the "representation" within the [Song]s, and then
- * by their names. "Representation" is defined by how many [Song]s were found to be linked to
- * the given [Album] in the given [Song] list.
- */
- fun computeCoverOrdering(songs: Collection): List {
- // TODO: Start short-circuiting in more places
- if (songs.isEmpty()) return listOf()
- if (songs.size == 1) return listOf(songs.first().album)
-
- val sortedMap =
- sortedMapOf(Sort.Mode.ByName.getAlbumComparator(Sort.Direction.ASCENDING))
- for (song in songs) {
- sortedMap[song.album] = (sortedMap[song.album] ?: 0) + 1
- }
- return sortedMap.keys.sortedByDescending { sortedMap[it] }
- }
-
- private suspend fun openCoverInputStream(album: Album) =
- try {
- when (imageSettings.coverMode) {
- CoverMode.OFF -> null
- CoverMode.MEDIA_STORE -> extractMediaStoreCover(album)
- CoverMode.QUALITY -> extractQualityCover(album)
- }
- } catch (e: Exception) {
- logE("Unable to extract album cover due to an error: $e")
- null
- }
-
- private suspend fun extractQualityCover(album: Album) =
- extractAospMetadataCover(album)
- ?: extractExoplayerCover(album) ?: extractMediaStoreCover(album)
-
- private fun extractAospMetadataCover(album: Album): InputStream? =
- MediaMetadataRetriever().run {
- // This call is time-consuming but it also doesn't seem to hold up the main thread,
- // so it's probably fine not to wrap it.rmt
- setDataSource(context, album.coverUri.song)
-
- // Get the embedded picture from MediaMetadataRetriever, which will return a full
- // ByteArray of the cover without any compression artifacts.
- // If its null [i.e there is no embedded cover], than just ignore it and move on
- embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
- }
-
- private suspend fun extractExoplayerCover(album: Album): InputStream? {
- val tracks =
- MetadataRetriever.retrieveMetadata(
- mediaSourceFactory, MediaItem.fromUri(album.coverUri.song))
- .asDeferred()
- .await()
-
- // The metadata extraction process of ExoPlayer results in a dump of all metadata
- // it found, which must be iterated through.
- val metadata = tracks[0].getFormat(0).metadata
-
- if (metadata == null || metadata.length() == 0) {
- // No (parsable) metadata. This is also expected.
- return null
- }
-
+ fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
var stream: ByteArrayInputStream? = null
for (i in 0 until metadata.length()) {
@@ -204,10 +138,62 @@ constructor(
return stream
}
- private suspend fun extractMediaStoreCover(album: Album) =
+ private suspend fun openCoverInputStream(cover: Cover) =
+ try {
+ when (cover) {
+ is Cover.Embedded ->
+ when (imageSettings.coverMode) {
+ CoverMode.OFF -> null
+ CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
+ CoverMode.QUALITY -> extractQualityCover(cover)
+ }
+ is Cover.External -> {
+ extractMediaStoreCover(cover)
+ }
+ }
+ } catch (e: Exception) {
+ logE("Unable to extract album cover due to an error: $e")
+ null
+ }
+
+ private suspend fun extractQualityCover(cover: Cover.Embedded) =
+ extractExoplayerCover(cover)
+ ?: extractAospMetadataCover(cover) ?: extractMediaStoreCover(cover)
+
+ private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
+ MediaMetadataRetriever().run {
+ // This call is time-consuming but it also doesn't seem to hold up the main thread,
+ // so it's probably fine not to wrap it.rmt
+ setDataSource(context, cover.songUri)
+
+ // Get the embedded picture from MediaMetadataRetriever, which will return a full
+ // ByteArray of the cover without any compression artifacts.
+ // If its null [i.e there is no embedded cover], than just ignore it and move on
+ embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
+ }
+
+ private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? {
+ val tracks =
+ MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
+ .asDeferred()
+ .await()
+
+ // The metadata extraction process of ExoPlayer results in a dump of all metadata
+ // it found, which must be iterated through.
+ val metadata = tracks[0].getFormat(0).metadata
+
+ if (metadata == null || metadata.length() == 0) {
+ // No (parsable) metadata. This is also expected.
+ return null
+ }
+
+ return findCoverDataInMetadata(metadata)
+ }
+
+ private suspend fun extractMediaStoreCover(cover: Cover) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) {
- context.contentResolver.openInputStream(album.coverUri.mediaStore)
+ context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
new file mode 100644
index 000000000..1e7809606
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * DHash.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.image.extractor
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorMatrix
+import android.graphics.ColorMatrixColorFilter
+import android.graphics.Paint
+import java.math.BigInteger
+
+@Suppress("UNUSED")
+fun Bitmap.dHash(hashSize: Int = 16): String {
+ // Step 1: Resize the bitmap to a fixed size
+ val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
+
+ // Step 2: Convert the bitmap to grayscale
+ val grayBitmap =
+ Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(grayBitmap)
+ val paint = Paint()
+ val colorMatrix = ColorMatrix()
+ colorMatrix.setSaturation(0f)
+ val filter = ColorMatrixColorFilter(colorMatrix)
+ paint.colorFilter = filter
+ canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
+
+ // Step 3: Compute the difference between adjacent pixels
+ var hash = BigInteger.valueOf(0)
+ val one = BigInteger.valueOf(1)
+ for (y in 0 until hashSize) {
+ for (x in 0 until hashSize) {
+ val pixel1 = grayBitmap.getPixel(x, y)
+ val pixel2 = grayBitmap.getPixel(x + 1, y)
+ val diff = Color.red(pixel1) - Color.red(pixel2)
+ if (diff > 0) {
+ hash = hash.or(one.shl(y * hashSize + x))
+ }
+ }
+ }
+
+ return hash.toString(16)
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt
index 5f4145479..44c4d3166 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt
@@ -35,14 +35,14 @@ class ExtractorModule {
@Provides
fun imageLoader(
@ApplicationContext context: Context,
- songKeyer: SongKeyer,
- songFactory: SongCoverFetcher.Factory
+ keyer: CoverKeyer,
+ factory: CoverFetcher.Factory
) =
ImageLoader.Builder(context)
.components {
// Add fetchers for Music components to make them usable with ImageRequest
- add(songKeyer)
- add(songFactory)
+ add(keyer)
+ add(factory)
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt
index 28112ca61..6ccf789b4 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt
@@ -25,6 +25,10 @@ import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.sign
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
import org.oxycblt.auxio.util.getDimen
@@ -53,6 +57,27 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
0
}
+ override fun interpolateOutOfBoundsScroll(
+ recyclerView: RecyclerView,
+ viewSize: Int,
+ viewSizeOutOfBounds: Int,
+ totalSize: Int,
+ msSinceStartScroll: Long
+ ): Int {
+ // Clamp the scroll speed to prevent the lists from freaking out
+ // Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe
+ val standardSpeed =
+ super.interpolateOutOfBoundsScroll(
+ recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll)
+
+ val clampedAbsVelocity =
+ max(
+ MINIMUM_INITIAL_DRAG_VELOCITY,
+ min(abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY))
+
+ return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt()
+ }
+
final override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
@@ -150,4 +175,9 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
/** The drawable of the [body] background that can be elevated. */
val background: Drawable
}
+
+ companion object {
+ const val MINIMUM_INITIAL_DRAG_VELOCITY = 10
+ const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25
+ }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
index 1bf23aaf6..359afd9c3 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
@@ -27,7 +27,8 @@ import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
-import org.oxycblt.auxio.image.extractor.CoverUri
+import org.oxycblt.auxio.image.extractor.Cover
+import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
@@ -246,6 +247,8 @@ interface Song : Music {
* audio file in a way that is scoped-storage-safe.
*/
val uri: Uri
+ /** Useful information to quickly obtain the album cover. */
+ val cover: Cover
/**
* The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file.
@@ -293,11 +296,8 @@ interface Album : MusicParent {
* [ReleaseType.Album].
*/
val releaseType: ReleaseType
- /**
- * The URI to a MediaStore-provided album cover. These images will be fast to load, but at the
- * cost of image quality.
- */
- val coverUri: CoverUri
+ /** Cover information from the template song used for the album. */
+ val cover: ParentCover
/** The duration of all songs in the album, in milliseconds. */
val durationMs: Long
/** The earliest date a song in this album was added, as a unix epoch timestamp. */
@@ -326,6 +326,8 @@ interface Artist : MusicParent {
* songs.
*/
val durationMs: Long?
+ /** Useful information to quickly obtain a (single) cover for a Genre. */
+ val cover: ParentCover
/** The [Genre]s of this artist. */
val genres: List
}
@@ -340,6 +342,8 @@ interface Genre : MusicParent {
val artists: Collection
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
+ /** Useful information to quickly obtain a (single) cover for a Genre. */
+ val cover: ParentCover
}
/**
@@ -352,6 +356,8 @@ interface Playlist : MusicParent {
override val songs: List
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
+ /** Useful information to quickly obtain a (single) cover for a Genre. */
+ val cover: ParentCover?
}
/**
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 6ec2c5c09..ff449b029 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
@@ -206,7 +206,7 @@ interface MusicRepository {
/** A persistent worker that can load music in the background. */
interface IndexingWorker {
/** A [Context] required to read device storage */
- val context: Context
+ val workerContext: Context
/** The [CoroutineScope] to perform coroutine music loading work on. */
val scope: CoroutineScope
@@ -343,7 +343,7 @@ constructor(
}
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
- worker.scope.launch { indexWrapper(worker.context, this, withCache) }
+ worker.scope.launch { indexWrapper(worker.workerContext, this, withCache) }
private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
try {
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
index 4c45dfc84..99e3fee4d 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
@@ -164,7 +164,10 @@ constructor(
}
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
- val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath)
+ val songs =
+ importedPlaylist.paths.mapNotNull {
+ it.firstNotNullOfOrNull(deviceLibrary::findSongByPath)
+ }
if (songs.isEmpty()) {
logE("No songs found")
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 63e4ccf98..2a7113066 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 = 42, exportSchema = false)
+@Database(entities = [CachedSong::class], version = 46, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao
}
@@ -80,6 +80,8 @@ data class CachedSong(
var subtitle: String? = null,
/** @see RawSong.date */
var date: Date? = null,
+ /** @see RawSong.coverPerceptualHash */
+ var coverPerceptualHash: String? = null,
/** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawSong.albumName */
@@ -119,6 +121,8 @@ data class CachedSong(
rawSong.subtitle = subtitle
rawSong.date = date
+ rawSong.coverPerceptualHash = coverPerceptualHash
+
rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName
rawSong.albumSortName = albumSortName
@@ -167,6 +171,7 @@ data class CachedSong(
disc = rawSong.disc,
subtitle = rawSong.subtitle,
date = rawSong.date,
+ coverPerceptualHash = rawSong.coverPerceptualHash,
albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName,
diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt
index 7b16070cc..674c0cb75 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt
@@ -19,7 +19,8 @@
package org.oxycblt.auxio.music.device
import org.oxycblt.auxio.R
-import org.oxycblt.auxio.image.extractor.CoverUri
+import org.oxycblt.auxio.image.extractor.Cover
+import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@@ -28,8 +29,9 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.MimeType
+import org.oxycblt.auxio.music.fs.toAlbumCoverUri
import org.oxycblt.auxio.music.fs.toAudioUri
-import org.oxycblt.auxio.music.fs.toCoverUri
+import org.oxycblt.auxio.music.fs.toSongCoverUri
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
@@ -112,6 +114,20 @@ class SongImpl(
override val genres: List
get() = _genres
+ override val cover =
+ rawSong.coverPerceptualHash?.let {
+ // We were able to confirm that the song had a parsable cover and can be used on
+ // a per-song basis. Otherwise, just fall back to a per-album cover instead, as
+ // it implies either a cover.jpg pattern is used (likely) or ExoPlayer does not
+ // support the cover metadata of a given spec (unlikely).
+ Cover.Embedded(
+ requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }
+ .toSongCoverUri(),
+ uri,
+ it)
+ }
+ ?: Cover.External(requireNotNull(rawSong.albumMediaStoreId).toAlbumCoverUri())
+
/**
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
* [Album].
@@ -291,9 +307,9 @@ class AlbumImpl(
override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
override val dates: Date.Range?
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
- override val coverUri = CoverUri(rawAlbum.mediaStoreId.toCoverUri(), grouping.raw.src.uri)
override val durationMs: Long
override val dateAdded: Long
+ override val cover: ParentCover
private val _artists = mutableListOf()
override val artists: List
@@ -337,6 +353,8 @@ class AlbumImpl(
durationMs = totalDuration
dateAdded = earliestDateAdded
+ cover = ParentCover.from(grouping.raw.src.cover, songs)
+
hashCode = 31 * hashCode + rawAlbum.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
@@ -419,6 +437,7 @@ class ArtistImpl(
override val explicitAlbums: Set
override val implicitAlbums: Set
override val durationMs: Long?
+ override val cover: ParentCover
override lateinit var genres: List
@@ -451,6 +470,14 @@ class ArtistImpl(
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
+ val singleCover =
+ when (val src = grouping.raw.src) {
+ is SongImpl -> src.cover
+ is AlbumImpl -> src.cover.single
+ else -> error("Unexpected input source $src in $name ${src::class.simpleName}")
+ }
+ cover = ParentCover.from(singleCover, songs)
+
hashCode = 31 * hashCode + rawArtist.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
@@ -528,6 +555,7 @@ class GenreImpl(
override val songs: Set
override val artists: Set
override val durationMs: Long
+ override val cover: ParentCover
private var hashCode = uid.hashCode()
@@ -545,6 +573,8 @@ class GenreImpl(
artists = distinctArtists
durationMs = totalDuration
+ cover = ParentCover.from(grouping.raw.src.cover, songs)
+
hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt
index 2f3b6ec73..5b1b6df03 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt
@@ -67,6 +67,8 @@ data class RawSong(
var subtitle: String? = null,
/** @see Song.date */
var date: Date? = null,
+ /** @see Song.cover */
+ var coverPerceptualHash: String? = null,
/** @see RawAlbum.mediaStoreId */
var albumMediaStoreId: Long? = null,
/** @see RawAlbum.musicBrainzId */
diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt
index 1cf0b0810..a1171efee 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt
@@ -76,7 +76,9 @@ data class ExportConfig(val absolute: Boolean, val windowsPaths: Boolean)
* @see ExternalPlaylistManager
* @see M3U
*/
-data class ImportedPlaylist(val name: String?, val paths: List)
+data class ImportedPlaylist(val name: String?, val paths: List)
+
+typealias PossiblePaths = List
class ExternalPlaylistManagerImpl
@Inject
diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt
index 11ce3dea1..211f6cea6 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt
@@ -29,9 +29,12 @@ import javax.inject.Inject
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.fs.Components
import org.oxycblt.auxio.music.fs.Path
+import org.oxycblt.auxio.music.fs.Volume
+import org.oxycblt.auxio.music.fs.VolumeManager
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.logE
+import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* Minimal M3U file format implementation.
@@ -72,10 +75,16 @@ interface M3U {
}
}
-class M3UImpl @Inject constructor(@ApplicationContext private val context: Context) : M3U {
+class M3UImpl
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+ private val volumeManager: VolumeManager
+) : M3U {
override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
+ val volumes = volumeManager.getVolumes()
val reader = BufferedReader(InputStreamReader(stream))
- val paths = mutableListOf()
+ val paths = mutableListOf()
var name: String? = null
consumeFile@ while (true) {
@@ -112,39 +121,13 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte
}
// There is basically no formal specification of file paths in M3U, and it differs
- // based on the US that generated it. These are the paths though that I assume most
- // programs will generate.
- val components =
- when {
- path.startsWith('/') -> {
- // Unix absolute path. Note that we still assume this absolute path is in
- // the same volume as the M3U file. There's no sane way to map the volume
- // to the phone's volumes, so this is the only thing we can do.
- Components.parseUnix(path)
- }
- path.startsWith("./") -> {
- // Unix relative path, resolve it
- Components.parseUnix(path).absoluteTo(workingDirectory.components)
- }
- path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> {
- // Windows absolute path, we should get rid of the volume prefix, but
- // otherwise
- // the rest should be fine. Again, we have to disregard what the volume
- // actually
- // is since there's no sane way to map it to the phone's volumes.
- Components.parseWindows(path.substring(2))
- }
- path.startsWith(".\\") -> {
- // Windows relative path, we need to remove the .\\ prefix
- Components.parseWindows(path).absoluteTo(workingDirectory.components)
- }
- else -> {
- // No clue, parse by all separators and assume it's relative.
- Components.parseAny(path).absoluteTo(workingDirectory.components)
- }
- }
+ // based on the programs that generated it. I more or less have to consider any possible
+ // interpretation as valid.
+ val interpretations = interpretPath(path)
+ val possibilities =
+ interpretations.flatMap { expandInterpretation(it, workingDirectory, volumes) }
- paths.add(Path(workingDirectory.volume, components))
+ paths.add(possibilities)
}
return if (paths.isNotEmpty()) {
@@ -155,6 +138,44 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte
}
}
+ private data class InterpretedPath(val components: Components, val likelyAbsolute: Boolean)
+
+ private fun interpretPath(path: String): List =
+ when {
+ path.startsWith('/') -> listOf(InterpretedPath(Components.parseUnix(path), true))
+ path.startsWith("./") -> listOf(InterpretedPath(Components.parseUnix(path), false))
+ path.matches(WINDOWS_VOLUME_PREFIX_REGEX) ->
+ listOf(InterpretedPath(Components.parseWindows(path.substring(2)), true))
+ path.startsWith("\\") -> listOf(InterpretedPath(Components.parseWindows(path), true))
+ path.startsWith(".\\") -> listOf(InterpretedPath(Components.parseWindows(path), false))
+ else ->
+ listOf(
+ InterpretedPath(Components.parseUnix(path), false),
+ InterpretedPath(Components.parseWindows(path), true))
+ }
+
+ private fun expandInterpretation(
+ path: InterpretedPath,
+ workingDirectory: Path,
+ volumes: List
+ ): List {
+ val absoluteInterpretation = Path(workingDirectory.volume, path.components)
+ val relativeInterpretation =
+ Path(workingDirectory.volume, path.components.absoluteTo(workingDirectory.components))
+ val volumeExactMatch = volumes.find { it.components?.contains(path.components) == true }
+ val volumeInterpretation =
+ volumeExactMatch?.let {
+ val components =
+ unlikelyToBeNull(volumeExactMatch.components).containing(path.components)
+ Path(volumeExactMatch, components)
+ }
+ return if (path.likelyAbsolute) {
+ listOfNotNull(volumeInterpretation, absoluteInterpretation, relativeInterpretation)
+ } else {
+ listOfNotNull(relativeInterpretation, volumeInterpretation, absoluteInterpretation)
+ }
+ }
+
override fun write(
playlist: Playlist,
outputStream: OutputStream,
diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt
index 2639ec207..9f3cbff3b 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt
@@ -158,6 +158,8 @@ value class Components private constructor(val components: List) {
return components == other.components.take(components.size)
}
+ fun containing(other: Components) = Components(other.components.drop(components.size))
+
companion object {
/**
* Parses a path string into a [Components] instance by the unix path separator (/).
diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt
index 87eff7081..6cdc7df67 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt
@@ -102,7 +102,14 @@ fun Long.toAudioUri() =
* @return An external storage image [Uri]. May not exist.
* @see ContentUris.withAppendedId
*/
-fun Long.toCoverUri() = ContentUris.withAppendedId(externalCoversUri, this)
+fun Long.toSongCoverUri(): Uri =
+ MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().run {
+ appendPath(this@toSongCoverUri.toString())
+ appendPath("albumart")
+ build()
+ }
+
+fun Long.toAlbumCoverUri(): Uri = ContentUris.withAppendedId(externalCoversUri, this)
// --- STORAGEMANAGER UTILITIES ---
// Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles
diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt
index 4eb8969f4..4098d0c37 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt
@@ -37,9 +37,9 @@ import org.oxycblt.auxio.util.positiveOrNull
* @author Alexander Capehart (OxygenCobalt)
*/
class Date private constructor(private val tokens: List) : Comparable {
- private val year = tokens[0]
- private val month = tokens.getOrNull(1)
- private val day = tokens.getOrNull(2)
+ val year = tokens[0]
+ val month = tokens.getOrNull(1)
+ val day = tokens.getOrNull(2)
private val hour = tokens.getOrNull(3)
private val minute = tokens.getOrNull(4)
private val second = tokens.getOrNull(5)
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 78669caee..d30e5324e 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
@@ -25,6 +25,8 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray
import java.util.concurrent.Future
import javax.inject.Inject
+import kotlin.math.min
+import org.oxycblt.auxio.image.extractor.CoverExtractor
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.info.Date
@@ -60,7 +62,10 @@ interface TagWorker {
class TagWorkerFactoryImpl
@Inject
-constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory {
+constructor(
+ private val mediaSourceFactory: MediaSource.Factory,
+ private val coverExtractor: CoverExtractor
+) : TagWorker.Factory {
override fun create(rawSong: RawSong): TagWorker =
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
@@ -70,12 +75,14 @@ constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Fac
MetadataRetriever.retrieveMetadata(
mediaSourceFactory,
MediaItem.fromUri(
- requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())))
+ requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())),
+ coverExtractor)
}
private class TagWorkerImpl(
private val rawSong: RawSong,
- private val future: Future
+ private val future: Future,
+ private val coverExtractor: CoverExtractor
) : TagWorker {
override fun poll(): RawSong? {
if (!future.isDone) {
@@ -98,6 +105,25 @@ private class TagWorkerImpl(
populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis)
+ coverExtractor.findCoverDataInMetadata(metadata)?.use {
+ val available = it.available()
+ val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong())
+ it.skip(skip)
+ val bytes = ByteArray(COVER_KEY_SAMPLE)
+ it.read(bytes)
+
+ @OptIn(ExperimentalStdlibApi::class) val byteString = bytes.toHexString()
+
+ rawSong.coverPerceptualHash = byteString
+ }
+
+ // OPTIONAL: Nicer cover art keying using an actual perceptual hash
+ // Really bad idea if you have big cover arts. Okay idea if you have different
+ // formats for the same cover art.
+ // val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) }
+ // rawSong.coverPerceptualHash = bitmap?.dHash()
+ // bitmap?.recycle()
+
// OPUS base gain interpretation code: This is likely not needed, as the media player
// should be using the base gain already. Uncomment if that's not the case.
// if (format.sampleMimeType == MimeTypes.AUDIO_OPUS
@@ -127,7 +153,9 @@ private class TagWorkerImpl(
private fun populateWithId3v2(textFrames: Map>) {
// Song
- textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
+ (textFrames["TXXX:musicbrainz release track id"]
+ ?: textFrames["TXXX:musicbrainz_releasetrackid"])
+ ?.let { rawSong.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { rawSong.name = it.first() }
textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
@@ -157,7 +185,9 @@ private class TagWorkerImpl(
?.let { rawSong.date = it }
// Album
- textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() }
+ (textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let {
+ rawSong.albumMusicBrainzId = it.first()
+ }
textFrames["TALB"]?.let { rawSong.albumName = it.first() }
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"]
@@ -167,7 +197,9 @@ private class TagWorkerImpl(
?.let { rawSong.releaseTypes = it }
// Artist
- textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it }
+ (textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
+ rawSong.artistMusicBrainzIds = it
+ }
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
(textFrames["TXXX:artistssort"]
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
@@ -175,9 +207,9 @@ private class TagWorkerImpl(
?.let { rawSong.artistSortNames = it }
// Album artist
- textFrames["TXXX:musicbrainz album artist id"]?.let {
- rawSong.albumArtistMusicBrainzIds = it
- }
+ (textFrames["TXXX:musicbrainz album artist id"]
+ ?: textFrames["TXXX:musicbrainz_albumartistid"])
+ ?.let { rawSong.albumArtistMusicBrainzIds = it }
(textFrames["TXXX:albumartists"]
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
?: textFrames["TPE2"])
@@ -248,7 +280,9 @@ private class TagWorkerImpl(
private fun populateWithVorbis(comments: Map>) {
// Song
- comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
+ (comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let {
+ rawSong.musicBrainzId = it.first()
+ }
comments["title"]?.let { rawSong.name = it.first() }
comments["titlesort"]?.let { rawSong.sortName = it.first() }
@@ -277,20 +311,28 @@ private class TagWorkerImpl(
?.let { rawSong.date = it }
// Album
- comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() }
+ (comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let {
+ rawSong.albumMusicBrainzId = it.first()
+ }
comments["album"]?.let { rawSong.albumName = it.first() }
comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
- comments["releasetype"]?.let { rawSong.releaseTypes = it }
+ (comments["releasetype"] ?: comments["musicbrainz album type"])?.let {
+ rawSong.releaseTypes = it
+ }
// Artist
- comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it }
+ (comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let {
+ rawSong.artistMusicBrainzIds = it
+ }
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artistssort"]
?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"])
?.let { rawSong.artistSortNames = it }
// Album artist
- comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it }
+ (comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let {
+ rawSong.albumArtistMusicBrainzIds = it
+ }
(comments["albumartists"]
?: comments["album_artists"] ?: comments["album artists"]
?: comments["albumartist"])
@@ -347,6 +389,8 @@ private class TagWorkerImpl(
first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull()
private companion object {
+ val COVER_KEY_SAMPLE = 32
+
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
val COMPILATION_RELEASE_TYPES = listOf("compilation")
diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt
similarity index 68%
rename from app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt
rename to app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt
index e94e4fe16..d857ab32b 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt
@@ -16,27 +16,68 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.music.system
+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.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.IndexingProgress
-import org.oxycblt.auxio.service.ForegroundServiceNotification
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent
/**
- * A dynamic [ForegroundServiceNotification] that shows the current music loading state.
+ * 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.
*
* @param context [Context] required to create the notification.
* @author Alexander Capehart (OxygenCobalt)
*/
class IndexingNotification(private val context: Context) :
- ForegroundServiceNotification(context, indexerChannel) {
+ IndexerNotification(context, indexerChannel) {
private var lastUpdateTime = -1L
init {
@@ -92,13 +133,12 @@ class IndexingNotification(private val context: Context) :
}
/**
- * A static [ForegroundServiceNotification] that signals to the user that the app is currently
- * monitoring the music library for changes.
+ * A static [IndexerNotification] that signals to the user that the app is currently monitoring the
+ * music library for changes.
*
* @author Alexander Capehart (OxygenCobalt)
*/
-class ObservingNotification(context: Context) :
- ForegroundServiceNotification(context, indexerChannel) {
+class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) {
init {
setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE)
@@ -116,5 +156,5 @@ class ObservingNotification(context: Context) :
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val indexerChannel =
- ForegroundServiceNotification.ChannelInfo(
+ IndexerNotification.ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)
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
new file mode 100644
index 000000000..6362def6b
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt
@@ -0,0 +1,184 @@
+/*
+ * 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.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 IndexerServiceFragment
+@Inject
+constructor(
+ @ApplicationContext override val workerContext: Context,
+ private val playbackManager: PlaybackStateManager,
+ 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)
+ 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)
+ contentObserver.attach()
+ }
+
+ fun release() {
+ contentObserver.release()
+ musicSettings.registerListener(this)
+ musicRepository.addIndexingListener(this)
+ musicRepository.addUpdateListener(this)
+ musicRepository.removeIndexingListener(this)
+ foregroundListener = null
+ }
+
+ fun createNotification(post: (IndexerNotification?) -> 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)
+ }
+ }
+
+ 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)
+ }
+
+ 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
+ }
+}
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
new file mode 100644
index 000000000..93841a63f
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt
@@ -0,0 +1,374 @@
+/*
+ * 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
new file mode 100644
index 000000000..9a5bb53c2
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt
@@ -0,0 +1,310 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * MediaItemTranslation.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.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Bundle
+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
+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.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
+
+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()
+}
+
+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
+ }
+}
+
+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 Single(val uid: Music.UID) : MediaSessionUID {
+ override fun toString() = "$ID_ITEM:$uid"
+ }
+
+ data class Joined(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"
+
+ fun fromString(str: String): MediaSessionUID? {
+ val parts = str.split(":", limit = 2)
+ if (parts.size != 2) {
+ return null
+ }
+ return when (parts[0]) {
+ ID_CATEGORY ->
+ when (parts[1]) {
+ Category.ROOT.id -> Category.ROOT
+ 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
+ }
+ ID_ITEM -> {
+ val uids = parts[1].split(">", limit = 2)
+ if (uids.size == 1) {
+ Music.UID.fromString(uids[0])?.let { Single(it) }
+ } else {
+ Music.UID.fromString(uids[0])?.let { parent ->
+ Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) }
+ }
+ }
+ }
+ else -> return null
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt b/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt
new file mode 100644
index 000000000..b44c9785c
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * SystemContentObserver.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.database.ContentObserver
+import android.os.Handler
+import android.os.Looper
+import android.provider.MediaStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import org.oxycblt.auxio.music.MusicRepository
+import org.oxycblt.auxio.music.MusicSettings
+import org.oxycblt.auxio.music.fs.contentResolverSafe
+import org.oxycblt.auxio.util.logD
+
+/**
+ * A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior known
+ * to the user as automatic rescanning. The active (and not passive) nature of observing the
+ * database is what requires [IndexerService] to stay foreground when this is enabled.
+ */
+class SystemContentObserver
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+ private val musicRepository: MusicRepository,
+ private val musicSettings: MusicSettings
+) : ContentObserver(Handler(Looper.getMainLooper())), Runnable {
+ private val handler = Handler(Looper.getMainLooper())
+
+ fun attach() {
+ context.contentResolverSafe.registerContentObserver(
+ MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
+ }
+
+ /**
+ * Release this instance, preventing it from further observing the database and cancelling any
+ * pending update events.
+ */
+ fun release() {
+ handler.removeCallbacks(this)
+ context.contentResolverSafe.unregisterContentObserver(this)
+ }
+
+ override fun onChange(selfChange: Boolean) {
+ // Batch rapid-fire updates to the library into a single call to run after 500ms
+ handler.removeCallbacks(this)
+ handler.postDelayed(this, REINDEX_DELAY_MS)
+ }
+
+ override fun run() {
+ // Check here if we should even start a reindex. This is much less bug-prone than
+ // registering and de-registering this component as this setting changes.
+ if (musicSettings.shouldBeObserving) {
+ logD("MediaStore changed, starting re-index")
+ musicRepository.requestIndex(true)
+ }
+ }
+
+ private companion object {
+ const val REINDEX_DELAY_MS = 500L
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
deleted file mode 100644
index 83f8d5f80..000000000
--- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
+++ /dev/null
@@ -1,286 +0,0 @@
-/*
- * Copyright (c) 2022 Auxio Project
- * IndexerService.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.system
-
-import android.app.Service
-import android.content.Intent
-import android.database.ContentObserver
-import android.os.Handler
-import android.os.IBinder
-import android.os.Looper
-import android.os.PowerManager
-import android.provider.MediaStore
-import coil.ImageLoader
-import dagger.hilt.android.AndroidEntryPoint
-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.music.IndexingProgress
-import org.oxycblt.auxio.music.IndexingState
-import org.oxycblt.auxio.music.MusicRepository
-import org.oxycblt.auxio.music.MusicSettings
-import org.oxycblt.auxio.music.fs.contentResolverSafe
-import org.oxycblt.auxio.playback.state.PlaybackStateManager
-import org.oxycblt.auxio.service.ForegroundManager
-import org.oxycblt.auxio.util.getSystemServiceCompat
-import org.oxycblt.auxio.util.logD
-
-/**
- * A [Service] that manages the background music loading process.
- *
- * Loading music is a time-consuming process that would likely be killed by the system before it
- * could complete if ran anywhere else. So, this [Service] manages the music loading process as an
- * instance of [MusicRepository.IndexingWorker].
- *
- * This [Service] also handles automatic rescanning, as that is a similarly long-running background
- * operation that would be unsuitable elsewhere in the app.
- *
- * @author Alexander Capehart (OxygenCobalt)
- *
- * TODO: Unify with PlaybackService as part of the service independence project
- */
-@AndroidEntryPoint
-class IndexerService :
- Service(),
- MusicRepository.IndexingWorker,
- MusicRepository.IndexingListener,
- MusicRepository.UpdateListener,
- MusicSettings.Listener {
- @Inject lateinit var imageLoader: ImageLoader
- @Inject lateinit var musicRepository: MusicRepository
- @Inject lateinit var musicSettings: MusicSettings
- @Inject lateinit var playbackManager: PlaybackStateManager
-
- private val serviceJob = Job()
- private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
- private var currentIndexJob: Job? = null
- private lateinit var foregroundManager: ForegroundManager
- private lateinit var indexingNotification: IndexingNotification
- private lateinit var observingNotification: ObservingNotification
- private lateinit var wakeLock: PowerManager.WakeLock
- private lateinit var indexerContentObserver: SystemContentObserver
-
- override fun onCreate() {
- super.onCreate()
- // Initialize the core service components first.
- foregroundManager = ForegroundManager(this)
- indexingNotification = IndexingNotification(this)
- observingNotification = ObservingNotification(this)
- wakeLock =
- getSystemServiceCompat(PowerManager::class)
- .newWakeLock(
- PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService")
- // Initialize any listener-dependent components last as we wouldn't want a listener race
- // condition to cause us to load music before we were fully initialize.
- indexerContentObserver = SystemContentObserver()
- musicSettings.registerListener(this)
- musicRepository.addUpdateListener(this)
- musicRepository.addIndexingListener(this)
- musicRepository.registerWorker(this)
-
- logD("Service created.")
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = START_NOT_STICKY
-
- override fun onBind(intent: Intent?): IBinder? = null
-
- override fun onDestroy() {
- super.onDestroy()
- // De-initialize core service components first.
- foregroundManager.release()
- wakeLock.releaseSafe()
- // Then cancel the listener-dependent components to ensure that stray reloading
- // events will not occur.
- indexerContentObserver.release()
- musicSettings.unregisterListener(this)
- musicRepository.removeUpdateListener(this)
- musicRepository.removeIndexingListener(this)
- musicRepository.unregisterWorker(this)
- // Then cancel any remaining music loading jobs.
- serviceJob.cancel()
- }
-
- // --- CONTROLLER CALLBACKS ---
-
- 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@IndexerService, withCache)
- }
-
- override val context = this
-
- override val scope = indexScope
-
- 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 onIndexingStateChanged() {
- val state = musicRepository.indexingState
- if (state is IndexingState.Indexing) {
- updateActiveSession(state.progress)
- } else {
- updateIdleSession()
- }
- }
-
- // --- INTERNAL ---
-
- private fun updateActiveSession(progress: IndexingProgress) {
- // When loading, we want to enter the foreground state so that android does
- // not shut off the loading process. Note that while we will always post the
- // notification when initially starting, we will not update the notification
- // unless it indicates that it has changed.
- val changed = indexingNotification.updateIndexingState(progress)
- if (!foregroundManager.tryStartForeground(indexingNotification) && changed) {
- logD("Notification changed, re-posting notification")
- indexingNotification.post()
- }
- // Make sure we can keep the CPU on while loading music
- wakeLock.acquireSafe()
- }
-
- private fun updateIdleSession() {
- if (musicSettings.shouldBeObserving) {
- // 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.
- // TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
- // this anymore, or at least I only have to use it when the app task is not removed.
- logD("Need to observe, staying in foreground")
- if (!foregroundManager.tryStartForeground(observingNotification)) {
- logD("Notification changed, re-posting notification")
- observingNotification.post()
- }
- } else {
- // Not observing and done loading, exit foreground.
- logD("Exiting foreground")
- foregroundManager.tryStopForeground()
- }
- // Release our wake lock (if we were using it)
- wakeLock.releaseSafe()
- }
-
- /** 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()
- }
- }
-
- // --- SETTING CALLBACKS ---
-
- override fun onIndexingSettingChanged() {
- // Music loading configuration changed, need to reload music.
- requestIndex(true)
- }
-
- override fun 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")
- updateIdleSession()
- }
- }
-
- /**
- * A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior
- * known to the user as automatic rescanning. The active (and not passive) nature of observing
- * the database is what requires [IndexerService] to stay foreground when this is enabled.
- */
- private inner class SystemContentObserver :
- ContentObserver(Handler(Looper.getMainLooper())), Runnable {
- private val handler = Handler(Looper.getMainLooper())
-
- init {
- contentResolverSafe.registerContentObserver(
- MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
- }
-
- /**
- * Release this instance, preventing it from further observing the database and cancelling
- * any pending update events.
- */
- fun release() {
- handler.removeCallbacks(this)
- contentResolverSafe.unregisterContentObserver(this)
- }
-
- override fun onChange(selfChange: Boolean) {
- // Batch rapid-fire updates to the library into a single call to run after 500ms
- handler.removeCallbacks(this)
- handler.postDelayed(this, REINDEX_DELAY_MS)
- }
-
- override fun run() {
- // Check here if we should even start a reindex. This is much less bug-prone than
- // registering and de-registering this component as this setting changes.
- if (musicSettings.shouldBeObserving) {
- logD("MediaStore changed, starting re-index")
- requestIndex(true)
- }
- }
- }
-
- private companion object {
- const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
- const val REINDEX_DELAY_MS = 500L
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt
index fe4418894..2e12f1bff 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt
@@ -18,6 +18,7 @@
package org.oxycblt.auxio.music.user
+import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
@@ -46,6 +47,8 @@ private constructor(
override fun toString() = "Playlist(uid=$uid, name=$name)"
+ override val cover = songs.takeIf { it.isNotEmpty() }?.let { ParentCover.from(it.first(), it) }
+
/**
* Clone the data in this instance to a new [PlaylistImpl] with the given [name].
*
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
index 483a36d99..3cccaa0f4 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
@@ -32,15 +32,16 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
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.playback.persist.PersistenceRepository
import org.oxycblt.auxio.playback.state.DeferredPlayback
+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.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD
@@ -59,8 +60,8 @@ constructor(
private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings,
private val persistenceRepository: PersistenceRepository,
+ private val commandFactory: PlaybackCommand.Factory,
private val listSettings: ListSettings,
- private val musicRepository: MusicRepository,
) : ViewModel(), PlaybackStateManager.Listener, PlaybackSettings.Listener {
private var lastPositionJob: Job? = null
@@ -189,21 +190,21 @@ constructor(
fun play(song: Song, with: PlaySong) {
logD("Playing $song with $with")
- playWithImpl(song, with, isImplicitlyShuffled())
+ playWithImpl(song, with, ShuffleMode.IMPLICIT)
}
fun playExplicit(song: Song, with: PlaySong) {
- playWithImpl(song, with, false)
+ playWithImpl(song, with, ShuffleMode.OFF)
}
fun shuffleExplicit(song: Song, with: PlaySong) {
- playWithImpl(song, with, true)
+ playWithImpl(song, with, ShuffleMode.ON)
}
/** Shuffle all songs in the music library. */
fun shuffleAll() {
logD("Shuffling all songs")
- playFromAllImpl(null, true)
+ playFromAllImpl(null, ShuffleMode.ON)
}
/**
@@ -214,7 +215,7 @@ constructor(
* be prompted on what artist to play. Defaults to null.
*/
fun playFromArtist(song: Song, artist: Artist? = null) {
- playFromArtistImpl(song, artist, isImplicitlyShuffled())
+ playFromArtistImpl(song, artist, ShuffleMode.IMPLICIT)
}
/**
@@ -225,63 +226,68 @@ constructor(
* be prompted on what artist to play. Defaults to null.
*/
fun playFromGenre(song: Song, genre: Genre? = null) {
- playFromGenreImpl(song, genre, isImplicitlyShuffled())
+ playFromGenreImpl(song, genre, ShuffleMode.IMPLICIT)
}
- private fun isImplicitlyShuffled() = playbackManager.isShuffled && playbackSettings.keepShuffle
-
- private fun playWithImpl(song: Song, with: PlaySong, shuffled: Boolean) {
+ private fun playWithImpl(song: Song, with: PlaySong, shuffle: ShuffleMode) {
when (with) {
- is PlaySong.FromAll -> playFromAllImpl(song, shuffled)
- is PlaySong.FromAlbum -> playFromAlbumImpl(song, shuffled)
- is PlaySong.FromArtist -> playFromArtistImpl(song, with.which, shuffled)
- is PlaySong.FromGenre -> playFromGenreImpl(song, with.which, shuffled)
- is PlaySong.FromPlaylist -> playFromPlaylistImpl(song, with.which, shuffled)
- is PlaySong.ByItself -> playItselfImpl(song, shuffled)
+ is PlaySong.FromAll -> playFromAllImpl(song, shuffle)
+ is PlaySong.FromAlbum -> playFromAlbumImpl(song, shuffle)
+ is PlaySong.FromArtist -> playFromArtistImpl(song, with.which, shuffle)
+ is PlaySong.FromGenre -> playFromGenreImpl(song, with.which, shuffle)
+ is PlaySong.FromPlaylist -> playFromPlaylistImpl(song, with.which, shuffle)
+ is PlaySong.ByItself -> playItselfImpl(song, shuffle)
}
}
- private fun playFromAllImpl(song: Song?, shuffled: Boolean) {
- playImpl(song, null, shuffled)
+ private fun playItselfImpl(song: Song, shuffle: ShuffleMode) {
+ playbackManager.play(
+ requireNotNull(commandFactory.song(song, shuffle)) {
+ "Invalid playback parameters [$song $shuffle]"
+ })
}
- private fun playFromAlbumImpl(song: Song, shuffled: Boolean) {
- playImpl(song, song.album, shuffled)
+ private fun playFromAllImpl(song: Song?, shuffle: ShuffleMode) {
+ val params =
+ if (song != null) {
+ commandFactory.songFromAll(song, shuffle)
+ } else {
+ commandFactory.all(shuffle)
+ }
+
+ playImpl(params)
}
- private fun playFromArtistImpl(song: Song, artist: Artist?, shuffled: Boolean) {
- if (artist != null) {
- logD("Playing $song from $artist")
- playImpl(song, artist, shuffled)
- } else if (song.artists.size == 1) {
- logD("$song has one artist, playing from it")
- playImpl(song, song.artists[0], shuffled)
- } else {
- logD("$song has multiple artists, showing choice dialog")
- startPlaybackDecision(PlaybackDecision.PlayFromArtist(song))
+ private fun playFromAlbumImpl(song: Song, shuffle: ShuffleMode) {
+ logD("Playing $song from album")
+ playImpl(commandFactory.songFromAlbum(song, shuffle))
+ }
+
+ private fun playFromArtistImpl(song: Song, artist: Artist?, shuffle: ShuffleMode) {
+ val params = commandFactory.songFromArtist(song, artist, shuffle)
+ if (params != null) {
+ playbackManager.play(params)
+ return
}
+ logD(
+ "Cannot use given artist parameter for $song [$artist from ${song.artists}], showing choice dialog")
+ startPlaybackDecision(PlaybackDecision.PlayFromArtist(song))
}
- private fun playFromGenreImpl(song: Song, genre: Genre?, shuffled: Boolean) {
- if (genre != null) {
- logD("Playing $song from $genre")
- playImpl(song, genre, shuffled)
- } else if (song.genres.size == 1) {
- logD("$song has one genre, playing from it")
- playImpl(song, song.genres[0], shuffled)
- } else {
- logD("$song has multiple genres, showing choice dialog")
- startPlaybackDecision(PlaybackDecision.PlayFromGenre(song))
+ private fun playFromGenreImpl(song: Song, genre: Genre?, shuffle: ShuffleMode) {
+ val params = commandFactory.songFromGenre(song, genre, shuffle)
+ if (params != null) {
+ playbackManager.play(params)
+ return
}
+ logD(
+ "Cannot use given genre parameter for $song [$genre from ${song.genres}], showing choice dialog")
+ startPlaybackDecision(PlaybackDecision.PlayFromArtist(song))
}
- private fun playFromPlaylistImpl(song: Song, playlist: Playlist, shuffled: Boolean) {
+ private fun playFromPlaylistImpl(song: Song, playlist: Playlist, shuffle: ShuffleMode) {
logD("Playing $song from $playlist")
- playImpl(song, playlist, shuffled)
- }
-
- private fun playItselfImpl(song: Song, shuffled: Boolean) {
- playImpl(song, listOf(song), shuffled)
+ playImpl(commandFactory.songFromPlaylist(song, playlist, shuffle))
}
private fun startPlaybackDecision(decision: PlaybackDecision) {
@@ -300,7 +306,7 @@ constructor(
*/
fun play(album: Album) {
logD("Playing $album")
- playImpl(null, album, false)
+ playImpl(commandFactory.album(album, ShuffleMode.OFF))
}
/**
@@ -310,7 +316,7 @@ constructor(
*/
fun shuffle(album: Album) {
logD("Shuffling $album")
- playImpl(null, album, true)
+ playImpl(commandFactory.album(album, ShuffleMode.ON))
}
/**
@@ -320,7 +326,7 @@ constructor(
*/
fun play(artist: Artist) {
logD("Playing $artist")
- playImpl(null, artist, false)
+ playImpl(commandFactory.artist(artist, ShuffleMode.OFF))
}
/**
@@ -330,7 +336,7 @@ constructor(
*/
fun shuffle(artist: Artist) {
logD("Shuffling $artist")
- playImpl(null, artist, true)
+ playImpl(commandFactory.artist(artist, ShuffleMode.ON))
}
/**
@@ -340,7 +346,7 @@ constructor(
*/
fun play(genre: Genre) {
logD("Playing $genre")
- playImpl(null, genre, false)
+ playImpl(commandFactory.genre(genre, ShuffleMode.OFF))
}
/**
@@ -350,7 +356,7 @@ constructor(
*/
fun shuffle(genre: Genre) {
logD("Shuffling $genre")
- playImpl(null, genre, true)
+ playImpl(commandFactory.genre(genre, ShuffleMode.ON))
}
/**
@@ -360,7 +366,7 @@ constructor(
*/
fun play(playlist: Playlist) {
logD("Playing $playlist")
- playImpl(null, playlist, false)
+ playImpl(commandFactory.playlist(playlist, ShuffleMode.OFF))
}
/**
@@ -370,7 +376,7 @@ constructor(
*/
fun shuffle(playlist: Playlist) {
logD("Shuffling $playlist")
- playImpl(null, playlist, true)
+ playImpl(commandFactory.playlist(playlist, ShuffleMode.ON))
}
/**
@@ -380,7 +386,7 @@ constructor(
*/
fun play(songs: List) {
logD("Playing ${songs.size} songs")
- playbackManager.play(null, null, songs, false)
+ playImpl(commandFactory.songs(songs, ShuffleMode.OFF))
}
/**
@@ -390,28 +396,11 @@ constructor(
*/
fun shuffle(songs: List) {
logD("Shuffling ${songs.size} songs")
- playbackManager.play(null, null, songs, true)
+ playImpl(commandFactory.songs(songs, ShuffleMode.ON))
}
- private fun playImpl(song: Song?, queue: List, shuffled: Boolean) {
- check(song == null || queue.contains(song)) { "Song to play not in queue" }
- playbackManager.play(song, null, queue, shuffled)
- }
-
- private fun playImpl(song: Song?, parent: MusicParent?, shuffled: Boolean) {
- check(song == null || parent == null || parent.songs.contains(song)) {
- "Song to play not in parent"
- }
- val deviceLibrary = musicRepository.deviceLibrary ?: return
- val queue =
- when (parent) {
- is Genre -> listSettings.genreSongSort.songs(parent.songs)
- is Artist -> listSettings.artistSongSort.songs(parent.songs)
- is Album -> listSettings.albumSongSort.songs(parent.songs)
- is Playlist -> parent.songs
- null -> listSettings.songSort.songs(deviceLibrary.songs)
- }
- playbackManager.play(song, parent, queue, shuffled)
+ private fun playImpl(command: PlaybackCommand?) {
+ playbackManager.play(requireNotNull(command) { "Invalid playback parameters" })
}
/**
@@ -617,49 +606,6 @@ constructor(
}
_openPanel.put(panel)
}
-
- // --- SAVE/RESTORE FUNCTIONS ---
-
- /**
- * Force-save the current playback state.
- *
- * @param onDone Called when the save is completed with true if successful, and false otherwise.
- */
- fun savePlaybackState(onDone: (Boolean) -> Unit) {
- logD("Saving playback state")
- viewModelScope.launch {
- onDone(persistenceRepository.saveState(playbackManager.toSavedState()))
- }
- }
-
- /**
- * Clear the current playback state.
- *
- * @param onDone Called when the wipe is completed with true if successful, and false otherwise.
- */
- fun wipePlaybackState(onDone: (Boolean) -> Unit) {
- logD("Wiping playback state")
- viewModelScope.launch { onDone(persistenceRepository.saveState(null)) }
- }
-
- /**
- * Force-restore the current playback state.
- *
- * @param onDone Called when the restoration is completed with true if successful, and false
- * otherwise.
- */
- fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) {
- logD("Force-restoring playback state")
- viewModelScope.launch {
- val savedState = persistenceRepository.readState()
- if (savedState != null) {
- playbackManager.applySavedState(savedState, true)
- onDone(true)
- return@launch
- }
- onDone(false)
- }
- }
}
/**
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 09168842d..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
@@ -57,7 +57,7 @@ constructor(
flush()
}
- init {
+ fun attach() {
playbackManager.addListener(this)
playbackSettings.registerListener(this)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt
similarity index 98%
rename from app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt
rename to app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt
index b3b067fa4..0f037a75a 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt
@@ -16,11 +16,10 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.playback.system
+package org.oxycblt.auxio.playback.service
import androidx.media3.common.C
import androidx.media3.exoplayer.source.ShuffleOrder
-import java.util.*
/**
* A ShuffleOrder that fixes the poorly defined default implementation of cloneAndInsert. Whereas
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/BluetoothHeadsetReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/BluetoothHeadsetReceiver.kt
similarity index 97%
rename from app/src/main/java/org/oxycblt/auxio/playback/system/BluetoothHeadsetReceiver.kt
rename to app/src/main/java/org/oxycblt/auxio/playback/service/BluetoothHeadsetReceiver.kt
index c8dbabc83..11df166f2 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/BluetoothHeadsetReceiver.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/BluetoothHeadsetReceiver.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.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
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
new file mode 100644
index 000000000..058b33403
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt
@@ -0,0 +1,591 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * ExoPlaybackStateHolder.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.content.Intent
+import android.media.audiofx.AudioEffect
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.RenderersFactory
+import androidx.media3.exoplayer.audio.AudioCapabilities
+import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer
+import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
+import androidx.media3.exoplayer.source.MediaSource
+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.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.yield
+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.music.service.toSong
+import org.oxycblt.auxio.playback.PlaybackSettings
+import org.oxycblt.auxio.playback.persist.PersistenceRepository
+import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
+import org.oxycblt.auxio.playback.state.DeferredPlayback
+import org.oxycblt.auxio.playback.state.PlaybackCommand
+import org.oxycblt.auxio.playback.state.PlaybackStateHolder
+import org.oxycblt.auxio.playback.state.PlaybackStateManager
+import org.oxycblt.auxio.playback.state.Progression
+import org.oxycblt.auxio.playback.state.RawQueue
+import org.oxycblt.auxio.playback.state.RepeatMode
+import org.oxycblt.auxio.playback.state.ShuffleMode
+import org.oxycblt.auxio.playback.state.StateAck
+import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.util.logE
+
+class ExoPlaybackStateHolder(
+ private val context: Context,
+ private val player: ExoPlayer,
+ private val playbackManager: PlaybackStateManager,
+ private val persistenceRepository: PersistenceRepository,
+ private val playbackSettings: PlaybackSettings,
+ private val commandFactory: PlaybackCommand.Factory,
+ private val replayGainProcessor: ReplayGainAudioProcessor,
+ private val musicRepository: MusicRepository,
+ private val imageSettings: ImageSettings
+) :
+ PlaybackStateHolder,
+ Player.Listener,
+ MusicRepository.UpdateListener,
+ PlaybackSettings.Listener,
+ ImageSettings.Listener {
+ private val saveJob = Job()
+ private val saveScope = CoroutineScope(Dispatchers.IO + saveJob)
+ private val restoreScope = CoroutineScope(Dispatchers.IO + saveJob)
+ private var currentSaveJob: Job? = null
+ private var openAudioEffectSession = false
+
+ var sessionOngoing = false
+ private set
+
+ fun attach() {
+ imageSettings.registerListener(this)
+ player.addListener(this)
+ replayGainProcessor.attach()
+ playbackManager.registerStateHolder(this)
+ playbackSettings.registerListener(this)
+ musicRepository.addUpdateListener(this)
+ }
+
+ fun release() {
+ saveJob.cancel()
+ player.removeListener(this)
+ playbackManager.unregisterStateHolder(this)
+ musicRepository.removeUpdateListener(this)
+ replayGainProcessor.release()
+ imageSettings.unregisterListener(this)
+ player.release()
+ }
+
+ 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()
+ val duration = mediaItem.mediaMetadata.extras?.getLong("durationMs") ?: Long.MAX_VALUE
+ val clampedPosition = player.currentPosition.coerceAtLeast(0).coerceAtMost(duration)
+ return Progression.from(player.playWhenReady, player.isPlaying, clampedPosition)
+ }
+
+ override val repeatMode
+ get() =
+ when (val repeatMode = player.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")
+ }
+
+ override val audioSessionId: Int
+ get() = player.audioSessionId
+
+ override fun resolveQueue(): RawQueue {
+ val deviceLibrary =
+ musicRepository.deviceLibrary
+ // No library, cannot do anything.
+ ?: return RawQueue(emptyList(), emptyList(), 0)
+ val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) }
+ val shuffledMapping =
+ if (player.shuffleModeEnabled) {
+ player.unscrambleQueueIndices()
+ } else {
+ emptyList()
+ }
+ return RawQueue(
+ heap.mapNotNull { it.toSong(deviceLibrary) },
+ shuffledMapping,
+ player.currentMediaItemIndex)
+ }
+
+ override fun handleDeferred(action: DeferredPlayback): Boolean {
+ val deviceLibrary =
+ musicRepository.deviceLibrary
+ // No library, cannot do anything.
+ ?: return false
+
+ when (action) {
+ // Restore state -> Start a new restoreState job
+ is DeferredPlayback.RestoreState -> {
+ logD("Restoring playback state")
+ restoreScope.launch {
+ persistenceRepository.readState()?.let {
+ // 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) }
+ }
+ }
+ }
+ // Shuffle all -> Start new playback from all songs
+ is DeferredPlayback.ShuffleAll -> {
+ logD("Shuffling all tracks")
+ playbackManager.play(
+ requireNotNull(commandFactory.all(ShuffleMode.ON)) {
+ "Invalid playback parameters"
+ })
+ }
+ // Open -> Try to find the Song for the given file and then play it from all songs
+ is DeferredPlayback.Open -> {
+ logD("Opening specified file")
+ deviceLibrary.findSongForUri(context, action.uri)?.let { song ->
+ playbackManager.play(
+ requireNotNull(commandFactory.song(song, ShuffleMode.IMPLICIT)) {
+ "Invalid playback parameters"
+ })
+ }
+ }
+ }
+
+ return true
+ }
+
+ override fun playing(playing: Boolean) {
+ player.playWhenReady = playing
+ }
+
+ override fun seekTo(positionMs: Long) {
+ player.seekTo(positionMs)
+ deferSave()
+ // Ack handled w/ExoPlayer events
+ }
+
+ override fun repeatMode(repeatMode: RepeatMode) {
+ player.repeatMode =
+ when (repeatMode) {
+ RepeatMode.NONE -> Player.REPEAT_MODE_OFF
+ RepeatMode.ALL -> Player.REPEAT_MODE_ALL
+ RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
+ }
+ updatePauseOnRepeat()
+ playbackManager.ack(this, StateAck.RepeatModeChanged)
+ deferSave()
+ }
+
+ override fun newPlayback(command: PlaybackCommand) {
+ parent = command.parent
+ player.shuffleModeEnabled = command.shuffled
+ player.setMediaItems(command.queue.map { it.toMediaItem(context, null) })
+ val startIndex =
+ command.song
+ ?.let { command.queue.indexOf(it) }
+ .also { check(it != -1) { "Start song not in queue" } }
+ if (command.shuffled) {
+ player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1))
+ }
+ val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(command.shuffled)
+ player.seekTo(target, C.TIME_UNSET)
+ player.prepare()
+ player.play()
+ playbackManager.ack(this, StateAck.NewPlayback)
+ deferSave()
+ }
+
+ override fun shuffled(shuffled: Boolean) {
+ player.setShuffleModeEnabled(shuffled)
+ if (player.shuffleModeEnabled) {
+ // Have to manually refresh the shuffle seed and anchor it to the new current songs
+ player.setShuffleOrder(
+ BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex))
+ }
+ playbackManager.ack(this, StateAck.QueueReordered)
+ deferSave()
+ }
+
+ override fun next() {
+ // Replicate the old pseudo-circular queue behavior when no repeat option is implemented.
+ // Basically, you can't skip back and wrap around the queue, but you can skip forward and
+ // wrap around the queue, albeit playback will be paused.
+ if (player.repeatMode == Player.REPEAT_MODE_ALL || player.hasNextMediaItem()) {
+ player.seekToNext()
+ if (!playbackSettings.rememberPause) {
+ player.play()
+ }
+ } else {
+ player.seekTo(
+ player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled), C.TIME_UNSET)
+ // TODO: Dislike the UX implications of this, I feel should I bite the bullet
+ // and switch to dynamic skip enable/disable?
+ if (!playbackSettings.rememberPause) {
+ player.pause()
+ }
+ }
+ playbackManager.ack(this, StateAck.IndexMoved)
+ deferSave()
+ }
+
+ override fun prev() {
+ if (playbackSettings.rewindWithPrev) {
+ player.seekToPrevious()
+ } else if (player.hasPreviousMediaItem()) {
+ player.seekToPreviousMediaItem()
+ } else {
+ player.seekTo(0)
+ }
+ if (!playbackSettings.rememberPause) {
+ player.play()
+ }
+ playbackManager.ack(this, StateAck.IndexMoved)
+ deferSave()
+ }
+
+ override fun goto(index: Int) {
+ val indices = player.unscrambleQueueIndices()
+ if (indices.isEmpty()) {
+ return
+ }
+
+ val trueIndex = indices[index]
+ player.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic
+ if (!playbackSettings.rememberPause) {
+ player.play()
+ }
+ playbackManager.ack(this, StateAck.IndexMoved)
+ deferSave()
+ }
+
+ override fun playNext(songs: List, ack: StateAck.PlayNext) {
+ val currTimeline = player.currentTimeline
+ val nextIndex =
+ if (currTimeline.isEmpty) {
+ C.INDEX_UNSET
+ } else {
+ currTimeline.getNextWindowIndex(
+ player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled)
+ }
+
+ if (nextIndex == C.INDEX_UNSET) {
+ player.addMediaItems(songs.map { it.toMediaItem(context, null) })
+ } else {
+ player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) })
+ }
+ playbackManager.ack(this, ack)
+ deferSave()
+ }
+
+ override fun addToQueue(songs: List, ack: StateAck.AddToQueue) {
+ player.addMediaItems(songs.map { it.toMediaItem(context, null) })
+ playbackManager.ack(this, ack)
+ deferSave()
+ }
+
+ override fun move(from: Int, to: Int, ack: StateAck.Move) {
+ val indices = player.unscrambleQueueIndices()
+ if (indices.isEmpty()) {
+ return
+ }
+
+ val trueFrom = indices[from]
+ val trueTo = indices[to]
+ // ExoPlayer does not actually update it's ShuffleOrder when moving items. Retain a
+ // semblance of "normalcy" by doing a weird no-op swap that actually moves the item.
+ when {
+ trueFrom > trueTo -> {
+ player.moveMediaItem(trueFrom, trueTo)
+ player.moveMediaItem(trueTo + 1, trueFrom)
+ }
+ trueTo > trueFrom -> {
+ player.moveMediaItem(trueFrom, trueTo)
+ player.moveMediaItem(trueTo - 1, trueFrom)
+ }
+ }
+ playbackManager.ack(this, ack)
+ deferSave()
+ }
+
+ override fun remove(at: Int, ack: StateAck.Remove) {
+ val indices = player.unscrambleQueueIndices()
+ if (indices.isEmpty()) {
+ return
+ }
+
+ val trueIndex = indices[at]
+ val songWillChange = player.currentMediaItemIndex == trueIndex
+ player.removeMediaItem(trueIndex)
+ if (songWillChange && !playbackSettings.rememberPause) {
+ player.play()
+ }
+ playbackManager.ack(this, ack)
+ deferSave()
+ }
+
+ override fun applySavedState(
+ parent: MusicParent?,
+ rawQueue: RawQueue,
+ ack: StateAck.NewPlayback?
+ ) {
+ logD("Applying saved state")
+ var sendEvent = false
+ if (this.parent != parent) {
+ this.parent = parent
+ sendEvent = true
+ }
+ if (rawQueue != resolveQueue()) {
+ player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
+ if (rawQueue.isShuffled) {
+ player.shuffleModeEnabled = true
+ player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
+ } else {
+ player.shuffleModeEnabled = false
+ }
+ player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
+ player.prepare()
+ player.pause()
+ sendEvent = true
+ }
+ if (sendEvent) {
+ ack?.let { playbackManager.ack(this, it) }
+ }
+ }
+
+ override fun endSession() {
+ // This session has ended, so we need to reset this flag for when the next
+ // session starts.
+ save {
+ // User could feasibly start playing again if they were fast enough, so
+ // we need to avoid stopping the foreground state if that's the case.
+ if (playbackManager.progression.isPlaying) {
+ playbackManager.playing(false)
+ }
+ sessionOngoing = false
+ playbackManager.ack(this, StateAck.SessionEnded)
+ }
+ }
+
+ override fun reset(ack: StateAck.NewPlayback) {
+ player.setMediaItems(listOf())
+ playbackManager.ack(this, ack)
+ deferSave()
+ }
+
+ // --- PLAYER OVERRIDES ---
+
+ override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
+ super.onPlayWhenReadyChanged(playWhenReady, reason)
+
+ if (player.playWhenReady) {
+ // Mark that we have started playing so that the notification can now be posted.
+ logD("Player has started playing")
+ sessionOngoing = true
+ if (!openAudioEffectSession) {
+ // Convention to start an audioeffect session on play/pause rather than
+ // start/stop
+ logD("Opening audio effect session")
+ broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
+ openAudioEffectSession = true
+ }
+ } else if (openAudioEffectSession) {
+ // Make sure to close the audio session when we stop playback.
+ logD("Closing audio effect session")
+ broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
+ openAudioEffectSession = false
+ }
+ }
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ super.onPlaybackStateChanged(playbackState)
+
+ if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) {
+ goto(0)
+ player.pause()
+ }
+ }
+
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+ super.onMediaItemTransition(mediaItem, reason)
+
+ if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
+ playbackManager.ack(this, StateAck.IndexMoved)
+ }
+ }
+
+ override fun onEvents(player: Player, events: Player.Events) {
+ super.onEvents(player, events)
+
+ // So many actions trigger progression changes that it becomes easier just to handle it
+ // in an ExoPlayer callback anyway. This doesn't really cause issues anywhere.
+ if (events.containsAny(
+ Player.EVENT_PLAY_WHEN_READY_CHANGED,
+ Player.EVENT_IS_PLAYING_CHANGED,
+ Player.EVENT_POSITION_DISCONTINUITY)) {
+ logD("Player state changed, must synchronize state")
+ playbackManager.ack(this, StateAck.ProgressionChanged)
+ }
+ }
+
+ override fun onPlayerError(error: PlaybackException) {
+ // TODO: Replace with no skipping and a notification instead
+ // If there's any issue, just go to the next song.
+ logE("Player error occurred")
+ logE(error.stackTraceToString())
+ playbackManager.next()
+ }
+
+ private fun broadcastAudioEffectAction(event: String) {
+ logD("Broadcasting AudioEffect event: $event")
+ context.sendBroadcast(
+ Intent(event)
+ .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
+ .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
+ .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
+ }
+
+ // --- MUSICREPOSITORY METHODS ---
+
+ override fun onMusicChanges(changes: MusicRepository.Changes) {
+ if (changes.deviceLibrary && musicRepository.deviceLibrary != null) {
+ // We now have a library, see if we have anything we need to do.
+ logD("Library obtained, requesting action")
+ playbackManager.requestAction(this)
+ }
+ }
+
+ // --- PLAYBACKSETTINGS OVERRIDES ---
+
+ override fun onPauseOnRepeatChanged() {
+ super.onPauseOnRepeatChanged()
+ updatePauseOnRepeat()
+ }
+
+ private fun updatePauseOnRepeat() {
+ player.pauseAtEndOfMediaItems =
+ player.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat
+ }
+
+ private fun save(cb: () -> Unit) {
+ saveJob {
+ persistenceRepository.saveState(playbackManager.toSavedState())
+ withContext(Dispatchers.Main) { cb() }
+ }
+ }
+
+ private fun deferSave() {
+ saveJob {
+ logD("Waiting for save buffer")
+ delay(SAVE_BUFFER)
+ yield()
+ logD("Committing saved state")
+ persistenceRepository.saveState(playbackManager.toSavedState())
+ }
+ }
+
+ private fun saveJob(block: suspend () -> Unit) {
+ currentSaveJob?.let {
+ logD("Discarding prior save job")
+ it.cancel()
+ }
+ currentSaveJob = saveScope.launch { block() }
+ }
+
+ class Factory
+ @Inject
+ constructor(
+ @ApplicationContext private val context: Context,
+ private val playbackManager: PlaybackStateManager,
+ private val persistenceRepository: PersistenceRepository,
+ private val playbackSettings: PlaybackSettings,
+ private val commandFactory: PlaybackCommand.Factory,
+ private val mediaSourceFactory: MediaSource.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 audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
+ arrayOf(
+ FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
+ MediaCodecAudioRenderer(
+ context,
+ MediaCodecSelector.DEFAULT,
+ handler,
+ audioListener,
+ AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
+ replayGainProcessor))
+ }
+
+ val exoPlayer =
+ ExoPlayer.Builder(context, audioRenderer)
+ .setMediaSourceFactory(mediaSourceFactory)
+ // Enable automatic WakeLock support
+ .setWakeMode(C.WAKE_MODE_LOCAL)
+ .setAudioAttributes(
+ // Signal that we are a music player.
+ AudioAttributes.Builder()
+ .setUsage(C.USAGE_MEDIA)
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
+ .build(),
+ true)
+ .build()
+
+ return ExoPlaybackStateHolder(
+ context,
+ exoPlayer,
+ playbackManager,
+ persistenceRepository,
+ playbackSettings,
+ commandFactory,
+ replayGainProcessor,
+ musicRepository,
+ imageSettings)
+ }
+ }
+
+ private companion object {
+ const val SAVE_BUFFER = 5000L
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt
similarity index 90%
rename from app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt
rename to app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt
index 83b6bcbcd..9ea0300b3 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.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.content.BroadcastReceiver
import android.content.ComponentName
@@ -25,11 +25,13 @@ import android.content.Intent
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
+import org.oxycblt.auxio.AuxioService
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD
/**
- * A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService].
+ * A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to
+ * [PlaybackServiceFragment].
*
* @author Alexander Capehart (OxygenCobalt)
*/
@@ -46,7 +48,7 @@ class MediaButtonReceiver : BroadcastReceiver() {
// wrong action at the wrong time will result in the app crashing, and there is
// nothing I can do about it.
logD("Delivering media button intent $intent")
- intent.component = ComponentName(context, PlaybackService::class.java)
+ intent.component = ComponentName(context, AuxioService::class.java)
ContextCompat.startForegroundService(context, intent)
}
}
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
new file mode 100644
index 000000000..f5ea4215c
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt
@@ -0,0 +1,390 @@
+/*
+ * 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/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt
new file mode 100644
index 000000000..69f2aab6d
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt
@@ -0,0 +1,273 @@
+/*
+ * 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.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.state.DeferredPlayback
+import org.oxycblt.auxio.playback.state.PlaybackStateManager
+import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.util.newMainPendingIntent
+
+class MediaSessionServiceFragment
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+ private val playbackManager: PlaybackStateManager,
+ private val actionHandler: PlaybackActionHandler,
+ 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 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)
+ mediaItemBrowser.attach(this)
+ return mediaSession
+ }
+
+ fun handleTaskRemoved() {
+ if (!playbackManager.progression.isPlaying) {
+ playbackManager.endSession()
+ }
+ }
+
+ fun handleNonNativeStart() {
+ // 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)
+ }
+
+ 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()
+ 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
new file mode 100644
index 000000000..6d0c1fbeb
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt
@@ -0,0 +1,280 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * PlaybackActionHandler.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
+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
+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
+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
+) : 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) =
+ 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"
+ const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
+ const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
+ const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
+ 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/system/SystemModule.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemModule.kt
similarity index 98%
rename from app/src/main/java/org/oxycblt/auxio/playback/system/SystemModule.kt
rename to app/src/main/java/org/oxycblt/auxio/playback/service/SystemModule.kt
index 47b052761..3aade8f3e 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/SystemModule.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemModule.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.content.Context
import androidx.media3.datasource.ContentDataSource
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt
new file mode 100644
index 000000000..f53745632
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * PlaybackCommand.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.state
+
+import javax.inject.Inject
+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.MusicParent
+import org.oxycblt.auxio.music.MusicRepository
+import org.oxycblt.auxio.music.Playlist
+import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.playback.PlaybackSettings
+
+/**
+ * @param song A particular [Song] to play, or null to play the first [Song] in the new queue.
+ * @param queue The queue of [Song]s to play from.
+ * @param parent The [MusicParent] to play from, or null if to play from an non-specific collection
+ * of "All [Song]s".
+ * @param shuffled Whether to shuffle or not.
+ */
+interface PlaybackCommand {
+ val song: Song?
+ val parent: MusicParent?
+ val queue: List
+ val shuffled: Boolean
+
+ interface Factory {
+ fun song(song: Song, shuffle: ShuffleMode): PlaybackCommand?
+
+ fun songFromAll(song: Song, shuffle: ShuffleMode): PlaybackCommand?
+
+ fun songFromAlbum(song: Song, shuffle: ShuffleMode): PlaybackCommand?
+
+ fun songFromArtist(song: Song, artist: Artist?, shuffle: ShuffleMode): PlaybackCommand?
+
+ fun songFromGenre(song: Song, genre: Genre?, shuffle: ShuffleMode): PlaybackCommand?
+
+ fun songFromPlaylist(song: Song, playlist: Playlist, shuffle: ShuffleMode): PlaybackCommand?
+
+ fun all(shuffle: ShuffleMode): PlaybackCommand?
+
+ fun songs(songs: List, shuffle: ShuffleMode): PlaybackCommand?
+
+ fun album(album: Album, shuffle: ShuffleMode): PlaybackCommand?
+
+ fun artist(artist: Artist, shuffle: ShuffleMode): PlaybackCommand?
+
+ fun genre(genre: Genre, shuffle: ShuffleMode): PlaybackCommand?
+
+ fun playlist(playlist: Playlist, shuffle: ShuffleMode): PlaybackCommand?
+ }
+}
+
+enum class ShuffleMode {
+ ON,
+ OFF,
+ IMPLICIT
+}
+
+class PlaybackCommandFactoryImpl
+@Inject
+constructor(
+ val playbackManager: PlaybackStateManager,
+ val playbackSettings: PlaybackSettings,
+ val listSettings: ListSettings,
+ val musicRepository: MusicRepository
+) : PlaybackCommand.Factory {
+ data class PlaybackCommandImpl(
+ override val song: Song?,
+ override val parent: MusicParent?,
+ override val queue: List,
+ override val shuffled: Boolean
+ ) : PlaybackCommand
+
+ override fun song(song: Song, shuffle: ShuffleMode) =
+ newCommand(song, null, listOf(song), shuffle)
+
+ override fun songFromAll(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle)
+
+ override fun songFromAlbum(song: Song, shuffle: ShuffleMode) =
+ newCommand(song, song.album, listSettings.albumSongSort, shuffle)
+
+ override fun songFromArtist(song: Song, artist: Artist?, shuffle: ShuffleMode) =
+ newCommand(song, artist, song.artists, listSettings.artistSongSort, shuffle)
+
+ override fun songFromGenre(song: Song, genre: Genre?, shuffle: ShuffleMode) =
+ newCommand(song, genre, song.genres, listSettings.genreSongSort, shuffle)
+
+ override fun songFromPlaylist(song: Song, playlist: Playlist, shuffle: ShuffleMode) =
+ newCommand(song, playlist, playlist.songs, shuffle)
+
+ override fun all(shuffle: ShuffleMode) = newCommand(null, shuffle)
+
+ override fun songs(songs: List, shuffle: ShuffleMode) =
+ newCommand(null, null, songs, shuffle)
+
+ override fun album(album: Album, shuffle: ShuffleMode) =
+ newCommand(null, album, listSettings.albumSongSort, shuffle)
+
+ override fun artist(artist: Artist, shuffle: ShuffleMode) =
+ newCommand(null, artist, listSettings.artistSongSort, shuffle)
+
+ override fun genre(genre: Genre, shuffle: ShuffleMode) =
+ newCommand(null, genre, listSettings.genreSongSort, shuffle)
+
+ override fun playlist(playlist: Playlist, shuffle: ShuffleMode) =
+ newCommand(null, playlist, playlist.songs, shuffle)
+
+ private fun newCommand(
+ song: Song,
+ parent: T?,
+ parents: List,
+ sort: Sort,
+ shuffle: ShuffleMode
+ ): PlaybackCommand? {
+ return if (parent != null) {
+ newCommand(song, parent, sort, shuffle)
+ } else if (song.genres.size == 1) {
+ newCommand(song, parents.first(), sort, shuffle)
+ } else {
+ null
+ }
+ }
+
+ private fun newCommand(song: Song?, shuffle: ShuffleMode): PlaybackCommand? {
+ val deviceLibrary = musicRepository.deviceLibrary ?: return null
+ return newCommand(song, null, deviceLibrary.songs, listSettings.songSort, shuffle)
+ }
+
+ private fun newCommand(
+ song: Song?,
+ parent: MusicParent,
+ sort: Sort,
+ shuffle: ShuffleMode
+ ): PlaybackCommand? {
+ val songs = sort.songs(parent.songs)
+ return newCommand(song, parent, songs, sort, shuffle)
+ }
+
+ private fun newCommand(
+ song: Song?,
+ parent: MusicParent?,
+ queue: Collection,
+ sort: Sort,
+ shuffle: ShuffleMode
+ ): PlaybackCommand? {
+ if (queue.isEmpty() || (song != null && song !in queue)) {
+ return null
+ }
+ return newCommand(song, parent, sort.songs(queue), shuffle)
+ }
+
+ private fun newCommand(
+ song: Song?,
+ parent: MusicParent?,
+ queue: List,
+ shuffle: ShuffleMode
+ ): PlaybackCommand {
+ return PlaybackCommandImpl(song, parent, queue, isShuffled(shuffle))
+ }
+
+ private fun isShuffled(shuffle: ShuffleMode) =
+ when (shuffle) {
+ ShuffleMode.ON -> true
+ ShuffleMode.OFF -> false
+ ShuffleMode.IMPLICIT -> playbackSettings.keepShuffle && playbackManager.isShuffled
+ }
+}
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 259d5ab97..857ac6898 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
@@ -51,15 +51,8 @@ interface PlaybackStateHolder {
/** The current audio session ID of the audio player. */
val audioSessionId: Int
- /**
- * Applies a completely new playback state to the holder.
- *
- * @param queue The new queue to use.
- * @param start The song to start playback from. Should be in the queue.
- * @param parent The parent to play from.
- * @param shuffled Whether the queue should be shuffled.
- */
- fun newPlayback(queue: List, start: Song?, parent: MusicParent?, shuffled: Boolean)
+ /** Applies a completely new playback state to the holder. */
+ fun newPlayback(command: PlaybackCommand)
/**
* Update the playing state of the audio player.
@@ -154,6 +147,9 @@ interface PlaybackStateHolder {
*/
fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?)
+ /** End whatever ongoing playback session may be going on */
+ fun endSession()
+
/** Reset this instance to an empty state. */
fun reset(ack: StateAck.NewPlayback)
}
@@ -202,6 +198,8 @@ sealed interface StateAck {
/** @see PlaybackStateHolder.repeatMode */
data object RepeatModeChanged : StateAck
+
+ data object SessionEnded : StateAck
}
/**
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 fceb344a5..494ab2c0e 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
@@ -111,13 +111,9 @@ interface PlaybackStateManager {
/**
* Start new playback.
*
- * @param song A particular [Song] to play, or null to play the first [Song] in the new queue.
- * @param queue The queue of [Song]s to play from.
- * @param parent The [MusicParent] to play from, or null if to play from an non-specific
- * collection of "All [Song]s".
- * @param shuffled Whether to shuffle or not.
+ * @param command The parameters to start playback with.
*/
- fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean)
+ fun play(command: PlaybackCommand)
/**
* Go to the next [Song] in the queue. Will go to the first [Song] in the queue if there is no
@@ -237,8 +233,7 @@ interface PlaybackStateManager {
*/
fun seekTo(positionMs: Long)
- /** Rewind to the beginning of the currently playing [Song]. */
- fun rewind() = seekTo(0)
+ fun endSession()
/**
* Converts the current state of this instance into a [SavedState].
@@ -317,6 +312,8 @@ interface PlaybackStateManager {
* @param repeatMode The new [RepeatMode].
*/
fun onRepeatModeChanged(repeatMode: RepeatMode) {}
+
+ fun onSessionEnded() {}
}
/**
@@ -441,12 +438,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
// --- PLAYING FUNCTIONS ---
@Synchronized
- override fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) {
+ override fun play(command: PlaybackCommand) {
val stateHolder = stateHolder ?: return
- logD("Playing $song from $parent in ${queue.size}-song queue [shuffled=$shuffled]")
+ logD("Playing $command")
// Played something, so we are initialized now
isInitialized = true
- stateHolder.newPlayback(queue, song, parent, shuffled)
+ stateHolder.newPlayback(command)
}
// --- QUEUE FUNCTIONS ---
@@ -476,7 +473,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
override fun playNext(songs: List) {
if (currentSong == null) {
logD("Nothing playing, short-circuiting to new playback")
- play(songs[0], null, songs, false)
+ play(QueueCommand(songs))
} else {
val stateHolder = stateHolder ?: return
logD("Adding ${songs.size} songs to start of queue")
@@ -488,7 +485,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
override fun addToQueue(songs: List) {
if (currentSong == null) {
logD("Nothing playing, short-circuiting to new playback")
- play(songs[0], null, songs, false)
+ play(QueueCommand(songs))
} else {
val stateHolder = stateHolder ?: return
logD("Adding ${songs.size} songs to end of queue")
@@ -496,6 +493,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
}
}
+ private class QueueCommand(override val queue: List) : PlaybackCommand {
+ override val song: Song? = null
+ override val parent: MusicParent? = null
+ override val shuffled = false
+ }
+
@Synchronized
override fun moveQueueItem(src: Int, dst: Int) {
val stateHolder = stateHolder ?: return
@@ -562,6 +565,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
stateHolder.seekTo(positionMs)
}
+ @Synchronized
+ override fun endSession() {
+ val stateHolder = stateHolder ?: return
+ logD("Ending session")
+ stateHolder.endSession()
+ }
+
@Synchronized
override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) {
if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
@@ -688,6 +698,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
)
listeners.forEach { it.onRepeatModeChanged(stateMirror.repeatMode) }
}
+ is StateAck.SessionEnded -> {
+ listeners.forEach { it.onSessionEnded() }
+ }
}
}
@@ -782,15 +795,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
index
})
- // Valid state where something needs to be played, direct the stateholder to apply
- // this new state.
- val oldStateMirror = stateMirror
- if (oldStateMirror.rawQueue != rawQueue) {
- logD("Queue changed, must reload player")
- stateHolder.playing(false)
- stateHolder.applySavedState(parent, rawQueue, StateAck.NewPlayback)
- stateHolder.seekTo(savedState.positionMs)
- }
+ stateHolder.applySavedState(savedState.parent, rawQueue, StateAck.NewPlayback)
+ stateHolder.seekTo(savedState.positionMs)
+ stateHolder.repeatMode(savedState.repeatMode)
isInitialized = true
}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateModule.kt
similarity index 54%
rename from app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt
rename to app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateModule.kt
index 5e32d09ff..2b76ed971 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateModule.kt
@@ -1,6 +1,6 @@
/*
- * Copyright (c) 2023 Auxio Project
- * CoverUri.kt is part of Auxio.
+ * Copyright (c) 2024 Auxio Project
+ * PlaybackStateModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,17 +16,15 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.image.extractor
+package org.oxycblt.auxio.playback.state
-import android.net.Uri
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
-/**
- * Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading
- * images.
- *
- * @param mediaStore The album cover [Uri] obtained from MediaStore.
- * @param song The [Uri] of the first song (by track) of the album, which can also be used to obtain
- * an album cover.
- * @author Alexander Capehart (OxygenCobalt)
- */
-data class CoverUri(val mediaStore: Uri, val song: Uri)
+@Module
+@InstallIn(SingletonComponent::class)
+interface PlaybackStateModule {
+ @Binds fun playbackCommandFactory(factory: PlaybackCommandFactoryImpl): PlaybackCommand.Factory
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
deleted file mode 100644
index cbccf56ec..000000000
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
+++ /dev/null
@@ -1,482 +0,0 @@
-/*
- * Copyright (c) 2021 Auxio Project
- * MediaSessionComponent.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.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.media.session.MediaButtonReceiver
-import dagger.hilt.android.qualifiers.ApplicationContext
-import javax.inject.Inject
-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.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
-
-/**
- * A component that mirrors the current playback state into the [MediaSessionCompat] and
- * [NotificationComponent].
- *
- * @author Alexander Capehart (OxygenCobalt)
- */
-class MediaSessionComponent
-@Inject
-constructor(
- @ApplicationContext 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 {
- private val mediaSession =
- MediaSessionCompat(context, context.packageName).apply {
- isActive = true
- setQueueTitle(context.getString(R.string.lbl_queue))
- }
-
- private val notification = NotificationComponent(context, mediaSession.sessionToken)
-
- private var listener: Listener? = null
-
- init {
- playbackManager.addListener(this)
- playbackSettings.registerListener(this)
- imageSettings.registerListener(this)
- mediaSession.setCallback(this)
- }
-
- /**
- * Forward a system media button [Intent] to the [MediaSessionCompat].
- *
- * @param intent The [Intent.ACTION_MEDIA_BUTTON] [Intent] to forward.
- */
- fun handleMediaButtonIntent(intent: Intent) {
- logD("Forwarding $intent to MediaButtonReciever")
- MediaButtonReceiver.handleIntent(mediaSession, intent)
- }
-
- /**
- * Register a [Listener] for notification updates to this service.
- *
- * @param listener The [Listener] to register.
- */
- fun registerListener(listener: Listener) {
- this.listener = listener
- }
-
- /**
- * Release this instance, closing the [MediaSessionCompat] and preventing any further updates to
- * the [NotificationComponent].
- */
- fun release() {
- listener = 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) {
- listener?.onPostNotification(notification)
- }
- }
-
- 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.rewind()
- 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(PlaybackService.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")
- 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)
- listener?.onPostNotification(notification)
- }
- })
- }
-
- /**
- * 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.coverUri.mediaStore)
- .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(
- PlaybackService.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(
- PlaybackService.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(
- PlaybackService.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")
- listener?.onPostNotification(notification)
- }
- }
-
- /** An interface for handling changes in the notification configuration. */
- interface Listener {
- /**
- * Called when the [NotificationComponent] changes, requiring it to be re-posed.
- *
- * @param notification The new [NotificationComponent].
- */
- fun onPostNotification(notification: NotificationComponent)
- }
-
- 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/system/NotificationComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
deleted file mode 100644
index 7b9868072..000000000
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
+++ /dev/null
@@ -1,160 +0,0 @@
-/*
- * Copyright (c) 2021 Auxio Project
- * NotificationComponent.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.support.v4.media.MediaMetadataCompat
-import android.support.v4.media.session.MediaSessionCompat
-import androidx.annotation.DrawableRes
-import androidx.core.app.NotificationCompat
-import androidx.media.app.NotificationCompat.MediaStyle
-import org.oxycblt.auxio.BuildConfig
-import org.oxycblt.auxio.IntegerTable
-import org.oxycblt.auxio.R
-import org.oxycblt.auxio.playback.state.RepeatMode
-import org.oxycblt.auxio.service.ForegroundServiceNotification
-import org.oxycblt.auxio.util.logD
-import org.oxycblt.auxio.util.newBroadcastPendingIntent
-import org.oxycblt.auxio.util.newMainPendingIntent
-
-/**
- * The playback notification component. Due to race conditions regarding notification updates, this
- * component is not self-sufficient. [MediaSessionComponent] should be used instead of manage it.
- *
- * @author Alexander Capehart (OxygenCobalt)
- */
-@SuppressLint("RestrictedApi")
-class NotificationComponent(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, PlaybackService.ACTION_SKIP_PREV, R.drawable.ic_skip_prev_24))
- addAction(buildPlayPauseAction(context, true))
- addAction(
- buildAction(context, PlaybackService.ACTION_SKIP_NEXT, R.drawable.ic_skip_next_24))
- addAction(buildAction(context, PlaybackService.ACTION_EXIT, R.drawable.ic_close_24))
-
- setStyle(MediaStyle().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, PlaybackService.ACTION_PLAY_PAUSE, drawableRes)
- }
-
- private fun buildRepeatAction(
- context: Context,
- repeatMode: RepeatMode
- ): NotificationCompat.Action {
- return buildAction(context, PlaybackService.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, PlaybackService.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/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
deleted file mode 100644
index 3fc93a7f8..000000000
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
+++ /dev/null
@@ -1,800 +0,0 @@
-/*
- * Copyright (c) 2021 Auxio Project
- * PlaybackService.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.app.Service
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.media.AudioManager
-import android.media.audiofx.AudioEffect
-import android.os.IBinder
-import androidx.core.content.ContextCompat
-import androidx.media3.common.AudioAttributes
-import androidx.media3.common.C
-import androidx.media3.common.MediaItem
-import androidx.media3.common.PlaybackException
-import androidx.media3.common.Player
-import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.RenderersFactory
-import androidx.media3.exoplayer.audio.AudioCapabilities
-import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer
-import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
-import androidx.media3.exoplayer.source.MediaSource
-import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import kotlinx.coroutines.yield
-import org.oxycblt.auxio.BuildConfig
-import org.oxycblt.auxio.list.ListSettings
-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.persist.PersistenceRepository
-import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
-import org.oxycblt.auxio.playback.state.DeferredPlayback
-import org.oxycblt.auxio.playback.state.PlaybackStateHolder
-import org.oxycblt.auxio.playback.state.PlaybackStateManager
-import org.oxycblt.auxio.playback.state.Progression
-import org.oxycblt.auxio.playback.state.RawQueue
-import org.oxycblt.auxio.playback.state.RepeatMode
-import org.oxycblt.auxio.playback.state.StateAck
-import org.oxycblt.auxio.service.ForegroundManager
-import org.oxycblt.auxio.util.logD
-import org.oxycblt.auxio.util.logE
-import org.oxycblt.auxio.widgets.WidgetComponent
-import org.oxycblt.auxio.widgets.WidgetProvider
-
-/**
- * A service that manages the system-side aspects of playback, such as:
- * - The single [ExoPlayer] instance.
- * - The Media Notification
- * - Headset management
- * - Widgets
- *
- * This service is headless and does not manage the playback state. Moreover, the player instance is
- * not the source of truth for the state, but rather the means to control system-side playback. Both
- * of those tasks are what [PlaybackStateManager] is for.
- *
- * @author Alexander Capehart (OxygenCobalt)
- *
- * TODO: Refactor lifecycle to run completely headless (i.e no activity needed)
- * TODO: Android Auto
- */
-@AndroidEntryPoint
-class PlaybackService :
- Service(),
- Player.Listener,
- PlaybackStateHolder,
- PlaybackSettings.Listener,
- MediaSessionComponent.Listener,
- MusicRepository.UpdateListener {
- // Player components
- private lateinit var player: ExoPlayer
- @Inject lateinit var mediaSourceFactory: MediaSource.Factory
- @Inject lateinit var replayGainProcessor: ReplayGainAudioProcessor
-
- // System backend components
- @Inject lateinit var mediaSessionComponent: MediaSessionComponent
- @Inject lateinit var widgetComponent: WidgetComponent
- private val systemReceiver = PlaybackReceiver()
-
- // Shared components
- @Inject lateinit var playbackManager: PlaybackStateManager
- @Inject lateinit var playbackSettings: PlaybackSettings
- @Inject lateinit var persistenceRepository: PersistenceRepository
- @Inject lateinit var listSettings: ListSettings
- @Inject lateinit var musicRepository: MusicRepository
-
- // State
- private lateinit var foregroundManager: ForegroundManager
- private var hasPlayed = false
- private var openAudioEffectSession = false
-
- // Coroutines
- private val serviceJob = Job()
- private val restoreScope = CoroutineScope(serviceJob + Dispatchers.IO)
- private val saveScope = CoroutineScope(serviceJob + Dispatchers.IO)
- private var currentSaveJob: Job? = null
-
- // --- SERVICE OVERRIDES ---
-
- override fun onCreate() {
- super.onCreate()
-
- // Since Auxio is a music player, only specify an audio renderer to save
- // battery/apk size/cache size
- val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
- arrayOf(
- FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
- MediaCodecAudioRenderer(
- this,
- MediaCodecSelector.DEFAULT,
- handler,
- audioListener,
- AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
- replayGainProcessor))
- }
-
- player =
- ExoPlayer.Builder(this, audioRenderer)
- .setMediaSourceFactory(mediaSourceFactory)
- // Enable automatic WakeLock support
- .setWakeMode(C.WAKE_MODE_LOCAL)
- .setAudioAttributes(
- // Signal that we are a music player.
- AudioAttributes.Builder()
- .setUsage(C.USAGE_MEDIA)
- .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
- .build(),
- true)
- .build()
- .also { it.addListener(this) }
- foregroundManager = ForegroundManager(this)
- // Initialize any listener-dependent components last as we wouldn't want a listener race
- // condition to cause us to load music before we were fully initialize.
- playbackManager.registerStateHolder(this)
- musicRepository.addUpdateListener(this)
- mediaSessionComponent.registerListener(this)
- playbackSettings.registerListener(this)
-
- val intentFilter =
- IntentFilter().apply {
- addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
- addAction(AudioManager.ACTION_HEADSET_PLUG)
- addAction(ACTION_INC_REPEAT_MODE)
- addAction(ACTION_INVERT_SHUFFLE)
- addAction(ACTION_SKIP_PREV)
- addAction(ACTION_PLAY_PAUSE)
- addAction(ACTION_SKIP_NEXT)
- addAction(ACTION_EXIT)
- addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
- }
-
- ContextCompat.registerReceiver(
- this, systemReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED)
-
- logD("Service created")
- }
-
- override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
- // Forward system media button sent by MediaButtonReceiver to MediaSessionComponent
- if (intent.action == Intent.ACTION_MEDIA_BUTTON) {
- mediaSessionComponent.handleMediaButtonIntent(intent)
- }
- return START_NOT_STICKY
- }
-
- override fun onBind(intent: Intent): IBinder? = null
-
- override fun onTaskRemoved(rootIntent: Intent?) {
- super.onTaskRemoved(rootIntent)
- if (!playbackManager.progression.isPlaying) {
- playbackManager.playing(false)
- endSession()
- }
- }
-
- override fun onDestroy() {
- super.onDestroy()
-
- foregroundManager.release()
-
- // Pause just in case this destruction was unexpected.
- playbackManager.playing(false)
- playbackManager.unregisterStateHolder(this)
- musicRepository.removeUpdateListener(this)
- playbackSettings.unregisterListener(this)
-
- unregisterReceiver(systemReceiver)
- serviceJob.cancel()
-
- widgetComponent.release()
- mediaSessionComponent.release()
-
- replayGainProcessor.release()
- player.release()
- if (openAudioEffectSession) {
- // Make sure to close the audio session when we release the player.
- broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
- openAudioEffectSession = false
- }
-
- logD("Service destroyed")
- }
-
- // --- PLAYBACKSTATEHOLDER OVERRIDES ---
-
- override val progression: Progression
- get() =
- player.currentMediaItem?.let {
- Progression.from(
- player.playWhenReady,
- player.isPlaying,
- // The position value can be below zero or past the expected duration, make
- // sure we handle that.
- player.currentPosition.coerceAtLeast(0).coerceAtMost(it.song.durationMs))
- }
- ?: Progression.nil()
-
- override val repeatMode
- get() =
- when (val repeatMode = player.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")
- }
-
- override var parent: MusicParent? = null
-
- override fun resolveQueue(): RawQueue {
- val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it).song }
- val shuffledMapping =
- if (player.shuffleModeEnabled) {
- player.unscrambleQueueIndices()
- } else {
- emptyList()
- }
- return RawQueue(heap, shuffledMapping, player.currentMediaItemIndex)
- }
-
- override val audioSessionId: Int
- get() = player.audioSessionId
-
- override fun newPlayback(
- queue: List,
- start: Song?,
- parent: MusicParent?,
- shuffled: Boolean
- ) {
- this.parent = parent
- player.shuffleModeEnabled = shuffled
- player.setMediaItems(queue.map { it.toMediaItem() })
- val startIndex =
- start
- ?.let { queue.indexOf(start) }
- .also { check(it != -1) { "Start song not in queue" } }
- if (shuffled) {
- player.setShuffleOrder(BetterShuffleOrder(queue.size, startIndex ?: -1))
- }
- val target =
- startIndex ?: player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled)
- player.seekTo(target, C.TIME_UNSET)
- player.prepare()
- player.play()
- playbackManager.ack(this, StateAck.NewPlayback)
- deferSave()
- }
-
- override fun playing(playing: Boolean) {
- player.playWhenReady = playing
- // Dispatched later once all of the changes have been accumulated
- // Playing state is not persisted, do not need to save
- }
-
- override fun repeatMode(repeatMode: RepeatMode) {
- player.repeatMode =
- when (repeatMode) {
- RepeatMode.NONE -> Player.REPEAT_MODE_OFF
- RepeatMode.ALL -> Player.REPEAT_MODE_ALL
- RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
- }
- playbackManager.ack(this, StateAck.RepeatModeChanged)
- updatePauseOnRepeat()
- deferSave()
- }
-
- override fun seekTo(positionMs: Long) {
- player.seekTo(positionMs)
- // Dispatched later once all of the changes have been accumulated
- // Deferred save is handled on position discontinuity
- }
-
- override fun next() {
- // Replicate the old pseudo-circular queue behavior when no repeat option is implemented.
- // Basically, you can't skip back and wrap around the queue, but you can skip forward and
- // wrap around the queue, albeit playback will be paused.
- if (player.repeatMode != Player.REPEAT_MODE_OFF || player.hasNextMediaItem()) {
- player.seekToNext()
- if (!playbackSettings.rememberPause) {
- player.play()
- }
- } else {
- goto(0)
- // TODO: Dislike the UX implications of this, I feel should I bite the bullet
- // and switch to dynamic skip enable/disable?
- if (!playbackSettings.rememberPause) {
- player.pause()
- }
- }
- playbackManager.ack(this, StateAck.IndexMoved)
- // Deferred save is handled on position discontinuity
- }
-
- override fun prev() {
- if (playbackSettings.rewindWithPrev) {
- player.seekToPrevious()
- } else {
- player.seekToPreviousMediaItem()
- }
- if (!playbackSettings.rememberPause) {
- player.play()
- }
- playbackManager.ack(this, StateAck.IndexMoved)
- // Deferred save is handled on position discontinuity
- }
-
- override fun goto(index: Int) {
- val indices = player.unscrambleQueueIndices()
- if (indices.isEmpty()) {
- return
- }
-
- val trueIndex = indices[index]
- player.seekTo(trueIndex, C.TIME_UNSET)
- if (!playbackSettings.rememberPause) {
- player.play()
- }
- playbackManager.ack(this, StateAck.IndexMoved)
- // Deferred save is handled on position discontinuity
- }
-
- override fun shuffled(shuffled: Boolean) {
- logD("Reordering queue to $shuffled")
- player.shuffleModeEnabled = shuffled
- if (shuffled) {
- // Have to manually refresh the shuffle seed and anchor it to the new current songs
- player.setShuffleOrder(
- BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex))
- }
- playbackManager.ack(this, StateAck.QueueReordered)
- deferSave()
- }
-
- override fun playNext(songs: List, ack: StateAck.PlayNext) {
- val currTimeline = player.currentTimeline
- val nextIndex =
- if (currTimeline.isEmpty) {
- C.INDEX_UNSET
- } else {
- currTimeline.getNextWindowIndex(
- player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled)
- }
-
- if (nextIndex == C.INDEX_UNSET) {
- player.addMediaItems(songs.map { it.toMediaItem() })
- } else {
- player.addMediaItems(nextIndex, songs.map { it.toMediaItem() })
- }
-
- playbackManager.ack(this, ack)
- deferSave()
- }
-
- override fun addToQueue(songs: List, ack: StateAck.AddToQueue) {
- player.addMediaItems(songs.map { it.toMediaItem() })
- playbackManager.ack(this, ack)
- deferSave()
- }
-
- override fun move(from: Int, to: Int, ack: StateAck.Move) {
- val indices = player.unscrambleQueueIndices()
- if (indices.isEmpty()) {
- return
- }
-
- val trueFrom = indices[from]
- val trueTo = indices[to]
-
- when {
- trueFrom > trueTo -> {
- player.moveMediaItem(trueFrom, trueTo)
- player.moveMediaItem(trueTo + 1, trueFrom)
- }
- trueTo > trueFrom -> {
- player.moveMediaItem(trueFrom, trueTo)
- player.moveMediaItem(trueTo - 1, trueFrom)
- }
- }
- playbackManager.ack(this, ack)
- deferSave()
- }
-
- override fun remove(at: Int, ack: StateAck.Remove) {
- val indices = player.unscrambleQueueIndices()
- if (indices.isEmpty()) {
- return
- }
-
- val trueIndex = indices[at]
- val songWillChange = player.currentMediaItemIndex == trueIndex
- player.removeMediaItem(trueIndex)
- if (songWillChange && !playbackSettings.rememberPause) {
- player.play()
- }
- playbackManager.ack(this, ack)
- deferSave()
- }
-
- override fun handleDeferred(action: DeferredPlayback): Boolean {
- val deviceLibrary =
- musicRepository.deviceLibrary
- // No library, cannot do anything.
- ?: return false
-
- when (action) {
- // Restore state -> Start a new restoreState job
- is DeferredPlayback.RestoreState -> {
- logD("Restoring playback state")
- restoreScope.launch {
- persistenceRepository.readState()?.let {
- // 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) }
- }
- }
- }
- // Shuffle all -> Start new playback from all songs
- is DeferredPlayback.ShuffleAll -> {
- logD("Shuffling all tracks")
- playbackManager.play(
- null, null, listSettings.songSort.songs(deviceLibrary.songs), true)
- }
- // Open -> Try to find the Song for the given file and then play it from all songs
- is DeferredPlayback.Open -> {
- logD("Opening specified file")
- deviceLibrary.findSongForUri(application, action.uri)?.let { song ->
- playbackManager.play(
- song,
- null,
- listSettings.songSort.songs(deviceLibrary.songs),
- player.shuffleModeEnabled && playbackSettings.keepShuffle)
- }
- }
- }
-
- return true
- }
-
- override fun applySavedState(
- parent: MusicParent?,
- rawQueue: RawQueue,
- ack: StateAck.NewPlayback?
- ) {
- this.parent = parent
- player.setMediaItems(rawQueue.heap.map { it.toMediaItem() })
- if (rawQueue.isShuffled) {
- player.shuffleModeEnabled = true
- player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
- } else {
- player.shuffleModeEnabled = false
- }
- player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
- player.prepare()
- ack?.let { playbackManager.ack(this, it) }
- }
-
- override fun reset(ack: StateAck.NewPlayback) {
- player.setMediaItems(emptyList())
- playbackManager.ack(this, ack)
- }
-
- // --- PLAYER OVERRIDES ---
-
- override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
- super.onPlayWhenReadyChanged(playWhenReady, reason)
-
- if (player.playWhenReady) {
- // Mark that we have started playing so that the notification can now be posted.
- hasPlayed = true
- logD("Player has started playing")
- if (!openAudioEffectSession) {
- // Convention to start an audioeffect session on play/pause rather than
- // start/stop
- logD("Opening audio effect session")
- broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
- openAudioEffectSession = true
- }
- } else if (openAudioEffectSession) {
- // Make sure to close the audio session when we stop playback.
- logD("Closing audio effect session")
- broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
- openAudioEffectSession = false
- }
- }
-
- override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
- super.onMediaItemTransition(mediaItem, reason)
-
- if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
- reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) {
- playbackManager.ack(this, StateAck.IndexMoved)
- }
- }
-
- override fun onPlaybackStateChanged(playbackState: Int) {
- super.onPlaybackStateChanged(playbackState)
-
- if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) {
- goto(0)
- player.pause()
- }
- }
-
- override fun onPositionDiscontinuity(
- oldPosition: Player.PositionInfo,
- newPosition: Player.PositionInfo,
- reason: Int
- ) {
- super.onPositionDiscontinuity(oldPosition, newPosition, reason)
- if (reason == Player.DISCONTINUITY_REASON_SEEK) {
- // TODO: Once position also naturally drifts by some threshold, save
- deferSave()
- }
- }
-
- override fun onEvents(player: Player, events: Player.Events) {
- super.onEvents(player, events)
-
- if (events.containsAny(
- Player.EVENT_PLAY_WHEN_READY_CHANGED,
- Player.EVENT_IS_PLAYING_CHANGED,
- Player.EVENT_POSITION_DISCONTINUITY)) {
- logD("Player state changed, must synchronize state")
- playbackManager.ack(this, StateAck.ProgressionChanged)
- }
- }
-
- override fun onPlayerError(error: PlaybackException) {
- // TODO: Replace with no skipping and a notification instead
- // If there's any issue, just go to the next song.
- logE("Player error occured")
- logE(error.stackTraceToString())
- playbackManager.next()
- }
-
- // --- OTHER OVERRIDES ---
-
- override fun onPauseOnRepeatChanged() {
- updatePauseOnRepeat()
- }
-
- override fun onMusicChanges(changes: MusicRepository.Changes) {
- if (changes.deviceLibrary && musicRepository.deviceLibrary != null) {
- // We now have a library, see if we have anything we need to do.
- logD("Library obtained, requesting action")
- playbackManager.requestAction(this)
- }
- }
-
- override fun onPostNotification(notification: NotificationComponent) {
- // Do not post the notification if playback hasn't started yet. This prevents errors
- // where changing a setting would cause the notification to appear in an unfriendly
- // manner.
- if (hasPlayed) {
- logD("Played before, starting foreground state")
- if (!foregroundManager.tryStartForeground(notification)) {
- logD("Notification changed, re-posting")
- notification.post()
- }
- }
- }
-
- // --- PLAYER MANAGEMENT ---
-
- private fun updatePauseOnRepeat() {
- player.pauseAtEndOfMediaItems =
- playbackManager.repeatMode == RepeatMode.TRACK && playbackSettings.pauseOnRepeat
- }
-
- private fun ExoPlayer.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
- }
-
- private fun Song.toMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build()
-
- private val MediaItem.song: Song
- get() = requireNotNull(localConfiguration).tag as Song
-
- // --- OTHER FUNCTIONS ---
-
- private fun deferSave() {
- saveJob {
- logD("Waiting for save buffer")
- delay(SAVE_BUFFER)
- yield()
- logD("Committing saved state")
- persistenceRepository.saveState(playbackManager.toSavedState())
- }
- }
-
- private fun saveJob(block: suspend () -> Unit) {
- currentSaveJob?.let {
- logD("Discarding prior save job")
- it.cancel()
- }
- currentSaveJob = saveScope.launch { block() }
- }
-
- private fun broadcastAudioEffectAction(event: String) {
- logD("Broadcasting AudioEffect event: $event")
- sendBroadcast(
- Intent(event)
- .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
- .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
- .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
- }
-
- private fun endSession() {
- // This session has ended, so we need to reset this flag for when the next
- // session starts.
- saveJob {
- logD("Committing saved state")
- persistenceRepository.saveState(playbackManager.toSavedState())
- withContext(Dispatchers.Main) {
- // User could feasibly start playing again if they were fast enough, so
- // we need to avoid stopping the foreground state if that's the case.
- if (!player.isPlaying) {
- hasPlayed = false
- playbackManager.playing(false)
- foregroundManager.tryStopForeground()
- }
- }
- }
- }
-
- /**
- * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require
- * an active [IntentFilter] to be registered.
- */
- private inner class PlaybackReceiver : BroadcastReceiver() {
- private var initialHeadsetPlugEventHandled = false
-
- 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 ---
- ACTION_PLAY_PAUSE -> {
- logD("Received play event")
- playbackManager.playing(!playbackManager.progression.isPlaying)
- }
- ACTION_INC_REPEAT_MODE -> {
- logD("Received repeat mode event")
- playbackManager.repeatMode(playbackManager.repeatMode.increment())
- }
- ACTION_INVERT_SHUFFLE -> {
- logD("Received shuffle event")
- playbackManager.shuffled(!playbackManager.isShuffled)
- }
- ACTION_SKIP_PREV -> {
- logD("Received skip previous event")
- playbackManager.prev()
- }
- ACTION_SKIP_NEXT -> {
- logD("Received skip next event")
- playbackManager.next()
- }
- ACTION_EXIT -> {
- logD("Received exit event")
- playbackManager.playing(false)
- endSession()
- }
- WidgetProvider.ACTION_WIDGET_UPDATE -> {
- logD("Received widget update event")
- widgetComponent.update()
- }
- }
- }
-
- private fun playFromHeadsetPlug() {
- // ACTION_HEADSET_PLUG will fire when this BroadcastReciever 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)
- }
- }
- }
-
- companion object {
- const val SAVE_BUFFER = 5000L
- const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
- const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
- const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
- const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
- const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
- const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
- }
-}
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 0e0944961..7853bcca3 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt
@@ -56,11 +56,11 @@ interface SearchEngine {
* @param playlists A list of [Playlist], null if empty.
*/
data class Items(
- val songs: Collection?,
- val albums: Collection?,
- val artists: Collection?,
- val genres: Collection?,
- val playlists: Collection?
+ val songs: Collection? = null,
+ val albums: Collection? = null,
+ val artists: Collection? = null,
+ val genres: Collection? = null,
+ val playlists: Collection? = null
)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/service/ForegroundManager.kt b/app/src/main/java/org/oxycblt/auxio/service/ForegroundManager.kt
deleted file mode 100644
index b23457d48..000000000
--- a/app/src/main/java/org/oxycblt/auxio/service/ForegroundManager.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright (c) 2022 Auxio Project
- * ForegroundManager.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.service
-
-import android.app.Service
-import androidx.core.app.ServiceCompat
-import org.oxycblt.auxio.util.logD
-
-/**
- * A utility to create consistent foreground behavior for a given [Service].
- *
- * @param service [Service] to wrap in this instance.
- * @author Alexander Capehart (OxygenCobalt)
- *
- * TODO: Merge with unified service when done.
- */
-class ForegroundManager(private val service: Service) {
- private var isForeground = false
-
- /** Release this instance. */
- fun release() {
- tryStopForeground()
- }
-
- /**
- * Try to enter a foreground state.
- *
- * @param notification The [ForegroundServiceNotification] to show in order to signal the
- * foreground state.
- * @return true if the state was changed, false otherwise
- * @see Service.startForeground
- */
- fun tryStartForeground(notification: ForegroundServiceNotification): Boolean {
- if (isForeground) {
- // Nothing to do.
- return false
- }
-
- logD("Starting foreground state")
- service.startForeground(notification.code, notification.build())
- isForeground = true
- return true
- }
-
- /**
- * Try to exit a foreground state. Will remove the foreground notification.
- *
- * @return true if the state was changed, false otherwise
- * @see Service.stopForeground
- */
- fun tryStopForeground(): Boolean {
- if (!isForeground) {
- // Nothing to do.
- return false
- }
-
- logD("Stopping foreground state")
- ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE)
- isForeground = false
- return true
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt b/app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt
deleted file mode 100644
index 7bcd6118a..000000000
--- a/app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright (c) 2022 Auxio Project
- * ForegroundServiceNotification.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.service
-
-import android.content.Context
-import androidx.annotation.StringRes
-import androidx.core.app.NotificationChannelCompat
-import androidx.core.app.NotificationCompat
-import androidx.core.app.NotificationManagerCompat
-
-/**
- * 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
-
- /** Post this notification using [NotificationManagerCompat]. */
- fun post() {
- // This is safe to call without the POST_NOTIFICATIONS permission, as it's a foreground
- // notification.
- @Suppress("MissingPermission") notificationManager.notify(code, build())
- }
-
- /**
- * 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/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
index 2ece33656..a80bc446d 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
@@ -68,9 +68,6 @@ class AboutFragment : ViewBindingFragment() {
binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) }
binding.aboutProfile.setOnClickListener { requireContext().openInBrowser(LINK_PROFILE) }
binding.aboutDonate.setOnClickListener { requireContext().openInBrowser(LINK_DONATE) }
- binding.aboutSupporterYrliet.setOnClickListener {
- requireContext().openInBrowser(LINK_YRLIET)
- }
binding.aboutSupportersPromo.setOnClickListener {
requireContext().openInBrowser(LINK_DONATE)
}
@@ -100,6 +97,5 @@ class AboutFragment : ViewBindingFragment() {
const val LINK_LICENSES = "$LINK_WIKI/Licenses"
const val LINK_PROFILE = "https://github.com/OxygenCobalt"
const val LINK_DONATE = "https://github.com/sponsors/OxygenCobalt"
- const val LINK_YRLIET = "https://github.com/yrliet"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt
index d723dd5e3..71927c70c 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
+import org.oxycblt.auxio.BuildConfig
/**
* A wrapper around [StateFlow] exposing a one-time consumable event.
@@ -166,7 +167,13 @@ suspend fun SendChannel.sendWithTimeout(element: E, timeout: Long = DEFAU
try {
withTimeout(timeout) { send(element) }
} catch (e: TimeoutCancellationException) {
- throw TimeoutException("Timed out sending element $element to channel: $e")
+ logE("Failed to send element to channel $e in ${timeout}ms.")
+ if (BuildConfig.DEBUG) {
+ throw TimeoutException("Timed out sending element to channel: $e")
+ } else {
+ logE(e.stackTraceToString())
+ send(element)
+ }
}
}
@@ -203,7 +210,13 @@ suspend fun ReceiveChannel.forEachWithTimeout(
subsequent = true
}
} catch (e: TimeoutCancellationException) {
- throw TimeoutException("Timed out receiving element from channel: $e")
+ logE("Failed to send element to channel $e in ${timeout}ms.")
+ if (BuildConfig.DEBUG) {
+ throw TimeoutException("Timed out sending element to channel: $e")
+ } else {
+ logE(e.stackTraceToString())
+ handler()
+ }
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
index 3fb28172d..a13cd9895 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
@@ -31,8 +31,8 @@ import android.widget.RemoteViews
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.resolveNames
+import org.oxycblt.auxio.playback.service.PlaybackActions
import org.oxycblt.auxio.playback.state.RepeatMode
-import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@@ -339,7 +339,7 @@ class WidgetProvider : AppWidgetProvider() {
// by PlaybackService.
setOnClickPendingIntent(
R.id.widget_play_pause,
- context.newBroadcastPendingIntent(PlaybackService.ACTION_PLAY_PAUSE))
+ context.newBroadcastPendingIntent(PlaybackActions.ACTION_PLAY_PAUSE))
// Set up the play/pause button appearance. Like the Android 13 media controls, use
// a circular FAB when paused, and a squircle FAB when playing. This does require us
@@ -380,10 +380,10 @@ class WidgetProvider : AppWidgetProvider() {
// by PlaybackService.
setOnClickPendingIntent(
R.id.widget_skip_prev,
- context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_PREV))
+ context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_PREV))
setOnClickPendingIntent(
R.id.widget_skip_next,
- context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_NEXT))
+ context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_NEXT))
return this
}
@@ -405,10 +405,10 @@ class WidgetProvider : AppWidgetProvider() {
// be recognized by PlaybackService.
setOnClickPendingIntent(
R.id.widget_repeat,
- context.newBroadcastPendingIntent(PlaybackService.ACTION_INC_REPEAT_MODE))
+ context.newBroadcastPendingIntent(PlaybackActions.ACTION_INC_REPEAT_MODE))
setOnClickPendingIntent(
R.id.widget_shuffle,
- context.newBroadcastPendingIntent(PlaybackService.ACTION_INVERT_SHUFFLE))
+ context.newBroadcastPendingIntent(PlaybackActions.ACTION_INVERT_SHUFFLE))
// Set up the repeat/shuffle buttons. When working with RemoteViews, we will
// need to hard-code different accent tinting configurations, as stateful drawables
diff --git a/app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png
new file mode 100644
index 000000000..c25d1465b
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png
new file mode 100644
index 000000000..7a4ec1d24
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_genre_bitmap_24.png
new file mode 100644
index 000000000..0002c9c87
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_genre_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_playlist_bitmap_24.png
new file mode 100644
index 000000000..844eabebf
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png
new file mode 100644
index 000000000..9eb67934b
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png
new file mode 100644
index 000000000..1047e230c
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_artist_bitmap_24.png
new file mode 100644
index 000000000..3f3936e36
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_artist_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png
new file mode 100644
index 000000000..d5034c0d8
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_playlist_bitmap_24.png
new file mode 100644
index 000000000..931d33f30
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_song_bitmap_24.png
new file mode 100644
index 000000000..cbaa75491
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_song_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-nodpi/ui_widget_preview.png b/app/src/main/res/drawable-nodpi/ui_widget_preview.png
deleted file mode 100644
index a46297336..000000000
Binary files a/app/src/main/res/drawable-nodpi/ui_widget_preview.png and /dev/null differ
diff --git a/app/src/main/res/drawable-nodpi/ui_widget_preview.webp b/app/src/main/res/drawable-nodpi/ui_widget_preview.webp
new file mode 100644
index 000000000..5b801e3f3
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ui_widget_preview.webp differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_album_bitmap_24.png
new file mode 100644
index 000000000..f1ea82745
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_album_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png
new file mode 100644
index 000000000..880afde64
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png
new file mode 100644
index 000000000..7ac11d34d
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png
new file mode 100644
index 000000000..d88b412c9
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_song_bitmap_24.png
new file mode 100644
index 000000000..5a4c995f6
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_song_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png
new file mode 100644
index 000000000..68d136f59
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_artist_bitmap_24.png
new file mode 100644
index 000000000..5481634b0
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_artist_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_genre_bitmap_24.png
new file mode 100644
index 000000000..e7b7ff04c
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_genre_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_bitmap_24.png
new file mode 100644
index 000000000..1ac463441
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_song_bitmap_24.png
new file mode 100644
index 000000000..55709e1e2
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_song_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_album_bitmap_24.png
new file mode 100644
index 000000000..08f1eae7c
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_album_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png
new file mode 100644
index 000000000..e515a4134
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_genre_bitmap_24.png
new file mode 100644
index 000000000..f211f6b60
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_genre_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png
new file mode 100644
index 000000000..b0a08a8b3
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png
new file mode 100644
index 000000000..c0869bc1d
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png differ
diff --git a/app/src/main/res/drawable/ui_selection_bg.xml b/app/src/main/res/drawable/ui_selection_bg.xml
new file mode 100644
index 000000000..45a1fd8a1
--- /dev/null
+++ b/app/src/main/res/drawable/ui_selection_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml
index 06211fab4..787fcb546 100644
--- a/app/src/main/res/layout/fragment_about.xml
+++ b/app/src/main/res/layout/fragment_about.xml
@@ -224,18 +224,6 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
-
-
+ android:background="@drawable/ui_selection_bg">
Коска (,)
Плюс (+)
Амперсанд (&)
- Уключэнні
+ Ўключыць
Афармленне
Паўтарыць
Пошук у вашай бібліятэцы…
@@ -27,7 +27,7 @@
Выдаць
Песні
Змяніце тэму і колеры праграмы
- Усе песні
+ Ўсе песні
Альбомы
Альбом
Жывы альбом
@@ -58,7 +58,7 @@
Жанр
Пошук
Фільтр
- Усе
+ Ўсе
Назва
Жанры
Сартаваць
@@ -84,7 +84,7 @@
Перайсці да альбома
Перайсці да выканаўцы
Праглядзіце ўласцівасці
- Уласцівасці песні
+ Ўласцівасці песні
Фармат
Перамяшаць усё
Бітрэйт
@@ -121,8 +121,8 @@
Тэчкі
Рэжым
Выключэнні
- Музыка не будзе загружана з папак, якія вы дадасце.
- Музыка будзе загружацца толькі з папак, якія вы дадасце.
+ Музыка не будзе загружана з выбраных тэчак.
+ Музыка будзе загружана толькі з выбраных тэчак.
Перасканаваць музыку
Абнавіць музыку
Перазагрузіце музычную бібліятэку, выкарыстоўваючы па магчымасці кэшаваныя тэгі
@@ -148,7 +148,7 @@
Перайсці да апошняй песні
Змяніць рэжым паўтору
Значок Auxio
- Уключыце або выключыце перамешванне
+ Ўключыце або выключыце перамешванне
Выдаліць гэтую песню з чаргі
Перамяшаць усе песні
Спыніць прайграванне
@@ -221,11 +221,11 @@
Прасунуты аўдыё кодэк (AAC)
%1$s, %2$s
Свабодны аўдыё кодэк без страты якасці (FLAC)
- Уключыць не-музыку
- Уключыць закругленыя вуглы на дадатковых элементах інтэрфейсу (патрабуецца закругленне вокладак альбомаў)
+ Выключыць іншыя гукавыя файлы
+ Ўключыць закругленыя вуглы на дадатковых элементах інтэрфейсу (патрабуецца закругленне вокладак альбомаў)
Наладзьце элементы кіравання і паводзіны карыстацкага інтэрфейсу
Экран
- Укладкі бібліятэкі
+ Ўкладкі бібліятэкі
Змяніць бачнасць і парадак укладак бібліятэкі
Перайсці да наступнага
Рэжым паўтору
@@ -240,7 +240,7 @@
Гуляць ад выканаўцы
Гуляць з жанру
Запамінаць перамешванне
- Уключайце перамешванне падчас прайгравання новай песні
+ Ўключайце перамешванне падчас прайгравання новай песні
Кантэнт
Музыка
Аўтаматычная перазагрузка
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 8ce66a7bb..4f1f2bee1 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -232,7 +232,7 @@
Schrägstrich (/)
Plus (+)
Vom Künstler abspielen
- Achtung: Verwenden dieser Einstellung könnte dazu führen, dass einige Tags fälschlicherweise interpretiert werden, als hätten sie mehrere Werte. Das kann gelöst werden, in dem vor ungewollte Trenner ein Backslash (\\) eingefügt wird.
+ Achtung: Verwenden dieser Einstellung könnte dazu führen, dass einige Tags fälschlicherweise interpretiert werden, als hätten sie mehrere Werte. Das kann gelöst werden, indem vor ungewollte Trenner ein Backslash (\\) eingefügt wird.
Nicht-Musik ausschließen
Audio-Dateien, die keine Musik sind (wie Podcasts), ignorieren
Album-Cover
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 89a526160..63f4df9b6 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -299,4 +299,5 @@
ReplayGain-albumisäätö
Soittolistan tuonti tästä tiedostosta ei onnistu
Soittolistan vienti tähän tiedostoon ei onnistu
+ Valintakuva
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 9fd021428..839ea4dce 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -127,7 +127,7 @@
Genre
Égaliseur
Lecture aléatoire de tous les titres
- Auxio icône
+ Icône Auxio
Couverture de l\'album
Genre inconnu
Dynamique
@@ -142,7 +142,7 @@
Ce dossier n\'est pas pris en charge
Réinitialiser
Ogg audio
- Violet Claire
+ Violet foncé
Audio MPEG-1
Échec du chargement de la musique
Wiki
@@ -166,10 +166,10 @@
Esperluette (&)
Playlist
Lors de la lecture à partir des détails de l\'élément
- Gardez la lecture aléatoire lors de la lecture d\'une nouvelle chanson
+ Garder la lecture aléatoire lors de la lecture d\'une nouvelle chanson
Lire à partir de l\'élément affiché
N\'oubliez pas de mélanger
- Contrôlez le chargement de la musique et des images
+ Contrôler le chargement de la musique et des images
Musique
Images
Qualité améliorée (chargement lent)
@@ -195,7 +195,7 @@
Lire depuis l\'album
Barre oblique (/)
Plus (+)
- Vider l\'état de lecture précédemment enregistré (si il existe)
+ Vider l\'état de lecture précédemment enregistré (s\'il existe)
Ajustement avec étiquettes
Dossiers de musique
Gérer d\'où la musique doit être chargée
@@ -223,7 +223,7 @@
Scanner à nouveau la musique
Ajustement sans étiquettes
Enregistrer l\'état de lecture actuel maintenant
- Rétablir l\'état de lecture enregistré précédemment (si il existe)
+ Rétablir l\'état de lecture enregistré précédemment (s\'il existe)
Volume normalisé
Le préampli est appliqué à l\'ajustement actuel durant la lecture
Enregistrer l\'état de lecture
@@ -298,7 +298,7 @@
Chanson
Voir
Jouer la chanson par elle-même
- Image sélectionnée
+ Image de sélection
Trier par
Direction
Sélection
@@ -309,4 +309,27 @@
Aucun album
Démo
Démos
+ Liste de lecture importée
+ Liste de lecture exportée
+ Ajustement piste ReplayGain
+ Ajustement album ReplayGain
+ Impossible d\'exporter la liste de lecture dans ce fichier
+ Auteur
+ Impossible d’importer une liste de lecture depuis ce fichier
+ Liste de lecture vide
+ Importer la liste de lecture
+ Chemin
+ Faire un don
+ Liste de lecture importée
+ Soutiens
+ Faites un don au projet pour que votre nom soit ajouté ici !
+ Se souvenir de la pause
+ Rester en lecture ou en pause en sautant ou en modifiant la file d’attente
+ Exporter
+ Exporter la liste de lecture
+ Importer
+ Style de chemin
+ Absolu
+ Relatif
+ Utiliser les chemins compatibles Windows
\ No newline at end of file
diff --git a/app/src/main/res/values-ia/strings.xml b/app/src/main/res/values-ia/strings.xml
index c7aec119c..79cd80ab4 100644
--- a/app/src/main/res/values-ia/strings.xml
+++ b/app/src/main/res/values-ia/strings.xml
@@ -76,4 +76,130 @@
Usar percursos compatibile con Windows
Stato salveguardate
A proposito de
+ Un reproductor de musica simple e rational pro Android.
+ Genere
+ Generes
+ Cercar
+ Disco
+ Vader al album
+ Data de
+ Taxa de bits
+ Facer un donation
+ Selection
+ Information re le error
+ Signalar
+ Autor
+ Alexander Capehart
+ Vider e controlar le reproduction de musica
+ Addite al cauda
+ Lista de reproduction create
+ Lista de reproduction importate
+ Face un donation al projecto pro obtener tu nomine addite hic!
+ Addite al lista de reproduction
+ Cerca in le bibliotheca…
+ Parametros
+ Apparentia e comportamento
+ Cambiar le thema e colores del application
+ Thema
+ Schema de color
+ Automatic
+ Clar
+ Obscur
+ Thema nigre
+ Usar un thema nigre pur
+ Modo ronde
+ Personalisar
+ Personalisar le controlos e comportamento del interfacie de usator
+ Schermo
+ Reproducer ab le genere
+ Schedas del bibliotheca
+ Saltar al sequente
+ Comportamento
+ Al reproducer ab le bibliotheca
+ Al reproducer ab le detalios de elemento
+ Reproducer ab le elemento mostrate
+ Reproducer ab artista
+ Mantener le reproduction aleatori al reproducer un nove canto
+ Auxio besonia permission pro leger tu bibliotheca de musica
+ Genere incognite
+ Generes cargate: %d
+ Imagine de genere ab %s
+ Statisticas del bibliotheca
+ Bibliotheca
+ Stato radite
+ Copiate
+ Modo de repetition
+ Reproducer ab tote le cantos
+ Contento
+ Musica
+ Audio
+ Crear un nove lista de reproduction
+ Remover iste canto
+ Copertura de album
+ Coperrturas de album
+ Disactivate
+ Rapide
+ Reproduction
+ Rememorar le pausa
+ Dossieres de musica
+ Dossieres
+ Modo
+ Actualisar le musica
+ Salveguardar stato de reproduction
+ Restabilir le stato de reproduction
+ Nulle musica trovate
+ Falleva le carga del musica
+ Necun dossieres
+ Iste dossier non es supportate
+ Non poteva salveguardar le stato
+ Tracia %d
+ Reproducer o pausar
+ Saltar al canto sequente
+ Saltar al ultime canto
+ Cambiar modo de repetition
+ Stoppar le reproduction
+ Aperir le cauda
+ Remover le dossier
+ Icone de Auxio
+ Copertura de album pro %s
+ Artista incognite
+ Necun data
+ Necun disco
+ Necun tracia
+ Necun cantos
+ Necun albumes
+ Audio MPEG-4
+ Audio MPEG-1
+ Audio Ogg
+ Audio Matroska
+ Free Lossless Audio Codec (FLAC)
+ Advanced Audio Coding (AAC)
+ %d seligite
+ Disco %d
+
+ - %d album
+ - %d albumes
+
+
+ - %d artista
+ - %d artistas
+
+
+ - %d canto
+ - %d cantos
+
+ Non poteva rader le stato
+ Non poteva restaurar le stato
+ Cantos cargate: %d
+ Albumes cargate: %d
+ Artistas cargate: %d
+ Duration total: %s
+ Aleatori
+ Pausar in le repetition
+ Cargamento del musica
+ Lista de reproduction renominate
+ Lista de reproduction exportate
+ Lista de reproduction delite
+ Recargamento automatic
+ Imagines
\ No newline at end of file
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 53fc707cd..27372f238 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -41,7 +41,7 @@
Codice sorgente
Licenze
Sviluppato da Alexander Capehart
- Statistiche libreria
+ Statistiche della raccolta
Opzioni
Aspetto
@@ -301,4 +301,31 @@
Visualizza
Riproduci brano da solo
Ordina per
+ Playlist importata
+ Impossibile esportare la playlist in questo file
+ Direzione
+ Espandi
+ Immagine di selezione
+ Selezione
+ Copiato
+ Segnala
+ Autore
+ Dona
+ Supporti
+ Informazioni sull\'errore
+ Nessun album
+ Percorso
+ Playlist vuota
+ Importa playlist
+ Dona al progetto; il tuo nome sarà aggiunto qui!
+ Impossibile importare una playlist da questo file
+ Importa
+ Esporta
+ Esporta playlist
+ Stile percorso
+ Assoluto
+ Relativo
+ Usa percorsi compatibili con Windows
+ Playlist importata
+ Playlist esportata
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 9d7f7f28b..bae32c1d8 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -324,4 +324,10 @@
내보내기
재생 목록 내보내기
경로 스타일
+ 대기열을 건너뛰거나 편집할 때 일시 중지 상태 기억
+ 이 파일에서 재생 목록을 가져올 수 없습니다.
+ 이 파일로 재생 목록을 내보낼 수 없습니다.
+ ReplayGain 트랙 조절값
+ ReplayGain 앨범 조절값
+ 일시 중지 기억
\ No newline at end of file
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 56ac63584..4b659230c 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -223,7 +223,7 @@
-%.1f dB
%d kbps
Compilação ao vivo
- Compilações de remix
+ Compilação de remix
Mais (+)
E comercial (&)
Vírgula (,)
@@ -238,8 +238,8 @@
Desligado
Rápido
Alta qualidade
- Mixes
- Mix
+ Mixagens de DJ
+ Mixagem de DJ
- %d artista
- %d artistas
@@ -299,7 +299,7 @@
Visualizar
Playlist importada
Playlist exportada
- Incapaz de importar uma playlist deste arquivo
+ Não foi possível importar uma playlist deste arquivo
Incapaz de exportar a playlist para este arquivo
Demos
Autor
@@ -320,4 +320,13 @@
Relativo
Importar
Usar caminhos compatíveis com Windows
+ Ajuste de ReplayGain do álbum
+ Informação de erro
+ Forçar capas de álbuns quadradas
+ Sem disco
+ Sem álbuns
+ Ajuste de ReplayGain da faixa
+ Recorta todas as capas de álbuns para uma proporção de imagem 1:1
+ Sem músicas
+ Informar
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 80fd5c6b3..42fae303f 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -224,7 +224,7 @@
Эквалайзер
Скрыть соавторов
Показывать только тех исполнителей, которые напрямую указаны в альбоме
- Исключить не-музыку
+ Исключить другие звуковые файлы
Многозначные разделители
Косая черта (/)
Настройка символов, обозначающих несколько значений тегов
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index 42c64c068..3256b7dca 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -17,5 +17,4 @@
Microsoft WAVE
- yrliet
\ No newline at end of file
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 000000000..0a6a3c9fb
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index eae63966b..a81768d25 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,8 +1,8 @@
buildscript {
ext {
- kotlin_version = '1.9.10'
+ kotlin_version = '1.9.23'
navigation_version = "2.5.3"
- hilt_version = '2.47'
+ hilt_version = '2.51.1'
}
dependencies {
@@ -12,10 +12,10 @@ buildscript {
}
plugins {
- id "com.android.application" version '8.2.1' apply false
+ id "com.android.application" version '8.4.0' apply false
id "androidx.navigation.safeargs.kotlin" version "$navigation_version" apply false
id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false
- id "com.google.devtools.ksp" version '1.9.10-1.0.13' apply false
+ id "com.google.devtools.ksp" version '1.9.23-1.0.20' apply false
id "com.diffplug.spotless" version "6.20.0" apply false
}
diff --git a/fastlane/metadata/android/be/full_description.txt b/fastlane/metadata/android/be/full_description.txt
index dc3182282..d2a68f089 100644
--- a/fastlane/metadata/android/be/full_description.txt
+++ b/fastlane/metadata/android/be/full_description.txt
@@ -11,6 +11,7 @@ Auxio - гэта мясцовы музычны плэер з хуткім і н
- Удасканаленая сістэма выканаўцаў, якая аб'ядноўвае выканаўцаў і выканаўцаў альбомаў
- Кіраванне тэчкамі з улікам SD-карты
- Надзейнае захаванне стану прайгравання
+- Аўтаматычнае ўзнаўленне без разрываў
- Поўная падтрымка ReplayGain (для файлаў MP3, FLAC, OGG, OPUS і MP4)
- Падтрымка знешняга эквалайзера (напрыклад, Wavelet)
- Ад краю да краю
diff --git a/fastlane/metadata/android/cs/full_description.txt b/fastlane/metadata/android/cs/full_description.txt
index caf9e51ef..f47808edf 100644
--- a/fastlane/metadata/android/cs/full_description.txt
+++ b/fastlane/metadata/android/cs/full_description.txt
@@ -12,6 +12,7 @@ přesná/původní data, štítky pro řazení a další
- Správa složek podporující SD karty
- Spolehlivá funkce seznamů skladeb
- Uchovávání stavu přehrávání
+- Automatické přehrávání bez mezer
- Plná podpora ReplayGain (u souborů MP3, FLAC, OGG, OPUS a MP4)
- Podpora externích přehrávačů (např. Wavelet)
- Edge-to-edge
diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt
index 3eedd16a2..fc52bfe41 100644
--- a/fastlane/metadata/android/de/full_description.txt
+++ b/fastlane/metadata/android/de/full_description.txt
@@ -12,6 +12,7 @@ Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, ab
- untersützt SD-Karten
- verlässliche Wiedergabelisten-Verwaltung
- verlässliches Speichern des Wiedergabezustands
+- automatische lückenlose Wiedergabe
- Vollständiger ReplayGain-Support (für MP3-, FLAC-, OGG-, OPUS- und MP4-Dateien)
- Externer Equalizerunterstützung (z.B. Wavelet)
- Edge-to-Edge
diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt
index 67ee646c7..854a23c2f 100644
--- a/fastlane/metadata/android/es-ES/full_description.txt
+++ b/fastlane/metadata/android/es-ES/full_description.txt
@@ -11,6 +11,7 @@ fechas precisas/originales, ordenar etiquetas y más
- Sistema de artista avanzado que unifica artistas y artistas de álbumes
- Gestión de carpetas compatible con tarjetas SD
- Funcionalidad de lista de reproducción confiable
+- Reproducción automática sin pausas
- Persistencia del estado de reproducción
- Compatibilidad total con ReplayGain (en archivos MP3, FLAC, OGG, OPUS y MP4)
- Soporte de ecualizador externo (ej. Wavelet)
diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt
index 7567e8b1c..aeb864c64 100644
--- a/fastlane/metadata/android/fr-FR/full_description.txt
+++ b/fastlane/metadata/android/fr-FR/full_description.txt
@@ -1,10 +1,10 @@
-Auxio est un lecteur de musique local doté d'une UI/UX rapide et sûre, sans les fonctions inutiles de la plupart des autres lecteurs. Construit sur les bases d'une librairie moderne de lecture de media, Auxio supporte une libaririe et propose une qualité d'écoute supérieurs comparé aux autres applications qui utilisent des fonctionnalités d'android dépassées. Pour faire simple, il joue votre musique .
+Auxio est un lecteur de musique local doté d'une interface utilisateur rapide et sûre, sans les fonctions inutiles de la plupart des autres lecteurs. Construit sur les bases d'une bibliothèque moderne de lecture de médias, Auxio prend en charge une bibliothèque et propose une qualité d'écoute supérieurs comparé aux autres applications qui utilisent des fonctionnalités d'Android dépassées. Pour faire simple, il joue votre musique .
Fonctionnalités
- Lecture basée sur l'ExoPlayer Media3
-- UI réactive dérivée des dernières lignes directrices en Material Design
-- UX orientée qui mets l'accent sur la facilité d'utilisation plutôt que sur les usages
+- Interface réactive dérivée des dernières lignes directrices en Material Design
+- UX orientée qui met l'accent sur la facilité d'utilisation plutôt que sur les usages particuliers
- Comportement personnalisable
- Reconnaît les numéros de disque, les artistes multiples, les types de support,
les dates précises/originales, le classement par tags, and plus encore
diff --git a/fastlane/metadata/android/hi/full_description.txt b/fastlane/metadata/android/hi/full_description.txt
index 81b86358b..be3a02a95 100644
--- a/fastlane/metadata/android/hi/full_description.txt
+++ b/fastlane/metadata/android/hi/full_description.txt
@@ -12,6 +12,7 @@ Auxio एक तेज़, विश्वसनीय UI/UX वाला एक
- एसडी कार्ड-जागरूक फ़ोल्डर प्रबंधन
- विश्वसनीय प्लेलिस्टिंग कार्यक्षमता
- प्लेबैक अवस्था दृढ़ता
+- स्वचालित गैपलेस प्लेबैक
- पूर्ण रीप्लेगैन समर्थन (MP3, FLAC, OGG, OPUS और MP4 फ़ाइलों पर)
- बाहरी तुल्यकारक समर्थन (उदा: वेवलेट)
- एज-टू-एज
diff --git a/fastlane/metadata/android/ko/full_description.txt b/fastlane/metadata/android/ko/full_description.txt
index 387682b2c..45f7cc6ca 100644
--- a/fastlane/metadata/android/ko/full_description.txt
+++ b/fastlane/metadata/android/ko/full_description.txt
@@ -12,6 +12,7 @@ Auxio는 다른 음악 플레이어에 존재하는 쓸모없는 기능 없이,
- SD 카드를 지원하는 폴더 관리 기능
- 안정적인 재생 목록 기능
- 이전 재생 상태 기억
+- 자동 갭리스 재생 지원
- ReplayGain 완벽 지원 (MP3, FLAC, OGG, OPUS, MP4)
- 외부 이퀄라이저 지원 (Wavelet 등)
- Edge-to-edge
@@ -20,4 +21,4 @@ Auxio는 다른 음악 플레이어에 존재하는 쓸모없는 기능 없이,
- 헤드셋 연결 시 자동 재생
- 크기에 따라 자동으로 조정되는 세련된 위젯
- 인터넷 사용 없음
-- 둥근 앨범 커버 없음 (기본값)
+- 둥글지 않은 앨범 커버 (기본값)
diff --git a/fastlane/metadata/android/pa/full_description.txt b/fastlane/metadata/android/pa/full_description.txt
index c896207aa..646c41be8 100644
--- a/fastlane/metadata/android/pa/full_description.txt
+++ b/fastlane/metadata/android/pa/full_description.txt
@@ -12,6 +12,7 @@ Auxio ਇੱਕ ਤੇਜ਼, ਭਰੋਸੇਮੰਦ UI/UX ਵਾਲਾ ਇੱ
- ਭਰੋਸੇਯੋਗ ਪਲੇਅਲਿਸਟਿੰਗ ਕਾਰਜਕੁਸ਼ਲਤਾ
- ਭਰੋਸੇਯੋਗ ਪਲੇਅਬੈਕ ਸਥਿਤੀ ਸਥਿਰਤਾ
+- ਆਟੋਮੈਟਿਕ ਗੈਪਲੈੱਸ ਪਲੇਅਬੈਕ
- ਪੂਰਾ ਰੀਪਲੇਅ-ਗੇਨ ਸਮਰਥਨ (MP3, FLAC, OGG, OPUS, ਅਤੇ MP4 ਫਾਈਲਾਂ 'ਤੇ)
- ਬਾਹਰੀ ਈਕੋਲਾਈਜ਼ਰ ਦਾ ਸਮਰਥਨ (ਉਦਾਹਰਨ. ਵੇਵਲੇਟ)
- ਕਿਨਾਰੇ-ਤੋਂ-ਕਿਨਾਰੇ
diff --git a/fastlane/metadata/android/ru/full_description.txt b/fastlane/metadata/android/ru/full_description.txt
index 634f1e263..0220f53b7 100644
--- a/fastlane/metadata/android/ru/full_description.txt
+++ b/fastlane/metadata/android/ru/full_description.txt
@@ -11,6 +11,7 @@ Auxio — это локальный музыкальный плеер с быс
- Расширенная система исполнителей, объединяющая исполнителей и исполнителей альбомов
- Управление папками на SD-карте
- Надёжное сохранение состояния воспроизведения
+- Автоматическое воспроизведение без пауз
- Полная поддержка ReplayGain (в файлах MP3, FLAC, OGG, OPUS и MP4)
- Поддержка внешнего эквалайзера (например, Wavelet)
- Дизайн от края до края
diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt
index 890722b67..b2bf2642f 100644
--- a/fastlane/metadata/android/uk/full_description.txt
+++ b/fastlane/metadata/android/uk/full_description.txt
@@ -12,6 +12,7 @@ Auxio – це локальний музичний плеєр зі швидки
- Керування теками з підтримкою SD-картки
- Надійне функція списків відтворення
- Збереження стану відтворення
+- Автоматичне відтворення без пропусків
- Повна підтримка ReplayGain (у файлах MP3, FLAC, OGG, OPUS і MP4)
- Підтримка зовнішнього еквалайзера (наприклад, Wavelet)
- Дизайн від краю до краю
diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt
index 318031e5c..5eeab94cf 100644
--- a/fastlane/metadata/android/zh-CN/full_description.txt
+++ b/fastlane/metadata/android/zh-CN/full_description.txt
@@ -12,6 +12,7 @@ Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没
- 文件夹管理功能可以感知到 SD 卡
- 可靠的播放列表功能
- 回放状态持久化
+- 自动无缝回放
- 完整的回放增益支持(包括 MP3、FLAC、OGG、OPUS 和 MP4 文件)
- 支持外部均衡器(如 Wavelet 这样的应用)
- 边到边设计
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 880137945..43a6e163d 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=03ec176d388f2aa99defcadc3ac6adf8dd2bce5145a129659537c0874dea5ad1
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
+distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/media b/media
index e585deaa9..9fc2401b8 160000
--- a/media
+++ b/media
@@ -1 +1 @@
-Subproject commit e585deaa94cc679ab4fd0a653cc1bf67abb54b7e
+Subproject commit 9fc2401b8fdc2b23905402462e775c6db4e1527f