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