diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 61ec47e0c..9b7aae1cd 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -11,6 +11,8 @@ jobs: runs-on: ubuntu-latest steps: + - name: Install ninja-build + run: sudo apt-get install -y ninja-build - name: Clone repository uses: actions/checkout@v3 - name: Clone submodules diff --git a/CHANGELOG.md b/CHANGELOG.md index 83750a61f..90fdee88d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,42 @@ - Excessive CPU no longer spent showing music loading process - Fixed playback sheet flickering on warm start +## 3.6.0 + +#### What's New +- Added support for playback from google assistant + +#### What's Improved +- Home and detail UIs in Android Auto now reflect app sort settings +- Album view now shows discs in android auto + +#### What's Fixed +- Fixed playback briefly pausing when adding songs to playlist +- Fixed media lists in Android Auto being truncated in some cases +- Possibly fixed duplicated song items depending on album/all children +- Possibly fixed truncated tab lists in android auto + +#### Dev/Meta +- Moved to raw media session apis rather than media3 session + +## 3.5.3 + +#### What's New +- Basic Tasker integration for safely starting Auxio's service + +#### What's Improved +- Added support for informal singular-spaced tags like `album artist` in +file metadata + +#### What's Fixed +- Fix "Foreground not allowed" music loading crash from starting too early +- Fixed widget not loading on some devices due to the cover being too large + +## 3.5.2 + +#### What's Fixed +- Fixed music loading failure from improper sort systems (For real this time) + ## 3.5.1 #### What's Fixed diff --git a/README.md b/README.md index add156e6e..d23ad29a2 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index 384d008d6..f26c50e30 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,13 +16,13 @@ android { // it here so that binary stripping will work. // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the // NDK use is unified - ndkVersion = "25.2.9519653" + ndkVersion "26.3.11579264" namespace "org.oxycblt.auxio" defaultConfig { applicationId namespace - versionName "3.5.1" - versionCode 47 + versionName "3.6.0" + versionCode 50 minSdk 24 targetSdk 34 @@ -118,6 +118,9 @@ dependencies { // Media implementation "androidx.media:media:1.7.0" + // Android Auto + implementation "androidx.car.app:app:1.4.0" + // Preferences implementation "androidx.preference:preference-ktx:1.2.1" @@ -130,7 +133,6 @@ dependencies { // --- THIRD PARTY --- // Exoplayer (Vendored) - implementation project(":media-lib-session") implementation project(":media-lib-exoplayer") implementation project(":media-lib-decoder-ffmpeg") coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" @@ -155,6 +157,12 @@ dependencies { // Speed dial implementation "com.leinardi.android:speed-dial:3.3.0" + // Tasker integration + implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10' + + // Fuzzy search + implementation 'org.apache.commons:commons-text:1.9' + // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 960d2c8ae..308962b34 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -94,7 +94,6 @@ android:exported="true" android:roundIcon="@mipmap/ic_launcher"> - @@ -135,5 +134,15 @@ android:resource="@xml/widget_info" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index a8cb344cf..da79b8a11 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -19,92 +19,147 @@ package org.oxycblt.auxio import android.annotation.SuppressLint +import android.content.Context import android.content.Intent +import android.os.Bundle import android.os.IBinder +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaBrowserCompat.MediaItem +import androidx.annotation.StringRes +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat -import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaSession +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import org.oxycblt.auxio.music.service.IndexerServiceFragment -import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment +import org.oxycblt.auxio.music.service.MusicServiceFragment +import org.oxycblt.auxio.playback.service.PlaybackServiceFragment +import org.oxycblt.auxio.util.logD @AndroidEntryPoint -class AuxioService : MediaLibraryService(), ForegroundListener { - @Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment +class AuxioService : + MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { + @Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory + private lateinit var playbackFragment: PlaybackServiceFragment - @Inject lateinit var indexingFragment: IndexerServiceFragment + @Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory + private lateinit var musicFragment: MusicServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { super.onCreate() - mediaSessionFragment.attach(this, this) - indexingFragment.attach(this) - } - - override fun onBind(intent: Intent?): IBinder? { - start(intent) - return super.onBind(intent) + playbackFragment = playbackFragmentFactory.create(this, this) + sessionToken = playbackFragment.attach() + musicFragment = musicFragmentFactory.create(this, this, this) + musicFragment.attach() } 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. - start(intent) + onHandleForeground(intent) return super.onStartCommand(intent, flags, startId) } - private fun start(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() - } - indexingFragment.start() + override fun onBind(intent: Intent): IBinder? { + onHandleForeground(intent) + return super.onBind(intent) + } + + private fun onHandleForeground(intent: Intent?) { + val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1 + musicFragment.start() + playbackFragment.start(startId) } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) - mediaSessionFragment.handleTaskRemoved() + playbackFragment.handleTaskRemoved() } override fun onDestroy() { super.onDestroy() - indexingFragment.release() - mediaSessionFragment.release() + musicFragment.release() + playbackFragment.release() } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = - mediaSessionFragment.mediaSession + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot { + return musicFragment.getRoot() + } - override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - updateForeground(ForegroundListener.Change.MEDIA_SESSION) + override fun onLoadItem(itemId: String, result: Result) { + musicFragment.getItem(itemId, result) + } + + override fun onLoadChildren(parentId: String, result: Result>) { + val maximumRootChildLimit = getRootChildrenLimit() + musicFragment.getChildren(parentId, maximumRootChildLimit, result, null) + } + + override fun onLoadChildren( + parentId: String, + result: Result>, + options: Bundle + ) { + val maximumRootChildLimit = getRootChildrenLimit() + musicFragment.getChildren(parentId, maximumRootChildLimit, result, options.getPage()) + } + + override fun onSearch(query: String, extras: Bundle?, result: Result>) { + musicFragment.search(query, result, extras?.getPage()) + } + + private fun getRootChildrenLimit(): Int { + return browserRootHints?.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) + ?: 4 + } + + private fun Bundle.getPage(): MusicServiceFragment.Page? { + val page = getInt(MediaBrowserCompat.EXTRA_PAGE, -1).takeIf { it >= 0 } ?: return null + val pageSize = + getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null + return MusicServiceFragment.Page(page, pageSize) } override fun updateForeground(change: ForegroundListener.Change) { - if (mediaSessionFragment.hasNotification()) { + val mediaNotification = playbackFragment.notification + if (mediaNotification != null) { if (change == ForegroundListener.Change.MEDIA_SESSION) { - mediaSessionFragment.createNotification { - startForeground(it.notificationId, it.notification) - } + startForeground(mediaNotification.code, mediaNotification.build()) } // Nothing changed, but don't show anything music related since we can always // index during playback. } else { - indexingFragment.createNotification { + musicFragment.createNotification { if (it != null) { startForeground(it.code, it.build()) + isForeground = true } else { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + isForeground = false } } } } + override fun invalidateMusic(mediaId: String) { + logD(mediaId) + notifyChildrenChanged(mediaId) + } + companion object { + var isForeground = false + private set + // This is only meant for Auxio to internally ensure that it's state management will work. - const val INTENT_KEY_NATIVE_START = BuildConfig.APPLICATION_ID + ".service.NATIVE_START" + const val INTENT_KEY_START_ID = BuildConfig.APPLICATION_ID + ".service.START_ID" } } @@ -116,3 +171,42 @@ interface ForegroundListener { INDEXER } } + +/** + * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that + * signal a Service's ongoing foreground state. + * + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) : + NotificationCompat.Builder(context, info.id) { + private val notificationManager = NotificationManagerCompat.from(context) + + init { + // Set up the notification channel. Foreground notifications are non-substantial, and + // thus make no sense to have lights, vibration, or lead to a notification badge. + val channel = + NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(context.getString(info.nameRes)) + .setLightsEnabled(false) + .setVibrationEnabled(false) + .setShowBadge(false) + .build() + notificationManager.createNotificationChannel(channel) + } + + /** + * The code used to identify this notification. + * + * @see NotificationManagerCompat.notify + */ + abstract val code: Int + + /** + * Reduced representation of a [NotificationChannelCompat]. + * + * @param id The ID of the channel. + * @param nameRes A string resource ID corresponding to the human-readable name of this channel. + */ + data class ChannelInfo(val id: String, @StringRes val nameRes: Int) +} diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 271de0969..eb1a27d53 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -59,6 +59,10 @@ object IntegerTable { const val INDEXER_NOTIFICATION_CODE = 0xA0A1 /** MainActivity Intent request code */ const val REQUEST_CODE = 0xA0C0 + /** Activity AuxioService Start ID */ + const val START_ID_ACTIVITY = 0xA050 + /** Tasker AuxioService Start ID */ + const val START_ID_TASKER = 0xA051 /** RepeatMode.NONE */ const val REPEAT_MODE_NONE = 0xA100 /** RepeatMode.ALL */ diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index cf414c371..530f3f14f 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -71,11 +71,11 @@ class MainActivity : AppCompatActivity() { startService( Intent(this, AuxioService::class.java) - .putExtra(AuxioService.INTENT_KEY_NATIVE_START, true)) + .putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY)) if (!startIntentAction(intent)) { // No intent action to do, just restore the previously saved state. - playbackModel.playDeferred(DeferredPlayback.RestoreState) + playbackModel.playDeferred(DeferredPlayback.RestoreState(false)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt new file mode 100644 index 000000000..348badcdb --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2024 Auxio Project + * DetailGenerator.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.detail + +import androidx.annotation.StringRes +import javax.inject.Inject +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.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.music.info.ReleaseType +import org.oxycblt.auxio.util.logD + +interface DetailGenerator { + fun any(uid: Music.UID): Detail? + + fun album(uid: Music.UID): Detail? + + fun artist(uid: Music.UID): Detail? + + fun genre(uid: Music.UID): Detail? + + fun playlist(uid: Music.UID): Detail? + + fun attach() + + fun release() + + interface Factory { + fun create(invalidator: Invalidator): DetailGenerator + } + + interface Invalidator { + fun invalidate(type: MusicType, replace: Int?) + } +} + +class DetailGeneratorFactoryImpl +@Inject +constructor(private val listSettings: ListSettings, private val musicRepository: MusicRepository) : + DetailGenerator.Factory { + override fun create(invalidator: DetailGenerator.Invalidator): DetailGenerator = + DetailGeneratorImpl(invalidator, listSettings, musicRepository) +} + +private class DetailGeneratorImpl( + private val invalidator: DetailGenerator.Invalidator, + private val listSettings: ListSettings, + private val musicRepository: MusicRepository +) : DetailGenerator, MusicRepository.UpdateListener, ListSettings.Listener { + override fun attach() { + listSettings.registerListener(this) + musicRepository.addUpdateListener(this) + } + + override fun onAlbumSongSortChanged() { + super.onAlbumSongSortChanged() + invalidator.invalidate(MusicType.ALBUMS, -1) + } + + override fun onArtistSongSortChanged() { + super.onArtistSongSortChanged() + invalidator.invalidate(MusicType.ARTISTS, -1) + } + + override fun onGenreSongSortChanged() { + super.onGenreSongSortChanged() + invalidator.invalidate(MusicType.GENRES, -1) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.deviceLibrary) { + invalidator.invalidate(MusicType.ALBUMS, null) + invalidator.invalidate(MusicType.ARTISTS, null) + invalidator.invalidate(MusicType.GENRES, null) + } + if (changes.userLibrary) { + invalidator.invalidate(MusicType.PLAYLISTS, null) + } + } + + override fun release() { + listSettings.unregisterListener(this) + musicRepository.removeUpdateListener(this) + } + + override fun any(uid: Music.UID): Detail? { + val music = musicRepository.find(uid) ?: return null + return when (music) { + is Album -> album(uid) + is Artist -> artist(uid) + is Genre -> genre(uid) + is Playlist -> playlist(uid) + else -> null + } + } + + override fun album(uid: Music.UID): Detail? { + val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null + val songs = listSettings.albumSongSort.songs(album.songs) + val discs = songs.groupBy { it.disc } + val section = + if (discs.size > 1) { + DetailSection.Discs(discs) + } else { + DetailSection.Songs(songs) + } + return Detail(album, listOf(section)) + } + + override fun artist(uid: Music.UID): Detail? { + val artist = musicRepository.deviceLibrary?.findArtist(uid) ?: return null + val grouping = + artist.explicitAlbums.groupByTo(sortedMapOf()) { + // Remap the complicated ReleaseType data structure into detail sections + when (it.releaseType.refinement) { + ReleaseType.Refinement.LIVE -> DetailSection.Albums.Category.LIVE + ReleaseType.Refinement.REMIX -> DetailSection.Albums.Category.REMIXES + null -> + when (it.releaseType) { + is ReleaseType.Album -> DetailSection.Albums.Category.ALBUMS + is ReleaseType.EP -> DetailSection.Albums.Category.EPS + is ReleaseType.Single -> DetailSection.Albums.Category.SINGLES + is ReleaseType.Compilation -> DetailSection.Albums.Category.COMPILATIONS + is ReleaseType.Soundtrack -> DetailSection.Albums.Category.SOUNDTRACKS + is ReleaseType.Mix -> DetailSection.Albums.Category.DJ_MIXES + is ReleaseType.Mixtape -> DetailSection.Albums.Category.MIXTAPES + is ReleaseType.Demo -> DetailSection.Albums.Category.DEMOS + } + } + } + + if (artist.implicitAlbums.isNotEmpty()) { + // groupByTo normally returns a mapping to a MutableList mapping. Since MutableList + // inherits list, we can cast upwards and save a copy by directly inserting the + // implicit album list into the mapping. + logD("Implicit albums present, adding to list") + @Suppress("UNCHECKED_CAST") + (grouping as MutableMap>)[ + DetailSection.Albums.Category.APPEARANCES] = artist.implicitAlbums + } + + val sections = + grouping.mapTo(mutableListOf()) { (category, albums) -> + DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums)) + } + val songs = DetailSection.Songs(listSettings.artistSongSort.songs(artist.songs)) + sections.add(songs) + return Detail(artist, sections) + } + + override fun genre(uid: Music.UID): Detail? { + val genre = musicRepository.deviceLibrary?.findGenre(uid) ?: return null + val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists)) + val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs)) + return Detail(genre, listOf(artists, songs)) + } + + override fun playlist(uid: Music.UID): Detail? { + val playlist = musicRepository.userLibrary?.findPlaylist(uid) ?: return null + val songs = DetailSection.Songs(playlist.songs) + return Detail(playlist, listOf(songs)) + } + + private companion object { + val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) + val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + } +} + +data class Detail

(val parent: P, val sections: List) + +sealed interface DetailSection { + val order: Int + val stringRes: Int + + abstract class PlainSection : DetailSection { + abstract val items: List + } + + data class Artists(override val items: List) : PlainSection() { + override val order = 0 + override val stringRes = R.string.lbl_artists + } + + data class Albums(val category: Category, override val items: List) : + PlainSection() { + override val order = 1 + category.ordinal + override val stringRes = category.stringRes + + enum class Category(@StringRes val stringRes: Int) { + ALBUMS(R.string.lbl_albums), + EPS(R.string.lbl_eps), + SINGLES(R.string.lbl_singles), + COMPILATIONS(R.string.lbl_compilations), + SOUNDTRACKS(R.string.lbl_soundtracks), + DJ_MIXES(R.string.lbl_mixes), + MIXTAPES(R.string.lbl_mixtapes), + DEMOS(R.string.lbl_demos), + APPEARANCES(R.string.lbl_appears_on), + LIVE(R.string.lbl_live_group), + REMIXES(R.string.lbl_remix_group) + } + } + + data class Songs(override val items: List) : PlainSection() { + override val order = 12 + override val stringRes = R.string.lbl_songs + } + + data class Discs(val discs: Map>) : DetailSection { + override val order = 13 + override val stringRes = R.string.lbl_songs + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt new file mode 100644 index 000000000..2fde9b6a6 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 Auxio Project + * DetailModule.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.detail + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DetailModule { + @Binds fun detailGeneratorFactory(factory: DetailGeneratorFactoryImpl): DetailGenerator.Factory +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index d97b54b09..0fbaf22be 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.detail -import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -43,10 +42,11 @@ import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.metadata.AudioProperties import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaybackSettings @@ -69,8 +69,9 @@ constructor( private val listSettings: ListSettings, private val musicRepository: MusicRepository, private val audioPropertiesFactory: AudioProperties.Factory, - private val playbackSettings: PlaybackSettings -) : ViewModel(), MusicRepository.UpdateListener { + private val playbackSettings: PlaybackSettings, + detailGeneratorFactory: DetailGenerator.Factory +) : ViewModel(), DetailGenerator.Invalidator { private val _toShow = MutableEvent() /** * A [Show] command that is awaiting a view capable of responding to it. Null if none currently. @@ -133,13 +134,8 @@ constructor( get() = _artistSongInstructions /** The current [Sort] used for [Song]s in [artistSongList]. */ - var artistSongSort: Sort + val artistSongSort: Sort get() = listSettings.artistSongSort - set(value) { - listSettings.artistSongSort = value - // Refresh the artist list to reflect the new sort. - currentArtist.value?.let { refreshArtistList(it, true) } - } /** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */ val playInArtistWith @@ -162,13 +158,8 @@ constructor( get() = _genreSongInstructions /** The current [Sort] used for [Song]s in [genreSongList]. */ - var genreSongSort: Sort + val genreSongSort: Sort get() = listSettings.genreSongSort - set(value) { - listSettings.genreSongSort = value - // Refresh the genre list to reflect the new sort. - currentGenre.value?.let { refreshGenreList(it, true) } - } /** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */ val playInGenreWith @@ -204,54 +195,35 @@ constructor( playbackSettings.inParentPlaybackMode ?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value)) + private val detailGenerator = detailGeneratorFactory.create(this) + init { - musicRepository.addUpdateListener(this) + detailGenerator.attach() } override fun onCleared() { - musicRepository.removeUpdateListener(this) + detailGenerator.release() } - override fun onMusicChanges(changes: MusicRepository.Changes) { - // If we are showing any item right now, we will need to refresh it (and any information - // related to it) with the new library in order to prevent stale items from showing up - // in the UI. - val deviceLibrary = musicRepository.deviceLibrary - if (changes.deviceLibrary && deviceLibrary != null) { - val song = currentSong.value - if (song != null) { - _currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo) - logD("Updated song to ${currentSong.value}") + override fun invalidate(type: MusicType, replace: Int?) { + when (type) { + MusicType.ALBUMS -> { + val album = detailGenerator.album(currentAlbum.value?.uid ?: return) + refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace) } - - val album = currentAlbum.value - if (album != null) { - _currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList) - logD("Updated album to ${currentAlbum.value}") + MusicType.ARTISTS -> { + val artist = detailGenerator.artist(currentArtist.value?.uid ?: return) + refreshDetail( + artist, _currentArtist, _artistSongList, _artistSongInstructions, replace) } - - val artist = currentArtist.value - if (artist != null) { - _currentArtist.value = - deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList) - logD("Updated artist to ${currentArtist.value}") + MusicType.GENRES -> { + val genre = detailGenerator.genre(currentGenre.value?.uid ?: return) + refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace) } - - val genre = currentGenre.value - if (genre != null) { - _currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList) - logD("Updated genre to ${currentGenre.value}") - } - } - - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - val playlist = currentPlaylist.value - if (playlist != null) { - _currentPlaylist.value = - userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList) - logD("Updated playlist to ${currentPlaylist.value}") + MusicType.PLAYLISTS -> { + refreshPlaylist(currentPlaylist.value?.uid ?: return) } + else -> error("Unexpected music type $type") } } @@ -356,8 +328,11 @@ constructor( */ fun setAlbum(uid: Music.UID) { logD("Opening album $uid") - _currentAlbum.value = - musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList) + if (uid === _currentAlbum.value?.uid) { + return + } + val album = detailGenerator.album(uid) + refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null) if (_currentAlbum.value == null) { logW("Given album UID was invalid") } @@ -370,7 +345,6 @@ constructor( */ fun applyAlbumSongSort(sort: Sort) { listSettings.albumSongSort = sort - _currentAlbum.value?.let { refreshAlbumList(it, true) } } /** @@ -381,11 +355,11 @@ constructor( */ fun setArtist(uid: Music.UID) { logD("Opening artist $uid") - _currentArtist.value = - musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList) - if (_currentArtist.value == null) { - logW("Given artist UID was invalid") + if (uid === _currentArtist.value?.uid) { + return } + val artist = detailGenerator.artist(uid) + refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null) } /** @@ -395,7 +369,6 @@ constructor( */ fun applyArtistSongSort(sort: Sort) { listSettings.artistSongSort = sort - _currentArtist.value?.let { refreshArtistList(it, true) } } /** @@ -406,11 +379,11 @@ constructor( */ fun setGenre(uid: Music.UID) { logD("Opening genre $uid") - _currentGenre.value = - musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) - if (_currentGenre.value == null) { - logW("Given genre UID was invalid") + if (uid === _currentGenre.value?.uid) { + return } + val genre = detailGenerator.genre(uid) + refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null) } /** @@ -420,7 +393,6 @@ constructor( */ fun applyGenreSongSort(sort: Sort) { listSettings.genreSongSort = sort - _currentGenre.value?.let { refreshGenreList(it, true) } } /** @@ -431,11 +403,10 @@ constructor( */ fun setPlaylist(uid: Music.UID) { logD("Opening playlist $uid") - _currentPlaylist.value = - musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList) - if (_currentPlaylist.value == null) { - logW("Given playlist UID was invalid") + if (uid === _currentPlaylist.value?.uid) { + return } + refreshPlaylist(uid) } /** Start a playlist editing session. Does nothing if a playlist is not being shown. */ @@ -443,7 +414,7 @@ constructor( val playlist = _currentPlaylist.value ?: return logD("Starting playlist edit") _editedPlaylist.value = playlist.songs - refreshPlaylistList(playlist) + refreshPlaylist(playlist.uid) } /** @@ -474,9 +445,8 @@ constructor( // Nothing to do. return false } - logD("Discarding playlist edits") _editedPlaylist.value = null - refreshPlaylistList(playlist) + refreshPlaylist(playlist.uid) return true } @@ -488,7 +458,7 @@ constructor( fun applyPlaylistSongSort(sort: Sort) { val playlist = _currentPlaylist.value ?: return _editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return) - refreshPlaylistList(playlist, UpdateInstructions.Replace(2)) + refreshPlaylist(playlist.uid, UpdateInstructions.Replace(2)) } /** @@ -501,15 +471,15 @@ constructor( fun movePlaylistSongs(from: Int, to: Int): Boolean { val playlist = _currentPlaylist.value ?: return false val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList() - val realFrom = from - 2 - val realTo = to - 2 + val realFrom = from - 1 + val realTo = to - 1 if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) { return false } logD("Moving playlist song from $realFrom [$from] to $realTo [$to]") editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo)) _editedPlaylist.value = editedPlaylist - refreshPlaylistList(playlist, UpdateInstructions.Move(from, to)) + refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to)) return true } @@ -521,20 +491,20 @@ constructor( fun removePlaylistSong(at: Int) { val playlist = _currentPlaylist.value ?: return val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList() - val realAt = at - 2 + val realAt = at - 1 if (realAt !in editedPlaylist.indices) { return } logD("Removing playlist song at $realAt [$at]") editedPlaylist.removeAt(realAt) _editedPlaylist.value = editedPlaylist - refreshPlaylistList( - playlist, + refreshPlaylist( + playlist.uid, if (editedPlaylist.isNotEmpty()) { UpdateInstructions.Remove(at, 1) } else { logD("Playlist will be empty after removal, removing header") - UpdateInstructions.Remove(at - 2, 3) + UpdateInstructions.Remove(at - 1, 3) }) } @@ -552,173 +522,72 @@ constructor( } } - private fun refreshAlbumList(album: Album, replace: Boolean = false) { - logD("Refreshing album list") - val list = mutableListOf() - val header = SortHeader(R.string.lbl_songs) - list.add(header) - val instructions = - if (replace) { - // Intentional so that the header item isn't replaced with the songs - UpdateInstructions.Replace(list.size) - } else { - UpdateInstructions.Diff - } - - // To create a good user experience regarding disc numbers, we group the album's - // songs up by disc and then delimit the groups by a disc header. - val songs = albumSongSort.songs(album.songs) - val byDisc = songs.groupBy { it.disc } - if (byDisc.size > 1) { - logD("Album has more than one disc, interspersing headers") - for (entry in byDisc.entries) { - list.add(DiscHeader(entry.key)) - list.addAll(entry.value) - } - } else { - // Album only has one disc, don't add any redundant headers - list.addAll(songs) + private fun refreshDetail( + detail: Detail?, + parent: MutableStateFlow, + list: MutableStateFlow>, + instructions: MutableEvent, + replace: Int? + ) { + if (detail == null) { + parent.value = null + return } - - logD("Update album list to ${list.size} items with $instructions") - _albumSongInstructions.put(instructions) - _albumSongList.value = list - } - - private fun refreshArtistList(artist: Artist, replace: Boolean = false) { - logD("Refreshing artist list") - val list = mutableListOf() - - val grouping = - artist.explicitAlbums.groupByTo(sortedMapOf()) { - // Remap the complicated ReleaseType data structure into an easier - // "AlbumGrouping" enum that will automatically group and sort - // the artist's albums. - when (it.releaseType.refinement) { - ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE - ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES - null -> - when (it.releaseType) { - is ReleaseType.Album -> AlbumGrouping.ALBUMS - is ReleaseType.EP -> AlbumGrouping.EPS - is ReleaseType.Single -> AlbumGrouping.SINGLES - is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS - is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS - is ReleaseType.Mix -> AlbumGrouping.DJMIXES - is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES - is ReleaseType.Demo -> AlbumGrouping.DEMOS - } + val newList = mutableListOf() + var newInstructions: UpdateInstructions = UpdateInstructions.Diff + for ((i, section) in detail.sections.withIndex()) { + val items = + when (section) { + is DetailSection.PlainSection<*> -> { + val header = + if (section is DetailSection.Songs) SortHeader(section.stringRes) + else BasicHeader(section.stringRes) + newList.add(Divider(header)) + newList.add(header) + section.items + } + is DetailSection.Discs -> { + val header = SortHeader(section.stringRes) + newList.add(Divider(header)) + newList.add(header) + section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value } + } } - } - - if (artist.implicitAlbums.isNotEmpty()) { - // groupByTo normally returns a mapping to a MutableList mapping. Since MutableList - // inherits list, we can cast upwards and save a copy by directly inserting the - // implicit album list into the mapping. - logD("Implicit albums present, adding to list") - @Suppress("UNCHECKED_CAST") - (grouping as MutableMap>)[AlbumGrouping.APPEARANCES] = - artist.implicitAlbums - } - - logD("Release groups for this artist: ${grouping.keys}") - - for ((i, entry) in grouping.entries.withIndex()) { - val header = BasicHeader(entry.key.headerTitleRes) - if (i > 0) { - list.add(Divider(header)) - } - list.add(header) - list.addAll(ARTIST_ALBUM_SORT.albums(entry.value)) - } - - // Artists may not be linked to any songs, only include a header entry if we have any. - var instructions: UpdateInstructions = UpdateInstructions.Diff - if (artist.songs.isNotEmpty()) { - logD("Songs present in this artist, adding header") - val header = SortHeader(R.string.lbl_songs) - list.add(Divider(header)) - list.add(header) - if (replace) { + // Currently only the final section (songs, which can be sorted) are invalidatable + // and thus need to be replaced. + if (replace == -1 && i == detail.sections.lastIndex) { // Intentional so that the header item isn't replaced with the songs - instructions = UpdateInstructions.Replace(list.size) + newInstructions = UpdateInstructions.Replace(newList.size) } - list.addAll(artistSongSort.songs(artist.songs)) + newList.addAll(items) } - - logD("Updating artist list to ${list.size} items with $instructions") - _artistSongInstructions.put(instructions) - _artistSongList.value = list.toList() + parent.value = detail.parent + instructions.put(newInstructions) + list.value = newList } - private fun refreshGenreList(genre: Genre, replace: Boolean = false) { - logD("Refreshing genre list") - val list = mutableListOf() - // Genre is guaranteed to always have artists and songs. - val artistHeader = BasicHeader(R.string.lbl_artists) - list.add(artistHeader) - list.addAll(GENRE_ARTIST_SORT.artists(genre.artists)) - - val songHeader = SortHeader(R.string.lbl_songs) - list.add(Divider(songHeader)) - list.add(songHeader) - val instructions = - if (replace) { - // Intentional so that the header item isn't replaced alongside the songs - UpdateInstructions.Replace(list.size) - } else { - UpdateInstructions.Diff - } - list.addAll(genreSongSort.songs(genre.songs)) - - logD("Updating genre list to ${list.size} items with $instructions") - _genreSongInstructions.put(instructions) - _genreSongList.value = list - } - - private fun refreshPlaylistList( - playlist: Playlist, + private fun refreshPlaylist( + uid: Music.UID, instructions: UpdateInstructions = UpdateInstructions.Diff ) { logD("Refreshing playlist list") - val list = mutableListOf() - - val songs = editedPlaylist.value ?: playlist.songs - if (songs.isNotEmpty()) { - val header = EditHeader(R.string.lbl_songs) - list.add(header) - list.addAll(songs) + val edited = editedPlaylist.value + if (edited == null) { + val playlist = detailGenerator.playlist(uid) + refreshDetail( + playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) + return + } + val list = mutableListOf() + if (edited.isNotEmpty()) { + val header = EditHeader(R.string.lbl_songs) + list.add(Divider(header)) + list.add(header) + list.addAll(edited) } - - logD("Updating playlist list to ${list.size} items with $instructions") _playlistSongInstructions.put(instructions) _playlistSongList.value = list } - - /** - * A simpler mapping of [ReleaseType] used for grouping and sorting songs. - * - * @param headerTitleRes The title string resource to use for a header created out of an - * instance of this enum. - */ - private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) { - ALBUMS(R.string.lbl_albums), - EPS(R.string.lbl_eps), - SINGLES(R.string.lbl_singles), - COMPILATIONS(R.string.lbl_compilations), - SOUNDTRACKS(R.string.lbl_soundtracks), - DJMIXES(R.string.lbl_mixes), - MIXTAPES(R.string.lbl_mixtapes), - DEMOS(R.string.lbl_demos), - APPEARANCES(R.string.lbl_appears_on), - LIVE(R.string.lbl_live_group), - REMIXES(R.string.lbl_remix_group), - } - - private companion object { - val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) - val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt index 63419e1e5..c1134b207 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt @@ -35,10 +35,10 @@ import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.music.info.resolveNumber import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater -import org.oxycblt.auxio.util.logD /** * An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view. @@ -111,16 +111,10 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : */ fun bind(discHeader: DiscHeader) { val disc = discHeader.inner - if (disc != null) { - binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number) - binding.discName.apply { - text = disc.name - isGone = disc.name == null - } - } else { - logD("Disc is null, defaulting to no disc") - binding.discNumber.text = binding.context.getString(R.string.def_disc) - binding.discName.isGone = true + binding.discNumber.text = disc.resolveNumber(binding.context) + binding.discName.apply { + text = disc?.name + isGone = disc?.name == null } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt new file mode 100644 index 000000000..52135d3d4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2024 Auxio Project + * HomeGenerator.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.home + +import javax.inject.Inject +import org.oxycblt.auxio.home.tabs.Tab +import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.util.logD + +interface HomeGenerator { + fun attach() + + fun release() + + fun songs(): List + + fun albums(): List + + fun artists(): List + + fun genres(): List + + fun playlists(): List + + fun tabs(): List + + interface Invalidator { + fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) + + fun invalidateTabs() + } + + interface Factory { + fun create(invalidator: Invalidator): HomeGenerator + } +} + +class HomeGeneratorFactoryImpl +@Inject +constructor( + private val homeSettings: HomeSettings, + private val listSettings: ListSettings, + private val musicRepository: MusicRepository, +) : HomeGenerator.Factory { + override fun create(invalidator: HomeGenerator.Invalidator): HomeGenerator = + HomeGeneratorImpl(invalidator, homeSettings, listSettings, musicRepository) +} + +private class HomeGeneratorImpl( + private val invalidator: HomeGenerator.Invalidator, + private val homeSettings: HomeSettings, + private val listSettings: ListSettings, + private val musicRepository: MusicRepository, +) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { + override fun attach() { + homeSettings.registerListener(this) + listSettings.registerListener(this) + musicRepository.addUpdateListener(this) + } + + override fun onTabsChanged() { + invalidator.invalidateTabs() + } + + override fun onHideCollaboratorsChanged() { + // Changes in the hide collaborator setting will change the artist contents + // of the library, consider it a library update. + logD("Collaborator setting changed, forwarding update") + onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) + } + + override fun onSongSortChanged() { + super.onSongSortChanged() + invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Replace(0)) + } + + override fun onAlbumSortChanged() { + super.onAlbumSortChanged() + invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Replace(0)) + } + + override fun onArtistSortChanged() { + super.onArtistSortChanged() + invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Replace(0)) + } + + override fun onGenreSortChanged() { + super.onGenreSortChanged() + invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Replace(0)) + } + + override fun onPlaylistSortChanged() { + super.onPlaylistSortChanged() + invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Replace(0)) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary + if (changes.deviceLibrary && deviceLibrary != null) { + logD("Refreshing library") + // Get the each list of items in the library to use as our list data. + // Applying the preferred sorting to them. + invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff) + } + + val userLibrary = musicRepository.userLibrary + if (changes.userLibrary && userLibrary != null) { + logD("Refreshing playlists") + invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) + } + } + + override fun release() { + musicRepository.removeUpdateListener(this) + listSettings.unregisterListener(this) + homeSettings.unregisterListener(this) + } + + override fun songs() = + musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() + + override fun albums() = + musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } + ?: emptyList() + + override fun artists() = + musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } + ?: emptyList() + + override fun genres() = + musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } + ?: emptyList() + + override fun playlists() = + musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } + ?: emptyList() + + override fun tabs() = homeSettings.homeTabs.filterIsInstance().map { it.type } +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt index a578b6e07..e7e2f9118 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt @@ -27,4 +27,6 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) interface HomeModule { @Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings + + @Binds fun homeGeneratorFactory(factory: HomeGeneratorFactoryImpl): HomeGenerator.Factory } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt index 5fc218cfe..ec54942f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt @@ -42,9 +42,9 @@ interface HomeSettings : Settings { interface Listener { /** Called when the [homeTabs] configuration changes. */ - fun onTabsChanged() + fun onTabsChanged() {} /** Called when the [shouldHideCollaborators] configuration changes. */ - fun onHideCollaboratorsChanged() + fun onHideCollaboratorsChanged() {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index bb9311c84..2dcf5b2a4 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -30,7 +30,6 @@ import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song @@ -49,12 +48,10 @@ import org.oxycblt.auxio.util.logD class HomeViewModel @Inject constructor( - private val homeSettings: HomeSettings, private val listSettings: ListSettings, private val playbackSettings: PlaybackSettings, - private val musicRepository: MusicRepository, -) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener { - + homeGeneratorFactory: HomeGenerator.Factory +) : ViewModel(), HomeGenerator.Invalidator { private val _songList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ val songList: StateFlow> @@ -132,11 +129,13 @@ constructor( val playlistSort: Sort get() = listSettings.playlistSort + private val homeGenerator = homeGeneratorFactory.create(this) + /** * A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible * [Tab]s. */ - var currentTabTypes = makeTabTypes() + var currentTabTypes = homeGenerator.tabs() private set private val _currentTabType = MutableStateFlow(currentTabTypes[0]) @@ -161,63 +160,44 @@ constructor( get() = _showOuter init { - musicRepository.addUpdateListener(this) - homeSettings.registerListener(this) + homeGenerator.attach() } override fun onCleared() { super.onCleared() - musicRepository.removeUpdateListener(this) - homeSettings.unregisterListener(this) + homeGenerator.release() } - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary - if (changes.deviceLibrary && deviceLibrary != null) { - logD("Refreshing library") - // Get the each list of items in the library to use as our list data. - // Applying the preferred sorting to them. - _songInstructions.put(UpdateInstructions.Diff) - _songList.value = listSettings.songSort.songs(deviceLibrary.songs) - _albumInstructions.put(UpdateInstructions.Diff) - _albumList.value = listSettings.albumSort.albums(deviceLibrary.albums) - _artistInstructions.put(UpdateInstructions.Diff) - _artistList.value = - listSettings.artistSort.artists( - if (homeSettings.shouldHideCollaborators) { - logD("Filtering collaborator artists") - // Hide Collaborators is enabled, filter out collaborators. - deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() } - } else { - logD("Using all artists") - deviceLibrary.artists - }) - _genreInstructions.put(UpdateInstructions.Diff) - _genreList.value = listSettings.genreSort.genres(deviceLibrary.genres) - } - - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - logD("Refreshing playlists") - _playlistInstructions.put(UpdateInstructions.Diff) - _playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists) + override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { + when (type) { + MusicType.SONGS -> { + _songInstructions.put(instructions) + _songList.value = homeGenerator.songs() + } + MusicType.ALBUMS -> { + _albumInstructions.put(instructions) + _albumList.value = homeGenerator.albums() + } + MusicType.ARTISTS -> { + _artistInstructions.put(instructions) + _artistList.value = homeGenerator.artists() + } + MusicType.GENRES -> { + _genreInstructions.put(instructions) + _genreList.value = homeGenerator.genres() + } + MusicType.PLAYLISTS -> { + _playlistInstructions.put(instructions) + _playlistList.value = homeGenerator.playlists() + } } } - override fun onTabsChanged() { - // Tabs changed, update the current tabs and set up a re-create event. - currentTabTypes = makeTabTypes() - logD("Updating tabs: ${currentTabType.value}") + override fun invalidateTabs() { + currentTabTypes = homeGenerator.tabs() _shouldRecreate.put(Unit) } - override fun onHideCollaboratorsChanged() { - // Changes in the hide collaborator setting will change the artist contents - // of the library, consider it a library update. - logD("Collaborator setting changed, forwarding update") - onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) - } - /** * Apply a new [Sort] to [songList]. * @@ -225,8 +205,6 @@ constructor( */ fun applySongSort(sort: Sort) { listSettings.songSort = sort - _songInstructions.put(UpdateInstructions.Replace(0)) - _songList.value = listSettings.songSort.songs(_songList.value) } /** @@ -236,8 +214,6 @@ constructor( */ fun applyAlbumSort(sort: Sort) { listSettings.albumSort = sort - _albumInstructions.put(UpdateInstructions.Replace(0)) - _albumList.value = listSettings.albumSort.albums(_albumList.value) } /** @@ -247,8 +223,6 @@ constructor( */ fun applyArtistSort(sort: Sort) { listSettings.artistSort = sort - _artistInstructions.put(UpdateInstructions.Replace(0)) - _artistList.value = listSettings.artistSort.artists(_artistList.value) } /** @@ -258,8 +232,6 @@ constructor( */ fun applyGenreSort(sort: Sort) { listSettings.genreSort = sort - _genreInstructions.put(UpdateInstructions.Replace(0)) - _genreList.value = listSettings.genreSort.genres(_genreList.value) } /** @@ -269,8 +241,6 @@ constructor( */ fun applyPlaylistSort(sort: Sort) { listSettings.playlistSort = sort - _playlistInstructions.put(UpdateInstructions.Replace(0)) - _playlistList.value = listSettings.playlistSort.playlists(_playlistList.value) } /** @@ -300,15 +270,6 @@ constructor( fun showAbout() { _showOuter.put(Outer.About) } - - /** - * Create a list of [MusicType]s representing a simpler version of the [Tab] configuration. - * - * @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in - * the same way as the configuration. - */ - private fun makeTabTypes() = - homeSettings.homeTabs.filterIsInstance().map { it.type } } sealed interface Outer { diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt index 73170ef4c..7cf0c2a53 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -37,40 +37,24 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) : private val width = context.resources.configuration.smallestScreenWidthDp override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { - val icon: Int - val string: Int - - when (tabs[position]) { - MusicType.SONGS -> { - icon = R.drawable.ic_song_24 - string = R.string.lbl_songs + val homeTab = tabs[position] + val icon = + when (homeTab) { + MusicType.SONGS -> R.drawable.ic_song_24 + MusicType.ALBUMS -> R.drawable.ic_album_24 + MusicType.ARTISTS -> R.drawable.ic_artist_24 + MusicType.GENRES -> R.drawable.ic_genre_24 + MusicType.PLAYLISTS -> R.drawable.ic_playlist_24 } - MusicType.ALBUMS -> { - icon = R.drawable.ic_album_24 - string = R.string.lbl_albums - } - MusicType.ARTISTS -> { - icon = R.drawable.ic_artist_24 - string = R.string.lbl_artists - } - MusicType.GENRES -> { - icon = R.drawable.ic_genre_24 - string = R.string.lbl_genres - } - MusicType.PLAYLISTS -> { - icon = R.drawable.ic_playlist_24 - string = R.string.lbl_playlists - } - } // Use expected sw* size thresholds when choosing a configuration. when { // On small screens, only display an icon. - width < 370 -> tab.setIcon(icon).setContentDescription(string) + width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes) // On large screens, display an icon and text. - width < 600 -> tab.setText(string) + width < 600 -> tab.setText(homeTab.nameRes) // On medium-size screens, display text. - else -> tab.setIcon(icon).setText(string) + else -> tab.setIcon(icon).setText(homeTab.nameRes) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt index c8d3ee145..0d32d20de 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt @@ -107,7 +107,10 @@ class RoundedRectTransformation( } private fun calculateOutputSize(input: Bitmap, size: Size): Pair { - // MODIFICATION: Remove short-circuiting for original size and input size + if (size == Size.ORIGINAL) { + // This path only runs w/the widget code, which already normalizes widget sizes + return input.width to input.height + } val multiplier = DecodeUtils.computeSizeMultiplier( srcWidth = input.width, diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt index 3f3388b73..7dcbfa13f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt @@ -26,7 +26,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.settings.Settings -interface ListSettings : Settings { +interface ListSettings : Settings { /** The [Sort] mode used in Song lists. */ var songSort: Sort /** The [Sort] mode used in Album lists. */ @@ -43,10 +43,28 @@ interface ListSettings : Settings { var artistSongSort: Sort /** The [Sort] mode used in a Genre's Song list. */ var genreSongSort: Sort + + interface Listener { + fun onSongSortChanged() {} + + fun onAlbumSortChanged() {} + + fun onAlbumSongSortChanged() {} + + fun onArtistSortChanged() {} + + fun onArtistSongSortChanged() {} + + fun onGenreSortChanged() {} + + fun onGenreSongSortChanged() {} + + fun onPlaylistSortChanged() {} + } } class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) : - Settings.Impl(context), ListSettings { + Settings.Impl(context), ListSettings { override var songSort: Sort get() = Sort.fromIntCode( @@ -145,4 +163,17 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont apply() } } + + override fun onSettingChanged(key: String, listener: ListSettings.Listener) { + when (key) { + getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged() + getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged() + getString(R.string.set_key_album_songs_sort) -> listener.onAlbumSongSortChanged() + getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged() + getString(R.string.set_key_artist_songs_sort) -> listener.onArtistSongSortChanged() + getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged() + getString(R.string.set_key_genre_songs_sort) -> listener.onGenreSongSortChanged() + getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged() + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt index 19f535af1..572280f8e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.music import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R /** * General configuration enum to control what kind of music is being worked with. @@ -52,6 +53,16 @@ enum class MusicType { PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS } + val nameRes: Int + get() = + when (this) { + SONGS -> R.string.lbl_songs + ALBUMS -> R.string.lbl_albums + ARTISTS -> R.string.lbl_artists + GENRES -> R.string.lbl_genres + PLAYLISTS -> R.string.lbl_playlists + } + companion object { /** * Convert a [MusicType] integer representation into an instance. diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 2a7113066..5ea7c8ebf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 46, exportSchema = false) +@Database(entities = [CachedSong::class], version = 49, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt index 2c8fd360b..5f2b52bd6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt @@ -18,6 +18,8 @@ package org.oxycblt.auxio.music.info +import android.content.Context +import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Item /** @@ -34,3 +36,7 @@ class Disc(val number: Int, val name: String?) : Item, Comparable { override fun compareTo(other: Disc) = number.compareTo(other.number) } + +fun Disc?.resolveNumber(context: Context) = + this?.run { context.getString(R.string.fmt_disc_no, number) } + ?: context.getString(R.string.def_disc) diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index 30626f01e..30f4564c8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -70,12 +70,12 @@ sealed interface Name : Comparable { final override fun compareTo(other: Name) = when (other) { is Known -> { - // Progressively compare the sort tokens between each known name. - sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) -> - acc.takeIf { it != 0 } ?: token.compareTo(otherToken) - } + val result = + sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) -> + acc.takeIf { it != 0 } ?: token.compareTo(otherToken) + } + if (result != 0) result else sortTokens.size.compareTo(other.sortTokens.size) } - // Unknown names always come before known names. is Unknown -> 1 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt index 3f70981ba..f5c16f985 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagInterpreter.kt @@ -100,6 +100,7 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx private fun populateWithId3v2(rawSong: RawSong, textFrames: Map>) { // Song + logD(textFrames) (textFrames["TXXX:musicbrainz release track id"] ?: textFrames["TXXX:musicbrainz_releasetrackid"]) ?.let { rawSong.musicBrainzId = it.first() } @@ -147,10 +148,13 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx (textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let { rawSong.artistMusicBrainzIds = it } - (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it } + (textFrames["TXXX:artists"] ?: textFrames["TPE1"] ?: textFrames["TXXX:artist"])?.let { + rawSong.artistNames = it + } (textFrames["TXXX:artistssort"] ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] - ?: textFrames["TSOP"]) + ?: textFrames["TSOP"] ?: textFrames["artistsort"] + ?: textFrames["TXXX:artist sort"]) ?.let { rawSong.artistSortNames = it } // Album artist @@ -159,13 +163,14 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx ?.let { rawSong.albumArtistMusicBrainzIds = it } (textFrames["TXXX:albumartists"] ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] - ?: textFrames["TPE2"]) + ?: textFrames["TPE2"] ?: textFrames["TXXX:albumartist"] + ?: textFrames["TXXX:album artist"]) ?.let { rawSong.albumArtistNames = it } (textFrames["TXXX:albumartistssort"] ?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"] ?: textFrames["TXXX:albumartistsort"] // This is a non-standard iTunes extension - ?: textFrames["TSO2"]) + ?: textFrames["TSO2"] ?: textFrames["TXXX:album artist sort"]) ?.let { rawSong.albumArtistSortNames = it } // Genre @@ -273,7 +278,8 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } (comments["artistssort"] - ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"]) + ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"] + ?: comments["artist sort"]) ?.let { rawSong.artistSortNames = it } // Album artist @@ -281,12 +287,12 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx rawSong.albumArtistMusicBrainzIds = it } (comments["albumartists"] - ?: comments["album_artists"] ?: comments["album artists"] - ?: comments["albumartist"]) + ?: comments["album_artists"] ?: comments["album artists"] ?: comments["albumartist"] + ?: comments["album artist"]) ?.let { rawSong.albumArtistNames = it } (comments["albumartistssort"] ?: comments["albumartists_sort"] ?: comments["albumartists sort"] - ?: comments["albumartistsort"]) + ?: comments["albumartistsort"] ?: comments["album artist sort"]) ?.let { rawSong.albumArtistSortNames = it } // Genre diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt similarity index 84% rename from app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index c6277ba7c..5552aec69 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * IndexerServiceFragment.kt is part of Auxio. + * Indexer.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,13 +21,13 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.os.PowerManager import coil.ImageLoader -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings @@ -35,34 +35,52 @@ 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, +class Indexer +private constructor( + override val workerContext: Context, + private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, - private val contentObserver: SystemContentObserver, - private val imageLoader: ImageLoader + private val imageLoader: ImageLoader, + private val contentObserver: SystemContentObserver ) : MusicRepository.IndexingWorker, MusicRepository.IndexingListener, MusicRepository.UpdateListener, MusicSettings.Listener { + class Factory + @Inject + constructor( + private val playbackManager: PlaybackStateManager, + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings, + private val imageLoader: ImageLoader, + private val contentObserver: SystemContentObserver + ) { + fun create(context: Context, listener: ForegroundListener) = + Indexer( + context, + listener, + playbackManager, + musicRepository, + musicSettings, + imageLoader, + contentObserver) + } + private val indexJob = Job() private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) private var currentIndexJob: Job? = null private 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 + fun attach() { musicSettings.registerListener(this) musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -76,7 +94,6 @@ constructor( musicRepository.removeIndexingListener(this) musicRepository.removeUpdateListener(this) musicSettings.unregisterListener(this) - foregroundListener = null } fun start() { @@ -85,7 +102,7 @@ constructor( } } - fun createNotification(post: (IndexerNotification?) -> Unit) { + fun createNotification(post: (ForegroundServiceNotification?) -> Unit) { val state = musicRepository.indexingState if (state is IndexingState.Indexing) { // There are a few reasons why we stay in the foreground with automatic rescanning: @@ -118,7 +135,7 @@ constructor( override val scope = indexScope override fun onIndexingStateChanged() { - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + foregroundListener.updateForeground(ForegroundListener.Change.INDEXER) val state = musicRepository.indexingState if (state is IndexingState.Indexing) { wakeLock.acquireSafe() @@ -157,9 +174,9 @@ constructor( // 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) { + if (musicRepository.indexingState == null) { logD("Not loading, updating idle session") - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + foregroundListener.updateForeground(ForegroundListener.Change.INDEXER) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt index d857ab32b..0e895196d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt @@ -20,11 +20,9 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.os.SystemClock -import androidx.annotation.StringRes -import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.IndexingProgress @@ -32,52 +30,13 @@ import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent /** - * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that - * signal a Service's ongoing foreground state. - * - * @author Alexander Capehart (OxygenCobalt) - */ -abstract class IndexerNotification(context: Context, info: ChannelInfo) : - NotificationCompat.Builder(context, info.id) { - private val notificationManager = NotificationManagerCompat.from(context) - - init { - // Set up the notification channel. Foreground notifications are non-substantial, and - // thus make no sense to have lights, vibration, or lead to a notification badge. - val channel = - NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(context.getString(info.nameRes)) - .setLightsEnabled(false) - .setVibrationEnabled(false) - .setShowBadge(false) - .build() - notificationManager.createNotificationChannel(channel) - } - - /** - * The code used to identify this notification. - * - * @see NotificationManagerCompat.notify - */ - abstract val code: Int - - /** - * Reduced representation of a [NotificationChannelCompat]. - * - * @param id The ID of the channel. - * @param nameRes A string resource ID corresponding to the human-readable name of this channel. - */ - data class ChannelInfo(val id: String, @StringRes val nameRes: Int) -} - -/** - * A dynamic [IndexerNotification] that shows the current music loading state. + * A dynamic [ForegroundServiceNotification] that shows the current music loading state. * * @param context [Context] required to create the notification. * @author Alexander Capehart (OxygenCobalt) */ class IndexingNotification(private val context: Context) : - IndexerNotification(context, indexerChannel) { + ForegroundServiceNotification(context, indexerChannel) { private var lastUpdateTime = -1L init { @@ -133,12 +92,13 @@ class IndexingNotification(private val context: Context) : } /** - * A static [IndexerNotification] that signals to the user that the app is currently monitoring the - * music library for changes. + * A static [ForegroundServiceNotification] that signals to the user that the app is currently + * monitoring the music library for changes. * * @author Alexander Capehart (OxygenCobalt) */ -class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) { +class ObservingNotification(context: Context) : + ForegroundServiceNotification(context, indexerChannel) { init { setSmallIcon(R.drawable.ic_indexer_24) setCategory(NotificationCompat.CATEGORY_SERVICE) @@ -156,5 +116,5 @@ class ObservingNotification(context: Context) : IndexerNotification(context, ind /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ private val indexerChannel = - IndexerNotification.ChannelInfo( + ForegroundServiceNotification.ChannelInfo( id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt deleted file mode 100644 index 93841a63f..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ /dev/null @@ -1,374 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaItemBrowser.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music.service - -import android.content.Context -import android.os.Bundle -import androidx.annotation.StringRes -import androidx.media.utils.MediaConstants -import androidx.media3.common.MediaItem -import androidx.media3.session.MediaSession.ControllerInfo -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import kotlin.math.min -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.ListSettings -import org.oxycblt.auxio.list.sort.Sort -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.user.UserLibrary -import org.oxycblt.auxio.search.SearchEngine - -class MediaItemBrowser -@Inject -constructor( - @ApplicationContext private val context: Context, - private val musicRepository: MusicRepository, - private val listSettings: ListSettings, - private val searchEngine: SearchEngine -) : MusicRepository.UpdateListener { - private val browserJob = Job() - private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) - private val searchSubscribers = mutableMapOf() - private val searchResults = mutableMapOf>() - private var invalidator: Invalidator? = null - - interface Invalidator { - fun invalidate(ids: Map) - - fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) - } - - fun attach(invalidator: Invalidator) { - this.invalidator = invalidator - musicRepository.addUpdateListener(this) - } - - fun release() { - browserJob.cancel() - invalidator = null - musicRepository.removeUpdateListener(this) - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary - var invalidateSearch = false - val invalidate = mutableMapOf() - if (changes.deviceLibrary && deviceLibrary != null) { - MediaSessionUID.Category.DEVICE_MUSIC.forEach { - invalidate[it.toString()] = getCategorySize(it, musicRepository) - } - - deviceLibrary.albums.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size - } - - deviceLibrary.artists.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size - } - - deviceLibrary.genres.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size + it.artists.size - } - - invalidateSearch = true - } - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - MediaSessionUID.Category.USER_MUSIC.forEach { - invalidate[it.toString()] = getCategorySize(it, musicRepository) - } - userLibrary.playlists.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size - } - invalidateSearch = true - } - - if (invalidate.isNotEmpty()) { - invalidator?.invalidate(invalidate) - } - - if (invalidateSearch) { - for (entry in searchResults.entries) { - searchResults[entry.key]?.cancel() - } - searchResults.clear() - - for (entry in searchSubscribers.entries) { - if (searchResults[entry.value] != null) { - continue - } - searchResults[entry.value] = searchTo(entry.value) - } - } - } - - val root: MediaItem - get() = MediaSessionUID.Category.ROOT.toMediaItem(context) - - fun getItem(mediaId: String): MediaItem? { - val music = - when (val uid = MediaSessionUID.fromString(mediaId)) { - is MediaSessionUID.Category -> return uid.toMediaItem(context) - is MediaSessionUID.Single -> - musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - is MediaSessionUID.Joined -> - musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } - null -> null - } - ?: return null - - return when (music) { - is Album -> music.toMediaItem(context) - is Artist -> music.toMediaItem(context) - is Genre -> music.toMediaItem(context) - is Playlist -> music.toMediaItem(context) - is Song -> music.toMediaItem(context, null) - } - } - - fun getChildren(parentId: String, page: Int, pageSize: Int): List? { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return listOf() - } - - val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null - return items.paginate(page, pageSize) - } - - private fun getMediaItemList( - id: String, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary - ): List? { - return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { - is MediaSessionUID.Category -> { - when (mediaSessionUID) { - MediaSessionUID.Category.ROOT -> - MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } - MediaSessionUID.Category.SONGS -> - listSettings.songSort.songs(deviceLibrary.songs).map { - it.toMediaItem(context, null) - } - MediaSessionUID.Category.ALBUMS -> - listSettings.albumSort.albums(deviceLibrary.albums).map { - it.toMediaItem(context) - } - MediaSessionUID.Category.ARTISTS -> - listSettings.artistSort.artists(deviceLibrary.artists).map { - it.toMediaItem(context) - } - MediaSessionUID.Category.GENRES -> - listSettings.genreSort.genres(deviceLibrary.genres).map { - it.toMediaItem(context) - } - MediaSessionUID.Category.PLAYLISTS -> - userLibrary.playlists.map { it.toMediaItem(context) } - } - } - is MediaSessionUID.Single -> { - getChildMediaItems(mediaSessionUID.uid) - } - is MediaSessionUID.Joined -> { - getChildMediaItems(mediaSessionUID.childUid) - } - null -> { - return null - } - } - } - - private fun getChildMediaItems(uid: Music.UID): List? { - return when (val item = musicRepository.find(uid)) { - is Album -> { - val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } - } - is Artist -> { - val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) - val songs = listSettings.artistSongSort.songs(item.songs) - albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + - songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } - } - is Genre -> { - val artists = GENRE_ARTISTS_SORT.artists(item.artists) - val songs = listSettings.genreSongSort.songs(item.songs) - artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + - songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } - } - is Playlist -> { - item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } - } - is Song, - null -> return null - } - } - - private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { - val oldExtras = mediaMetadata.extras ?: Bundle() - val newExtras = - Bundle(oldExtras).apply { - putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - context.getString(res)) - } - return buildUpon() - .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) - .build() - } - - private fun getCategorySize( - category: MediaSessionUID.Category, - musicRepository: MusicRepository - ): Int { - val deviceLibrary = musicRepository.deviceLibrary ?: return 0 - val userLibrary = musicRepository.userLibrary ?: return 0 - return when (category) { - MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size - MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size - MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size - MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size - MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size - MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size - } - } - - suspend fun prepareSearch(query: String, controller: ControllerInfo) { - searchSubscribers[controller] = query - val existing = searchResults[query] - if (existing == null) { - val new = searchTo(query) - searchResults[query] = new - new.await() - } else { - val items = existing.await() - invalidator?.invalidate(controller, query, items.count()) - } - } - - suspend fun getSearchResult( - query: String, - page: Int, - pageSize: Int, - ): List? { - val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } - return deferred.await().concat().paginate(page, pageSize) - } - - private fun SearchEngine.Items.concat(): MutableList { - val music = mutableListOf() - if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null) }) - } - if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context) }) - } - if (artists != null) { - music.addAll(artists.map { it.toMediaItem(context) }) - } - if (genres != null) { - music.addAll(genres.map { it.toMediaItem(context) }) - } - if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(context) }) - } - return music - } - - private fun SearchEngine.Items.count(): Int { - var count = 0 - if (songs != null) { - count += songs.size - } - if (albums != null) { - count += albums.size - } - if (artists != null) { - count += artists.size - } - if (genres != null) { - count += genres.size - } - if (playlists != null) { - count += playlists.size - } - return count - } - - private fun searchTo(query: String) = - searchScope.async { - if (query.isEmpty()) { - return@async SearchEngine.Items() - } - val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() - val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() - val items = - SearchEngine.Items( - deviceLibrary.songs, - deviceLibrary.albums, - deviceLibrary.artists, - deviceLibrary.genres, - userLibrary.playlists) - val results = searchEngine.search(items, query) - for (entry in searchSubscribers.entries) { - if (entry.value == query) { - invalidator?.invalidate(entry.key, query, results.count()) - } - } - results - } - - private fun List.paginate(page: Int, pageSize: Int): List? { - if (page == Int.MAX_VALUE) { - // I think if someone requests this page it more or less implies that I should - // return all of the pages. - return this - } - val start = page * pageSize - val end = min((page + 1) * pageSize, size) // Tolerate partial page queries - if (pageSize == 0 || start !in indices) { - // These pages are probably invalid. Hopefully this won't backfire. - return null - } - return subList(start, end).toMutableList() - } - - private companion object { - // TODO: Rely on detail item gen logic? - val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) - val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 9a5bb53c2..48488a376 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -19,15 +19,12 @@ 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 android.support.v4.media.MediaBrowserCompat.MediaItem +import android.support.v4.media.MediaDescriptionCompat import androidx.annotation.StringRes import androidx.media.utils.MediaConstants -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import java.io.ByteArrayOutputStream import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album @@ -37,242 +34,19 @@ 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.playback.formatDurationDs 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 Tab(val node: TabNode) : MediaSessionUID { + override fun toString() = "$ID_CATEGORY:${node.id}" } - data class Single(val uid: Music.UID) : MediaSessionUID { + data class SingleItem(val uid: Music.UID) : MediaSessionUID { override fun toString() = "$ID_ITEM:$uid" } - data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { - 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" @@ -283,28 +57,154 @@ sealed interface MediaSessionUID { 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) } - } - } - } + ID_CATEGORY -> Tab(TabNode.fromString(parts[1]) ?: return null) + ID_ITEM -> SingleItem(Music.UID.fromString(parts[1]) ?: return null) else -> return null } } } } + +typealias Sugar = Bundle.(Context) -> Unit + +fun header(@StringRes nameRes: Int): Sugar = { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) +} + +fun header(name: String): Sugar = { + putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, name) +} + +fun child(of: MusicParent): Sugar = { + putString(MusicBrowser.KEY_CHILD_OF, MediaSessionUID.SingleItem(of.uid).toString()) +} + +private fun style(style: Int): Sugar = { + putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style) +} + +private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle { + return Bundle().apply { sugars.forEach { this.it(context) } } +} + +fun TabNode.toMediaItem(context: Context): MediaItem { + val extras = + makeExtras( + context, + style(MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)) + val mediaSessionUID = MediaSessionUID.Tab(this) + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(context.getString(nameRes)) + .setExtras(extras) + bitmapRes?.let { res -> + val bitmap = BitmapFactory.decodeResource(context.resources, res) + description.setIconBitmap(bitmap) + } + return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) +} + +fun Song.toMediaDescription(context: Context, vararg sugar: Sugar): MediaDescriptionCompat { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val extras = makeExtras(context, *sugar) + return MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setDescription(album.name.resolve(context)) + .setIconUri(cover.mediaStoreCoverUri) + .setMediaUri(uri) + .setExtras(extras) + .build() +} + +fun Song.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { + return MediaItem(toMediaDescription(context, *sugar), MediaItem.FLAG_PLAYABLE) +} + +fun Album.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val extras = makeExtras(context, *sugar) + val counts = context.getPlural(R.plurals.fmt_song_count, songs.size) + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setDescription(counts) + .setIconUri(cover.single.mediaStoreCoverUri) + .setExtras(extras) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} + +fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = + context.getString( + R.string.fmt_two, + if (explicitAlbums.isNotEmpty()) { + context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) + } else { + context.getString(R.string.def_album_count) + }, + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + }) + val extras = makeExtras(context, *sugar) + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setDescription(genres.resolveNames(context)) + .setIconUri(cover.single.mediaStoreCoverUri) + .setExtras(extras) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} + +fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + } + val extras = makeExtras(context, *sugar) + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) + .setExtras(extras) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} + +fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + } + val extras = makeExtras(context, *sugar) + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setDescription(durationMs.formatDurationDs(true)) + .setIconUri(cover?.single?.mediaStoreCoverUri) + .setExtras(extras) + .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt new file mode 100644 index 000000000..35a43c7b5 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2024 Auxio Project + * MusicBrowser.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.support.v4.media.MediaBrowserCompat.MediaItem +import javax.inject.Inject +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.R +import org.oxycblt.auxio.detail.DetailGenerator +import org.oxycblt.auxio.detail.DetailSection +import org.oxycblt.auxio.home.HomeGenerator +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.info.resolveNumber +import org.oxycblt.auxio.search.SearchEngine + +class MusicBrowser +private constructor( + private val context: Context, + private val invalidator: Invalidator, + private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine, + homeGeneratorFactory: HomeGenerator.Factory, + detailGeneratorFactory: DetailGenerator.Factory +) : HomeGenerator.Invalidator, DetailGenerator.Invalidator { + + class Factory + @Inject + constructor( + private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine, + private val homeGeneratorFactory: HomeGenerator.Factory, + private val detailGeneratorFactory: DetailGenerator.Factory + ) { + fun create(context: Context, invalidator: Invalidator): MusicBrowser = + MusicBrowser( + context, + invalidator, + musicRepository, + searchEngine, + homeGeneratorFactory, + detailGeneratorFactory) + } + + interface Invalidator { + fun invalidateMusic(ids: Set) + } + + private val homeGenerator = homeGeneratorFactory.create(this) + private val detailGenerator = detailGeneratorFactory.create(this) + + fun attach() { + homeGenerator.attach() + detailGenerator.attach() + } + + fun release() { + homeGenerator.release() + detailGenerator.release() + } + + override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { + val id = MediaSessionUID.Tab(TabNode.Home(type)).toString() + invalidator.invalidateMusic(setOf(id)) + } + + override fun invalidateTabs() { + val rootId = MediaSessionUID.Tab(TabNode.Root).toString() + val moreId = MediaSessionUID.Tab(TabNode.More).toString() + invalidator.invalidateMusic(setOf(rootId, moreId)) + } + + override fun invalidate(type: MusicType, replace: Int?) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return + val music = + when (type) { + MusicType.ALBUMS -> deviceLibrary.albums + MusicType.ARTISTS -> deviceLibrary.artists + MusicType.GENRES -> deviceLibrary.genres + MusicType.PLAYLISTS -> userLibrary.playlists + else -> return + } + if (music.isEmpty()) { + return + } + val ids = music.map { MediaSessionUID.SingleItem(it.uid).toString() }.toSet() + invalidator.invalidateMusic(ids) + } + + fun getItem(mediaId: String): MediaItem? { + val music = + when (val uid = MediaSessionUID.fromString(mediaId)) { + is MediaSessionUID.Tab -> return uid.node.toMediaItem(context) + is MediaSessionUID.SingleItem -> + musicRepository.find(uid.uid)?.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) + } + } + + fun getChildren(parentId: String, maxTabs: Int): List? { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (deviceLibrary == null || userLibrary == null) { + return listOf() + } + return getMediaItemList(parentId, maxTabs) + } + + suspend fun search(query: String): MutableList { + if (query.isEmpty()) { + return mutableListOf() + } + val deviceLibrary = musicRepository.deviceLibrary ?: return mutableListOf() + val userLibrary = musicRepository.userLibrary ?: return mutableListOf() + val items = + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists) + return searchEngine.search(items, query).toMediaItems() + } + + private fun SearchEngine.Items.toMediaItems(): MutableList { + val music = mutableListOf() + if (songs != null) { + music.addAll(songs.map { it.toMediaItem(context, header(R.string.lbl_songs)) }) + } + if (albums != null) { + music.addAll(albums.map { it.toMediaItem(context, header(R.string.lbl_albums)) }) + } + if (artists != null) { + music.addAll(artists.map { it.toMediaItem(context, header(R.string.lbl_artists)) }) + } + if (genres != null) { + music.addAll(genres.map { it.toMediaItem(context, header(R.string.lbl_genres)) }) + } + if (playlists != null) { + music.addAll(playlists.map { it.toMediaItem(context, header(R.string.lbl_playlists)) }) + } + return music + } + + private fun getMediaItemList(id: String, maxTabs: Int): List? { + return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { + is MediaSessionUID.Tab -> { + getCategoryMediaItems(mediaSessionUID.node, maxTabs) + } + is MediaSessionUID.SingleItem -> { + getChildMediaItems(mediaSessionUID.uid) + } + null -> { + return null + } + } + } + + private fun getCategoryMediaItems(node: TabNode, maxTabs: Int) = + when (node) { + is TabNode.Root -> { + val tabs = homeGenerator.tabs() + if (maxTabs < tabs.size) { + tabs.take(maxTabs - 1).map { TabNode.Home(it).toMediaItem(context) } + + TabNode.More.toMediaItem(context) + } else { + tabs.map { TabNode.Home(it).toMediaItem(context) } + } + } + is TabNode.More -> { + homeGenerator.tabs().drop(maxTabs - 1).map { TabNode.Home(it).toMediaItem(context) } + } + is TabNode.Home -> + when (node.type) { + MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context) } + MusicType.ALBUMS -> homeGenerator.albums().map { it.toMediaItem(context) } + MusicType.ARTISTS -> homeGenerator.artists().map { it.toMediaItem(context) } + MusicType.GENRES -> homeGenerator.genres().map { it.toMediaItem(context) } + MusicType.PLAYLISTS -> homeGenerator.playlists().map { it.toMediaItem(context) } + } + } + + private fun getChildMediaItems(uid: Music.UID): List? { + val detail = detailGenerator.any(uid) ?: return null + return detail.sections.flatMap { section -> + when (section) { + is DetailSection.Songs -> + section.items.map { + it.toMediaItem(context, header(section.stringRes), child(detail.parent)) + } + is DetailSection.Albums -> + section.items.map { it.toMediaItem(context, header(section.stringRes)) } + is DetailSection.Artists -> + section.items.map { it.toMediaItem(context, header(section.stringRes)) } + is DetailSection.Discs -> + section.discs.flatMap { (disc, songs) -> + val discString = disc.resolveNumber(context) + songs.map { it.toMediaItem(context, header(discString)) } + } + else -> error("Unknown section type: $section") + } + } + } + + companion object { + const val KEY_CHILD_OF = BuildConfig.APPLICATION_ID + ".key.CHILD_OF" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt new file mode 100644 index 000000000..7cdd3fb9e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2024 Auxio Project + * MusicServiceFragment.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat.MediaItem +import androidx.media.MediaBrowserServiceCompat.BrowserRoot +import androidx.media.MediaBrowserServiceCompat.Result +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW + +class MusicServiceFragment +@Inject +constructor( + private val context: Context, + foregroundListener: ForegroundListener, + private val invalidator: Invalidator, + indexerFactory: Indexer.Factory, + musicBrowserFactory: MusicBrowser.Factory, + private val musicRepository: MusicRepository +) : MusicBrowser.Invalidator { + private val indexer = indexerFactory.create(context, foregroundListener) + private val musicBrowser = musicBrowserFactory.create(context, this) + private val dispatchJob = Job() + private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) + + data class Page(val num: Int, val size: Int) + + class Factory + @Inject + constructor( + private val indexerFactory: Indexer.Factory, + private val musicBrowserFactory: MusicBrowser.Factory, + private val musicRepository: MusicRepository + ) { + fun create( + context: Context, + foregroundListener: ForegroundListener, + invalidator: Invalidator + ): MusicServiceFragment = + MusicServiceFragment( + context, + foregroundListener, + invalidator, + indexerFactory, + musicBrowserFactory, + musicRepository) + } + + interface Invalidator { + fun invalidateMusic(mediaId: String) + } + + fun attach() { + indexer.attach() + musicBrowser.attach() + } + + fun release() { + dispatchJob.cancel() + musicBrowser.release() + indexer.release() + } + + override fun invalidateMusic(ids: Set) { + ids.forEach { mediaId -> invalidator.invalidateMusic(mediaId) } + } + + fun start() { + if (musicRepository.indexingState == null) { + musicRepository.requestIndex(true) + } + } + + fun createNotification(post: (ForegroundServiceNotification?) -> Unit) { + indexer.createNotification(post) + } + + fun getRoot() = BrowserRoot(MediaSessionUID.Tab(TabNode.Root).toString(), Bundle()) + + fun getItem(mediaId: String, result: Result) = + result.dispatch { + musicBrowser.getItem( + mediaId, + ) + } + + fun getChildren( + mediaId: String, + maxTabs: Int, + result: Result>, + page: Page? + ) = result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.expose(page) } + + fun search(query: String, result: Result>, page: Page?) = + result.dispatchAsync { musicBrowser.search(query).expose(page) } + + private fun Result.dispatch(body: () -> T?) { + try { + val result = body() + if (result == null) { + logW("Result is null") + } + sendResult(result) + } catch (e: Exception) { + logD("Error while dispatching: $e") + sendResult(null) + } + } + + private fun Result.dispatchAsync(body: suspend () -> T?) { + detach() + dispatchScope.launch { + try { + val result = body() + if (result == null) { + logW("Result is null") + } + sendResult(result) + } catch (e: Exception) { + logD("Error while dispatching: $e") + sendResult(null) + } + } + } + + private fun List.expose(page: Page?): MutableList { + if (page == null) return toMutableList() + val start = page.num * page.size + val end = start + page.size + return if (start >= size) { + mutableListOf() + } else { + subList(start, end.coerceAtMost(size)).toMutableList() + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt new file mode 100644 index 000000000..d13dd42be --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 Auxio Project + * TabNode.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.MusicType + +sealed class TabNode { + abstract val id: String + abstract val nameRes: Int + abstract val bitmapRes: Int? + + override fun toString() = id + + data object Root : TabNode() { + override val id = "root" + override val nameRes = R.string.info_app_name + override val bitmapRes = null + + override fun toString() = id + } + + data object More : TabNode() { + override val id = "more" + override val nameRes = R.string.lbl_more + override val bitmapRes = R.drawable.ic_more_bitmap_24 + } + + data class Home(val type: MusicType) : TabNode() { + override val id = "$ID/${type.intCode}" + override val bitmapRes: Int + get() = + when (type) { + MusicType.SONGS -> R.drawable.ic_song_bitmap_24 + MusicType.ALBUMS -> R.drawable.ic_album_bitmap_24 + MusicType.ARTISTS -> R.drawable.ic_artist_bitmap_24 + MusicType.GENRES -> R.drawable.ic_genre_bitmap_24 + MusicType.PLAYLISTS -> R.drawable.ic_playlist_bitmap_24 + } + + override val nameRes = type.nameRes + + companion object { + const val ID = "home" + } + } + + companion object { + fun fromString(str: String): TabNode? { + return when { + str == Root.id -> Root + str == More.id -> More + str.startsWith(Home.ID) -> { + val split = str.split("/") + if (split.size != 2) return null + val intCode = split[1].toIntOrNull() ?: return null + Home(MusicType.fromIntCode(intCode) ?: return null) + } + else -> null + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index adf3a782c..2947052c2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -46,8 +46,6 @@ import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.service.toMediaItem -import org.oxycblt.auxio.music.service.toSong import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.msToSecs import org.oxycblt.auxio.playback.persist.PersistenceRepository @@ -92,7 +90,6 @@ class ExoPlaybackStateHolder( fun attach() { imageSettings.registerListener(this) player.addListener(this) - replayGainProcessor.attach() playbackManager.registerStateHolder(this) playbackSettings.registerListener(this) musicRepository.addUpdateListener(this) @@ -111,10 +108,6 @@ class ExoPlaybackStateHolder( override var parent: MusicParent? = null private set - val mediaSessionPlayer: Player - get() = - MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository) - override val progression: Progression get() { val mediaItem = player.currentMediaItem ?: return Progression.nil() @@ -147,10 +140,7 @@ class ExoPlaybackStateHolder( } else { emptyList() } - return RawQueue( - heap.mapNotNull { it.toSong(deviceLibrary) }, - shuffledMapping, - player.currentMediaItemIndex) + return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex) } override fun handleDeferred(action: DeferredPlayback): Boolean { @@ -164,10 +154,18 @@ class ExoPlaybackStateHolder( 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) } + val state = persistenceRepository.readState() + withContext(Dispatchers.Main) { + if (state != null) { + // Apply the saved state on the main thread to prevent code expecting + // state updates on the main thread from crashing. + playbackManager.applySavedState(state, false) + if (action.play) { + playbackManager.playing(true) + } + } else if (action.fallback != null) { + playbackManager.playDeferred(action.fallback) + } } } } @@ -219,7 +217,7 @@ class ExoPlaybackStateHolder( override fun newPlayback(command: PlaybackCommand) { parent = command.parent player.shuffleModeEnabled = command.shuffled - player.setMediaItems(command.queue.map { it.toMediaItem(context, null) }) + player.setMediaItems(command.queue.map { it.buildMediaItem() }) val startIndex = command.song ?.let { command.queue.indexOf(it) } @@ -309,16 +307,16 @@ class ExoPlaybackStateHolder( } if (nextIndex == C.INDEX_UNSET) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(songs.map { it.buildMediaItem() }) } else { - player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() }) } playbackManager.ack(this, ack) deferSave() } override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(songs.map { it.buildMediaItem() }) playbackManager.ack(this, ack) deferSave() } @@ -370,12 +368,6 @@ class ExoPlaybackStateHolder( repeatMode: RepeatMode, ack: StateAck.NewPlayback? ) { - val resolve = resolveQueue() - logD("${rawQueue.heap == resolve.heap}") - logD("${rawQueue.shuffledMapping == resolve.shuffledMapping}") - logD("${rawQueue.heapIndex == resolve.heapIndex}") - logD("${rawQueue.isShuffled == resolve.isShuffled}") - logD("${rawQueue == resolve}") var sendNewPlaybackEvent = false var shouldSeek = false if (this.parent != parent) { @@ -383,7 +375,7 @@ class ExoPlaybackStateHolder( sendNewPlaybackEvent = true } if (rawQueue != resolveQueue()) { - player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) + player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() }) if (rawQueue.isShuffled) { player.shuffleModeEnabled = true player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) @@ -548,6 +540,50 @@ class ExoPlaybackStateHolder( currentSaveJob = saveScope.launch { block() } } + private fun Song.buildMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build() + + private val MediaItem.song: Song? + get() = this.localConfiguration?.tag as? Song? + + private fun Player.unscrambleQueueIndices(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = mutableListOf() + + // Add the active queue item. + val currentMediaItemIndex = currentMediaItemIndex + queue.add(currentMediaItemIndex) + + // Fill queue alternating with next and/or previous queue items. + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { + // Begin with next to have a longer tail than head if an even sized queue needs to be + // trimmed. + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = + timeline.getNextWindowIndex( + lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(lastMediaItemIndex) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET) { + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.add(0, firstMediaItemIndex) + } + } + } + + return queue + } + class Factory @Inject constructor( @@ -563,7 +599,7 @@ class ExoPlaybackStateHolder( ) { fun create(): ExoPlaybackStateHolder { // Since Auxio is a music player, only specify an audio renderer to save - // battery/apk size/cache size + // battery/apk size/cache size] val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> arrayOf( FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt new file mode 100644 index 000000000..b5724f6b4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -0,0 +1,512 @@ +/* + * Copyright (c) 2021 Auxio Project + * MediaSessionHolder.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.annotation.DrawableRes +import androidx.car.app.mediaextensions.MetadataExtras +import androidx.core.app.NotificationCompat +import androidx.media.app.NotificationCompat.MediaStyle +import javax.inject.Inject +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R +import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.image.ImageSettings +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.music.service.toMediaDescription +import org.oxycblt.auxio.playback.ActionMode +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Progression +import org.oxycblt.auxio.playback.state.QueueChange +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.newBroadcastPendingIntent +import org.oxycblt.auxio.util.newMainPendingIntent + +/** + * A component that mirrors the current playback state into the [MediaSessionCompat] and + * [NotificationComponent]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class MediaSessionHolder +private constructor( + private val context: Context, + private val foregroundListener: ForegroundListener, + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val bitmapProvider: BitmapProvider, + private val imageSettings: ImageSettings, + private val mediaSessionInterface: MediaSessionInterface +) : PlaybackStateManager.Listener, ImageSettings.Listener, PlaybackSettings.Listener { + + class Factory + @Inject + constructor( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val bitmapProvider: BitmapProvider, + private val imageSettings: ImageSettings, + private val mediaSessionInterface: MediaSessionInterface + ) { + fun create(context: Context, foregroundListener: ForegroundListener) = + MediaSessionHolder( + context, + foregroundListener, + playbackManager, + playbackSettings, + bitmapProvider, + imageSettings, + mediaSessionInterface) + } + + private val mediaSession = MediaSessionCompat(context, context.packageName) + val token: MediaSessionCompat.Token + get() = mediaSession.sessionToken + + private val _notification = PlaybackNotification(context, mediaSession.sessionToken) + val notification: ForegroundServiceNotification + get() = _notification + + fun attach() { + playbackManager.addListener(this) + playbackSettings.registerListener(this) + imageSettings.registerListener(this) + mediaSession.apply { + isActive = true + setQueueTitle(context.getString(R.string.lbl_queue)) + setCallback(mediaSessionInterface) + } + } + + /** + * Release this instance, closing the [MediaSessionCompat] and preventing any further updates to + * the [NotificationComponent]. + */ + fun release() { + bitmapProvider.release() + playbackSettings.unregisterListener(this) + imageSettings.unregisterListener(this) + playbackManager.removeListener(this) + mediaSession.apply { + isActive = false + release() + } + } + + // --- PLAYBACKSTATEMANAGER OVERRIDES --- + + override fun onIndexMoved(index: Int) { + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + invalidateSessionState() + } + + override fun onQueueChanged(queue: List, index: Int, change: QueueChange) { + updateQueue(queue) + when (change.type) { + // Nothing special to do with mapping changes. + QueueChange.Type.MAPPING -> {} + // Index changed, ensure playback state's index changes. + QueueChange.Type.INDEX -> invalidateSessionState() + // Song changed, ensure metadata changes. + QueueChange.Type.SONG -> + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + } + } + + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + updateQueue(queue) + invalidateSessionState() + mediaSession.setShuffleMode( + if (isShuffled) { + PlaybackStateCompat.SHUFFLE_MODE_ALL + } else { + PlaybackStateCompat.SHUFFLE_MODE_NONE + }) + invalidateSecondaryAction() + } + + override fun onNewPlayback( + parent: MusicParent?, + queue: List, + index: Int, + isShuffled: Boolean + ) { + updateMediaMetadata(playbackManager.currentSong, parent) + updateQueue(queue) + invalidateSessionState() + } + + override fun onProgressionChanged(progression: Progression) { + invalidateSessionState() + _notification.updatePlaying(playbackManager.progression.isPlaying) + if (!bitmapProvider.isBusy) { + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + } + + override fun onRepeatModeChanged(repeatMode: RepeatMode) { + mediaSession.setRepeatMode( + when (repeatMode) { + RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE + RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE + RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL + }) + + invalidateSecondaryAction() + } + + // --- SETTINGS OVERRIDES --- + + override fun onImageSettingsChanged() { + // Need to reload the metadata cover. + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + } + + override fun onNotificationActionChanged() { + // Need to re-load the action shown in the notification. + invalidateSecondaryAction() + } + + // --- MEDIASESSION OVERRIDES --- + + // --- 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 album = song.album.name.resolve(context) + val builder = + MediaMetadataCompat.Builder() + .putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, album) + // Note: We would leave the artist field null if it didn't exist and let downstream + // consumers handle it, but that would break the notification display. + .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + .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, album) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs) + .putText( + PlaybackNotification.KEY_PARENT, + parent?.name?.resolve(context) ?: context.getString(R.string.lbl_all_songs)) + .putText( + MetadataExtras.KEY_SUBTITLE_LINK_MEDIA_ID, + MediaSessionUID.SingleItem(song.artists[0].uid).toString()) + .putText( + MetadataExtras.KEY_DESCRIPTION_LINK_MEDIA_ID, + MediaSessionUID.SingleItem(song.album.uid).toString()) + // These fields are nullable and so we must check first before adding them to the fields. + song.track?.let { + logD("Adding track information") + 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()) + builder.putLong(MediaMetadataCompat.METADATA_KEY_YEAR, it.year.toLong()) + } + + // We are normally supposed to use URIs for album art, but that removes some of the + // nice things we can do like square cropping or high quality covers. Instead, + // we load a full-size bitmap into the media session and take the performance hit. + bitmapProvider.load( + song, + object : BitmapProvider.Target { + override fun onCompleted(bitmap: Bitmap?) { + logD("Bitmap loaded, applying media session and posting notification") + if (bitmap != null) { + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) + } + val metadata = builder.build() + mediaSession.setMetadata(metadata) + _notification.updateMetadata(metadata) + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + }) + } + + /** + * Upload a new queue to the [MediaSessionCompat]. + * + * @param queue The current queue to upload. + */ + private fun updateQueue(queue: List) { + val queueItems = + queue.mapIndexed { i, song -> + val description = + song.toMediaDescription( + context, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) + // Store the item index so we can then use the analogous index in the + // playback state. + MediaSessionCompat.QueueItem(description, i.toLong()) + } + 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(MediaSessionInterface.ACTIONS) + // Active queue ID corresponds to the indices we populated prior, use them here. + .setActiveQueueItemId(playbackManager.index.toLong()) + + // Android 13+ relies on custom actions in the notification. + + // Add the secondary action (either repeat/shuffle depending on the configuration) + val secondaryAction = + when (playbackSettings.notificationAction) { + ActionMode.SHUFFLE -> { + logD("Using shuffle MediaSession action") + PlaybackStateCompat.CustomAction.Builder( + PlaybackActions.ACTION_INVERT_SHUFFLE, + context.getString(R.string.desc_shuffle), + if (playbackManager.isShuffled) { + R.drawable.ic_shuffle_on_24 + } else { + R.drawable.ic_shuffle_off_24 + }) + } + else -> { + logD("Using repeat mode MediaSession action") + PlaybackStateCompat.CustomAction.Builder( + PlaybackActions.ACTION_INC_REPEAT_MODE, + context.getString(R.string.desc_change_repeat), + playbackManager.repeatMode.icon) + } + } + state.addCustomAction(secondaryAction.build()) + + // Add the exit action so the service can be closed + val exitAction = + PlaybackStateCompat.CustomAction.Builder( + PlaybackActions.ACTION_EXIT, + context.getString(R.string.desc_exit), + R.drawable.ic_close_24) + .build() + state.addCustomAction(exitAction) + + mediaSession.setPlaybackState(state.build()) + } + + /** Invalidate the "secondary" action (i.e shuffle/repeat mode). */ + private fun invalidateSecondaryAction() { + logD("Invalidating secondary action") + invalidateSessionState() + + when (playbackSettings.notificationAction) { + ActionMode.SHUFFLE -> { + logD("Using shuffle notification action") + _notification.updateShuffled(playbackManager.isShuffled) + } + else -> { + logD("Using repeat mode notification action") + _notification.updateRepeatMode(playbackManager.repeatMode) + } + } + + if (!bitmapProvider.isBusy) { + logD("Not loading a bitmap, post the notification") + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + } + + companion object { + private val emptyMetadata = MediaMetadataCompat.Builder().build() + } +} + +/** + * The playback notification component. Due to race conditions regarding notification updates, this + * component is not self-sufficient. [MediaSessionHolder] should be used instead of manage it. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@SuppressLint("RestrictedApi") +private class PlaybackNotification( + private val context: Context, + sessionToken: MediaSessionCompat.Token +) : ForegroundServiceNotification(context, CHANNEL_INFO) { + init { + setSmallIcon(R.drawable.ic_auxio_24) + setCategory(NotificationCompat.CATEGORY_TRANSPORT) + setShowWhen(false) + setSilent(true) + setContentIntent(context.newMainPendingIntent()) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + + addAction(buildRepeatAction(context, RepeatMode.NONE)) + addAction( + buildAction(context, PlaybackActions.ACTION_SKIP_PREV, R.drawable.ic_skip_prev_24)) + addAction(buildPlayPauseAction(context, true)) + addAction( + buildAction(context, PlaybackActions.ACTION_SKIP_NEXT, R.drawable.ic_skip_next_24)) + addAction(buildAction(context, PlaybackActions.ACTION_EXIT, R.drawable.ic_close_24)) + + setStyle( + MediaStyle(this).setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3)) + } + + override val code: Int + get() = IntegerTable.PLAYBACK_NOTIFICATION_CODE + + // --- STATE FUNCTIONS --- + + /** + * Update the currently shown metadata in this notification. + * + * @param metadata The [MediaMetadataCompat] to display in this notification. + */ + fun updateMetadata(metadata: MediaMetadataCompat) { + logD("Updating shown metadata") + setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)) + setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE)) + setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST)) + setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION)) + } + + /** + * Update the playing state shown in this notification. + * + * @param isPlaying Whether playback should be indicated as ongoing or paused. + */ + fun updatePlaying(isPlaying: Boolean) { + logD("Updating playing state: $isPlaying") + mActions[2] = buildPlayPauseAction(context, isPlaying) + } + + /** + * Update the secondary action in this notification to show the current [RepeatMode]. + * + * @param repeatMode The current [RepeatMode]. + */ + fun updateRepeatMode(repeatMode: RepeatMode) { + logD("Applying repeat mode action: $repeatMode") + mActions[0] = buildRepeatAction(context, repeatMode) + } + + /** + * Update the secondary action in this notification to show the current shuffle state. + * + * @param isShuffled Whether the queue is currently shuffled or not. + */ + fun updateShuffled(isShuffled: Boolean) { + logD("Applying shuffle action: $isShuffled") + mActions[0] = buildShuffleAction(context, isShuffled) + } + + // --- NOTIFICATION ACTION BUILDERS --- + + private fun buildPlayPauseAction( + context: Context, + isPlaying: Boolean + ): NotificationCompat.Action { + val drawableRes = + if (isPlaying) { + R.drawable.ic_pause_24 + } else { + R.drawable.ic_play_24 + } + return buildAction(context, PlaybackActions.ACTION_PLAY_PAUSE, drawableRes) + } + + private fun buildRepeatAction( + context: Context, + repeatMode: RepeatMode + ): NotificationCompat.Action { + return buildAction(context, PlaybackActions.ACTION_INC_REPEAT_MODE, repeatMode.icon) + } + + private fun buildShuffleAction( + context: Context, + isShuffled: Boolean + ): NotificationCompat.Action { + val drawableRes = + if (isShuffled) { + R.drawable.ic_shuffle_on_24 + } else { + R.drawable.ic_shuffle_off_24 + } + return buildAction(context, PlaybackActions.ACTION_INVERT_SHUFFLE, drawableRes) + } + + private fun buildAction(context: Context, actionName: String, @DrawableRes iconRes: Int) = + NotificationCompat.Action.Builder( + iconRes, actionName, context.newBroadcastPendingIntent(actionName)) + .build() + + companion object { + const val KEY_PARENT = BuildConfig.APPLICATION_ID + ".metadata.PARENT" + + /** Notification channel used by solely the playback notification. */ + private val CHANNEL_INFO = + ChannelInfo( + id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK", + nameRes = R.string.lbl_playback) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt new file mode 100644 index 000000000..2ea4f8db2 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaSessionInterface.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import org.apache.commons.text.similarity.JaroWinklerSimilarity +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +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.device.DeviceLibrary +import org.oxycblt.auxio.music.info.Name +import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.music.service.MusicBrowser +import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.playback.state.PlaybackCommand +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.ShuffleMode +import org.oxycblt.auxio.util.logD + +class MediaSessionInterface +@Inject +constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, +) : MediaSessionCompat.Callback() { + private val jaroWinkler = JaroWinklerSimilarity() + + override fun onPrepare() { + super.onPrepare() + // STUB, we already automatically prepare playback. + } + + override fun onPrepareFromMediaId(mediaId: String?, extras: Bundle?) { + super.onPrepareFromMediaId(mediaId, extras) + // STUB, can't tell when this is called + } + + override fun onPrepareFromUri(uri: Uri?, extras: Bundle?) { + super.onPrepareFromUri(uri, extras) + // STUB, can't tell when this is called + } + + override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { + super.onPlayFromUri(uri, extras) + // STUB, can't tell when this is called + } + + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + super.onPlayFromMediaId(mediaId, extras) + val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return + val parentUid = + extras?.getString(MusicBrowser.KEY_CHILD_OF)?.let { MediaSessionUID.fromString(it) } + val command = expandUidIntoCommand(uid, parentUid) + logD(extras?.getString(MusicBrowser.KEY_CHILD_OF)) + playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) + } + + override fun onPrepareFromSearch(query: String?, extras: Bundle?) { + super.onPrepareFromSearch(query, extras) + // STUB, can't tell when this is called + } + + override fun onPlayFromSearch(query: String, extras: Bundle) { + super.onPlayFromSearch(query, extras) + val deviceLibrary = musicRepository.deviceLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return + val command = + expandSearchInfoCommand(query.ifBlank { null }, extras, deviceLibrary, userLibrary) + playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) + } + + override fun onAddQueueItem(description: MediaDescriptionCompat) { + super.onAddQueueItem(description) + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val songUid = + when (uid) { + is MediaSessionUID.SingleItem -> uid.uid + else -> return + } + val song = deviceLibrary.songs.find { it.uid == songUid } ?: return + playbackManager.addToQueue(song) + } + + override fun onRemoveQueueItem(description: MediaDescriptionCompat) { + super.onRemoveQueueItem(description) + val at = description.extras?.getInt(KEY_QUEUE_POS) + if (at != null) { + // Direct queue item removal w/preserved extras, we can explicitly remove + // the correct item rather than a duplicate elsewhere. + playbackManager.removeQueueItem(at) + return + } + // Non-queue item or queue item lost it's extras in transit, remove the first item + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val songUid = + when (uid) { + is MediaSessionUID.SingleItem -> uid.uid + else -> return + } + val firstAt = playbackManager.queue.indexOfFirst { it.uid == songUid } + playbackManager.removeQueueItem(firstAt) + } + + override fun onPlay() { + playbackManager.playing(true) + } + + override fun onPause() { + playbackManager.playing(false) + } + + override fun onSkipToNext() { + playbackManager.next() + } + + override fun onSkipToPrevious() { + playbackManager.prev() + } + + override fun onSkipToQueueItem(id: Long) { + playbackManager.goto(id.toInt()) + } + + override fun onSeekTo(position: Long) { + playbackManager.seekTo(position) + } + + override fun onFastForward() { + playbackManager.next() + } + + override fun onRewind() { + playbackManager.seekTo(0) + playbackManager.playing(true) + } + + override fun onSetRepeatMode(repeatMode: Int) { + playbackManager.repeatMode( + when (repeatMode) { + PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL + PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL + PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK + else -> RepeatMode.NONE + }) + } + + override fun onSetShuffleMode(shuffleMode: Int) { + playbackManager.shuffled( + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) + } + + override fun onStop() { + // Get the service to shut down with the ACTION_EXIT intent + context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) + } + + override fun onCustomAction(action: String, extras: Bundle?) { + super.onCustomAction(action, extras) + // Service already handles intents from the old notification actions, easier to + // plug into that system. + context.sendBroadcast(Intent(action)) + } + + private fun expandUidIntoCommand( + uid: MediaSessionUID, + parentUid: MediaSessionUID? + ): PlaybackCommand? { + val unwrappedUid = (uid as? MediaSessionUID.SingleItem)?.uid ?: return null + val unwrappedParentUid = (parentUid as? MediaSessionUID.SingleItem)?.uid + val music = musicRepository.find(unwrappedUid) ?: return null + val parent = unwrappedParentUid?.let { musicRepository.find(it) as? MusicParent } + return expandMusicIntoCommand(music, parent) + } + + @Suppress("DEPRECATION") + private fun expandSearchInfoCommand( + query: String?, + extras: Bundle, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ): PlaybackCommand? { + if (query == null) { + // User just wanted to 'play some music', shuffle all + return commandFactory.all(ShuffleMode.ON) + } + + when (extras.getString(MediaStore.EXTRA_MEDIA_FOCUS)) { + MediaStore.Audio.Media.ENTRY_CONTENT_TYPE -> { + val songQuery = extras.getString(MediaStore.EXTRA_MEDIA_TITLE) + val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) + val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) + val best = + deviceLibrary.songs.maxByOrNull { + fuzzy(it.name, songQuery) + + fuzzy(it.album.name, albumQuery) + + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } + } + if (best != null) { + return expandSongIntoCommand(best, null) + } + } + MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> { + val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) + val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) + val best = + deviceLibrary.albums.maxByOrNull { + fuzzy(it.name, albumQuery) + + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } + } + if (best != null) { + return commandFactory.album(best, ShuffleMode.OFF) + } + } + MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> { + val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) + val best = deviceLibrary.artists.maxByOrNull { fuzzy(it.name, artistQuery) } + if (best != null) { + return commandFactory.artist(best, ShuffleMode.OFF) + } + } + MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> { + val genreQuery = extras.getString(MediaStore.EXTRA_MEDIA_GENRE) + val best = deviceLibrary.genres.maxByOrNull { fuzzy(it.name, genreQuery) } + if (best != null) { + return commandFactory.genre(best, ShuffleMode.OFF) + } + } + MediaStore.Audio.Playlists.ENTRY_CONTENT_TYPE -> { + val playlistQuery = extras.getString(MediaStore.EXTRA_MEDIA_PLAYLIST) + val best = userLibrary.playlists.maxByOrNull { fuzzy(it.name, playlistQuery) } + if (best != null) { + return commandFactory.playlist(best, ShuffleMode.OFF) + } + } + else -> {} + } + + val bestMusic = + (deviceLibrary.songs + + deviceLibrary.albums + + deviceLibrary.artists + + deviceLibrary.genres + + userLibrary.playlists) + .maxByOrNull { fuzzy(it.name, query) } + // TODO: Error out when we can't correctly resolve the query + return bestMusic?.let { expandMusicIntoCommand(it, null) } + ?: commandFactory.all(ShuffleMode.ON) + } + + private fun fuzzy(name: Name, query: String?): Double = + query?.let { jaroWinkler.apply(name.resolve(context), it) } ?: 0.0 + + private fun expandMusicIntoCommand(music: Music, parent: MusicParent?) = + when (music) { + is Song -> expandSongIntoCommand(music, parent) + is Album -> commandFactory.album(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.artist(music, ShuffleMode.IMPLICIT) + is Genre -> commandFactory.genre(music, ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.playlist(music, ShuffleMode.IMPLICIT) + } + + private fun expandSongIntoCommand(music: Song, parent: MusicParent?) = + when (parent) { + is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) + null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) + } + + companion object { + const val KEY_QUEUE_POS = BuildConfig.APPLICATION_ID + ".metadata.QUEUE_POS" + const val ACTIONS = + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + 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_REWIND or + PlaybackStateCompat.ACTION_STOP + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt deleted file mode 100644 index f5ea4215c..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ /dev/null @@ -1,390 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaSessionPlayer.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.playback.service - -import android.content.Context -import android.os.Bundle -import android.view.Surface -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.view.TextureView -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.ForwardingPlayer -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.common.TrackSelectionParameters -import java.lang.Exception -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.service.MediaSessionUID -import org.oxycblt.auxio.music.service.toSong -import org.oxycblt.auxio.playback.state.PlaybackCommand -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.state.ShuffleMode -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logE - -/** - * A thin wrapper around the player instance that drastically reduces the command surface and - * forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands that - * Media3 will throw at me will be handled in a predictable way, rather than just clobbering the - * playback state. Largely limited to the legacy media APIs. - * - * I'll add more support as I go along when I can confirm that apps will use the Media3 API and send - * more advanced commands. - * - * @author Alexander Capehart - */ -class MediaSessionPlayer( - private val context: Context, - player: Player, - private val playbackManager: PlaybackStateManager, - private val commandFactory: PlaybackCommand.Factory, - private val musicRepository: MusicRepository -) : ForwardingPlayer(player) { - override fun getAvailableCommands(): Player.Commands { - return super.getAvailableCommands() - .buildUpon() - .addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - .build() - } - - override fun isCommandAvailable(command: Int): Boolean { - // We can always skip forward and backward (this is to retain parity with the old behavior) - return super.isCommandAvailable(command) || - command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - } - - override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { - if (!resetPosition) { - error("Playing MediaItems with custom position parameters is not supported") - } - - setMediaItems(mediaItems, C.INDEX_UNSET, C.TIME_UNSET) - } - - override fun getMediaMetadata() = - super.getMediaMetadata().run { - val existingExtras = extras - val newExtras = existingExtras?.let { Bundle(it) } ?: Bundle() - newExtras.apply { - putString( - "parent", - playbackManager.parent?.name?.resolve(context) - ?: context.getString(R.string.lbl_all_songs)) - } - - buildUpon().setExtras(newExtras).build() - } - - override fun setMediaItems( - mediaItems: MutableList, - startIndex: Int, - startPositionMs: Long - ) { - // We assume the only people calling this method are going to be the MediaSession callbacks. - // As part of this, we expand the given MediaItems into the command that should be sent to - // the player. - if (startIndex != C.INDEX_UNSET || startPositionMs != C.TIME_UNSET) { - error("Playing MediaItems with custom position parameters is not supported") - } - if (mediaItems.size != 1) { - error("Playing multiple MediaItems is not supported") - } - val command = expandMediaItemIntoCommand(mediaItems.first()) - requireNotNull(command) { "Invalid playback configuration" } - playbackManager.play(command) - } - - private fun expandMediaItemIntoCommand(mediaItem: MediaItem): PlaybackCommand? { - val uid = MediaSessionUID.fromString(mediaItem.mediaId) ?: return null - val music: Music - var parent: MusicParent? = null - when (uid) { - is MediaSessionUID.Single -> { - music = musicRepository.find(uid.uid) ?: return null - } - is MediaSessionUID.Joined -> { - music = musicRepository.find(uid.childUid) ?: return null - parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null - } - else -> return null - } - - return when (music) { - is Song -> inferSongFromParentCommand(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) - } - } - - private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) = - when (parent) { - is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) - is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) - is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) - is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) - null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) - } - - override fun play() = playbackManager.playing(true) - - override fun pause() = playbackManager.playing(false) - - override fun setRepeatMode(repeatMode: Int) { - val appRepeatMode = - when (repeatMode) { - Player.REPEAT_MODE_OFF -> RepeatMode.NONE - Player.REPEAT_MODE_ONE -> RepeatMode.TRACK - Player.REPEAT_MODE_ALL -> RepeatMode.ALL - else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") - } - playbackManager.repeatMode(appRepeatMode) - } - - override fun seekToDefaultPosition(mediaItemIndex: Int) { - val indices = unscrambleQueueIndices() - val fakeIndex = indices.indexOf(mediaItemIndex) - if (fakeIndex < 0) { - return - } - playbackManager.goto(fakeIndex) - } - - override fun seekToNext() = playbackManager.next() - - override fun seekToNextMediaItem() = playbackManager.next() - - override fun seekToPrevious() = playbackManager.prev() - - override fun seekToPreviousMediaItem() = playbackManager.prev() - - override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs) - - override fun seekTo(mediaItemIndex: Int, positionMs: Long) = notAllowed() - - override fun seekToDefaultPosition() = notAllowed() - - override fun addMediaItems(index: Int, mediaItems: MutableList) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } - when { - index == - currentTimeline.getNextWindowIndex( - currentMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) -> { - playbackManager.playNext(songs) - } - index >= mediaItemCount -> playbackManager.addToQueue(songs) - else -> error("Unsupported index $index") - } - } - - override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { - playbackManager.shuffled(shuffleModeEnabled) - } - - override fun moveMediaItem(currentIndex: Int, newIndex: Int) { - val indices = unscrambleQueueIndices() - val fakeFrom = indices.indexOf(currentIndex) - if (fakeFrom < 0) { - return - } - val fakeTo = - if (newIndex >= mediaItemCount) { - currentTimeline.getLastWindowIndex(shuffleModeEnabled) - } else { - indices.indexOf(newIndex) - } - if (fakeTo < 0) { - return - } - playbackManager.moveQueueItem(fakeFrom, fakeTo) - } - - override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) = - error("Multi-item queue moves are unsupported") - - override fun removeMediaItem(index: Int) { - val indices = unscrambleQueueIndices() - val fakeAt = indices.indexOf(index) - if (fakeAt < 0) { - return - } - playbackManager.removeQueueItem(fakeAt) - } - - override fun removeMediaItems(fromIndex: Int, toIndex: Int) = - error("Any multi-item queue removal is unsupported") - - override fun stop() = playbackManager.endSession() - - // These methods I don't want MediaSession calling in any way since they'll do insane things - // that I'm not tracking. If they do call them, I will know. - - override fun setMediaItem(mediaItem: MediaItem) = notAllowed() - - override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = notAllowed() - - override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) = notAllowed() - - override fun setMediaItems(mediaItems: MutableList) = notAllowed() - - override fun addMediaItem(mediaItem: MediaItem) = notAllowed() - - override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() - - override fun addMediaItems(mediaItems: MutableList) = notAllowed() - - override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() - - override fun replaceMediaItems( - fromIndex: Int, - toIndex: Int, - mediaItems: MutableList - ) = notAllowed() - - override fun clearMediaItems() = notAllowed() - - override fun setPlaybackSpeed(speed: Float) = notAllowed() - - override fun seekForward() = notAllowed() - - override fun seekBack() = notAllowed() - - @Deprecated("Deprecated in Java") override fun next() = notAllowed() - - @Deprecated("Deprecated in Java") override fun previous() = notAllowed() - - @Deprecated("Deprecated in Java") override fun seekToPreviousWindow() = notAllowed() - - @Deprecated("Deprecated in Java") override fun seekToNextWindow() = notAllowed() - - override fun prepare() = notAllowed() - - override fun release() = notAllowed() - - override fun setPlayWhenReady(playWhenReady: Boolean) = notAllowed() - - override fun hasNextMediaItem() = notAllowed() - - override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = - notAllowed() - - override fun setVolume(volume: Float) = notAllowed() - - override fun setDeviceVolume(volume: Int, flags: Int) = notAllowed() - - override fun setDeviceMuted(muted: Boolean, flags: Int) = notAllowed() - - override fun increaseDeviceVolume(flags: Int) = notAllowed() - - override fun decreaseDeviceVolume(flags: Int) = notAllowed() - - @Deprecated("Deprecated in Java") override fun increaseDeviceVolume() = notAllowed() - - @Deprecated("Deprecated in Java") override fun decreaseDeviceVolume() = notAllowed() - - @Deprecated("Deprecated in Java") override fun setDeviceVolume(volume: Int) = notAllowed() - - @Deprecated("Deprecated in Java") override fun setDeviceMuted(muted: Boolean) = notAllowed() - - override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = notAllowed() - - override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) = notAllowed() - - override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) = notAllowed() - - override fun setVideoSurface(surface: Surface?) = notAllowed() - - override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() - - override fun setVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() - - override fun setVideoTextureView(textureView: TextureView?) = notAllowed() - - override fun clearVideoSurface() = notAllowed() - - override fun clearVideoSurface(surface: Surface?) = notAllowed() - - override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() - - override fun clearVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() - - override fun clearVideoTextureView(textureView: TextureView?) = notAllowed() - - private fun notAllowed(): Nothing { - logD("MediaSession unexpectedly called this method") - logE(Exception().stackTraceToString()) - error("MediaSession unexpectedly called this method") - } -} - -fun Player.unscrambleQueueIndices(): List { - val timeline = currentTimeline - if (timeline.isEmpty) { - return emptyList() - } - val queue = mutableListOf() - - // Add the active queue item. - val currentMediaItemIndex = currentMediaItemIndex - queue.add(currentMediaItemIndex) - - // Fill queue alternating with next and/or previous queue items. - var firstMediaItemIndex = currentMediaItemIndex - var lastMediaItemIndex = currentMediaItemIndex - val shuffleModeEnabled = shuffleModeEnabled - while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { - // Begin with next to have a longer tail than head if an even sized queue needs to be - // trimmed. - if (lastMediaItemIndex != C.INDEX_UNSET) { - lastMediaItemIndex = - timeline.getNextWindowIndex( - lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (lastMediaItemIndex != C.INDEX_UNSET) { - queue.add(lastMediaItemIndex) - } - } - if (firstMediaItemIndex != C.INDEX_UNSET) { - firstMediaItemIndex = - timeline.getPreviousWindowIndex( - firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (firstMediaItemIndex != C.INDEX_UNSET) { - queue.add(0, firstMediaItemIndex) - } - } - } - - return queue -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt deleted file mode 100644 index 69f2aab6d..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaSessionServiceFragment.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.playback.service - -import android.app.Notification -import android.content.Context -import android.os.Bundle -import androidx.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 deleted file mode 100644 index b65d980db..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ /dev/null @@ -1,282 +0,0 @@ -/* - * 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.annotation.SuppressLint -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 - - @SuppressLint("WrongConstant") - 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/service/PlaybackActions.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt new file mode 100644 index 000000000..484cb8541 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 Auxio Project + * PlaybackActions.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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 org.oxycblt.auxio.BuildConfig + +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" +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt new file mode 100644 index 000000000..04af2a40f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2024 Auxio Project + * PlaybackServiceFragment.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * 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.support.v4.media.session.MediaSessionCompat +import javax.inject.Inject +import kotlinx.coroutines.Job +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.playback.state.DeferredPlayback +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.widgets.WidgetComponent + +class PlaybackServiceFragment +private constructor( + private val context: Context, + private val foregroundListener: ForegroundListener, + private val playbackManager: PlaybackStateManager, + exoHolderFactory: ExoPlaybackStateHolder.Factory, + sessionHolderFactory: MediaSessionHolder.Factory, + widgetComponentFactory: WidgetComponent.Factory, + systemReceiverFactory: SystemPlaybackReceiver.Factory, +) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener { + class Factory + @Inject + constructor( + private val playbackManager: PlaybackStateManager, + private val exoHolderFactory: ExoPlaybackStateHolder.Factory, + private val sessionHolderFactory: MediaSessionHolder.Factory, + private val widgetComponentFactory: WidgetComponent.Factory, + private val systemReceiverFactory: SystemPlaybackReceiver.Factory, + ) { + fun create(context: Context, foregroundListener: ForegroundListener) = + PlaybackServiceFragment( + context, + foregroundListener, + playbackManager, + exoHolderFactory, + sessionHolderFactory, + widgetComponentFactory, + systemReceiverFactory) + } + + private val waitJob = Job() + private val exoHolder = exoHolderFactory.create() + private val sessionHolder = sessionHolderFactory.create(context, foregroundListener) + private val widgetComponent = widgetComponentFactory.create(context) + private val systemReceiver = systemReceiverFactory.create(context, widgetComponent) + + // --- MEDIASESSION CALLBACKS --- + + fun attach(): MediaSessionCompat.Token { + exoHolder.attach() + sessionHolder.attach() + widgetComponent.attach() + systemReceiver.attach() + playbackManager.addListener(this) + return sessionHolder.token + } + + fun handleTaskRemoved() { + if (!playbackManager.progression.isPlaying) { + playbackManager.endSession() + } + } + + fun start(startedBy: Int) { + // At minimum we want to ensure an active playback state. + // TODO: Possibly also force to go foreground? + logD("Handling non-native start.") + val action = + when (startedBy) { + IntegerTable.START_ID_ACTIVITY -> null + IntegerTable.START_ID_TASKER -> + DeferredPlayback.RestoreState( + play = true, fallback = DeferredPlayback.ShuffleAll) + // External services using Auxio better know what they are doing. + else -> DeferredPlayback.RestoreState(play = false) + } + if (action != null) { + logD("Initing service fragment using action $action") + playbackManager.playDeferred(action) + } + } + + val notification: ForegroundServiceNotification? + get() = if (exoHolder.sessionOngoing) sessionHolder.notification else null + + fun release() { + waitJob.cancel() + widgetComponent.release() + context.unregisterReceiver(systemReceiver) + sessionHolder.release() + exoHolder.release() + playbackManager.removeListener(this) + } + + override fun onSessionEnded() { + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt new file mode 100644 index 000000000..4e7c214e0 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2024 Auxio Project + * SystemPlaybackReceiver.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import androidx.core.content.ContextCompat +import javax.inject.Inject +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.widgets.WidgetComponent +import org.oxycblt.auxio.widgets.WidgetProvider + +/** + * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an + * active [IntentFilter] to be registered. + */ +class SystemPlaybackReceiver +private constructor( + private val context: Context, + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val widgetComponent: WidgetComponent +) : BroadcastReceiver() { + private var initialHeadsetPlugEventHandled = false + + class Factory + @Inject + constructor( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings + ) { + fun create(context: Context, widgetComponent: WidgetComponent) = + SystemPlaybackReceiver(context, playbackManager, playbackSettings, widgetComponent) + } + + fun attach() { + ContextCompat.registerReceiver( + context, this, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) + } + + fun release() { + context.unregisterReceiver(this) + } + + override fun onReceive(context: Context, intent: Intent) { + 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) + } + } + + private companion object { + val INTENT_FILTER = + IntentFilter().apply { + addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + addAction(AudioManager.ACTION_HEADSET_PLUG) + addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) + addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) + addAction(PlaybackActions.ACTION_SKIP_PREV) + addAction(PlaybackActions.ACTION_PLAY_PAUSE) + addAction(PlaybackActions.ACTION_SKIP_NEXT) + addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index a9ca350ca..34c1d4927 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 @@ -281,7 +281,8 @@ data class QueueChange(val type: Type, val instructions: UpdateInstructions) { /** Possible long-running background tasks handled by the background playback task. */ sealed interface DeferredPlayback { /** Restore the previously saved playback state. */ - data object RestoreState : DeferredPlayback + data class RestoreState(val play: Boolean, val fallback: DeferredPlayback? = null) : + DeferredPlayback /** * Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut. diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index 7853bcca3..32c6da8de 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -61,7 +61,7 @@ interface SearchEngine { val artists: Collection? = null, val genres: Collection? = null, val playlists: Collection? = null - ) + ) {} } class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) : diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt new file mode 100644 index 000000000..1dd5c8997 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 Auxio Project + * Start.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.tasker + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.content.ContextCompat +import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutputOrInput +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutputOrInput +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigNoInput +import com.joaomgcd.taskerpluginlibrary.input.TaskerInput +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess +import org.oxycblt.auxio.AuxioService +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R + +class StartActionHelper(config: TaskerPluginConfig) : + TaskerPluginConfigHelperNoOutputOrInput(config) { + override val runnerClass: Class + get() = StartActionRunner::class.java + + override fun addToStringBlurb(input: TaskerInput, blurbBuilder: StringBuilder) { + blurbBuilder.append(context.getString(R.string.lng_tasker_start)) + } +} + +class ActivityConfigStartAction : Activity(), TaskerPluginConfigNoInput { + override val context + get() = applicationContext + + private val taskerHelper by lazy { StartActionHelper(this) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + taskerHelper.finishForTasker() + } +} + +class StartActionRunner : TaskerPluginRunnerActionNoOutputOrInput() { + override fun run(context: Context, input: TaskerInput): TaskerPluginResult { + ContextCompat.startForegroundService( + context, + Intent(context, AuxioService::class.java) + .putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_TASKER)) + while (!AuxioService.isForeground) { + Thread.sleep(100) + } + return TaskerPluginResultSucess() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetBitmapTransformation.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetBitmapTransformation.kt new file mode 100644 index 000000000..ac44e418d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetBitmapTransformation.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 Auxio Project + * WidgetBitmapTransformation.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.widgets + +import android.content.res.Resources +import android.graphics.Bitmap +import coil.size.Size +import coil.transform.Transformation +import kotlin.math.sqrt + +class WidgetBitmapTransformation(private val reduce: Float) : Transformation { + private val metrics = Resources.getSystem().displayMetrics + private val sw = metrics.widthPixels + private val sh = metrics.heightPixels + // Cap memory usage at 1.5 times the size of the display + // 1.5 * 4 bytes/pixel * w * h ==> 6 * w * h + // https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/appwidget/java/com/android/server/appwidget/AppWidgetServiceImpl.java + // Of course since OEMs randomly patch this check, we give a lot of slack. + private val maxBitmapArea = (1.5 * sw * sh / reduce).toInt() + + override val cacheKey: String + get() = "WidgetBitmapTransformation:${maxBitmapArea}" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + if (size !== Size.ORIGINAL) { + // The widget loading stack basically discards the size parameter since there's no + // sane value from the get-go, all this transform does is actually dynamically apply + // the size cap so this transform must always be zero. + throw IllegalArgumentException("WidgetBitmapTransformation requires original size.") + } + val inputArea = input.width * input.height + if (inputArea != maxBitmapArea) { + val scale = sqrt(maxBitmapArea / inputArea.toDouble()) + val newWidth = (input.width * scale).toInt() + val newHeight = (input.height * scale).toInt() + return Bitmap.createScaledBitmap(input, newWidth, newHeight, true) + } + return input + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 8179e2ab8..1ffe0d705 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -22,7 +22,7 @@ import android.content.Context import android.graphics.Bitmap import android.os.Build import coil.request.ImageRequest -import dagger.hilt.android.qualifiers.ApplicationContext +import coil.size.Size import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider @@ -46,17 +46,28 @@ import org.oxycblt.auxio.util.logD * @author Alexander Capehart (OxygenCobalt) */ class WidgetComponent -@Inject -constructor( - @ApplicationContext private val context: Context, +private constructor( + private val context: Context, private val imageSettings: ImageSettings, private val bitmapProvider: BitmapProvider, private val playbackManager: PlaybackStateManager, private val uiSettings: UISettings ) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { + class Factory + @Inject + constructor( + private val imageSettings: ImageSettings, + private val bitmapProvider: BitmapProvider, + private val playbackManager: PlaybackStateManager, + private val uiSettings: UISettings + ) { + fun create(context: Context) = + WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings) + } + private val widgetProvider = WidgetProvider() - init { + fun attach() { playbackManager.addListener(this) uiSettings.registerListener(this) imageSettings.registerListener(this) @@ -96,24 +107,19 @@ constructor( 0 } - return if (cornerRadius > 0) { - // If rounded, reduce the bitmap size further to obtain more pronounced - // rounded corners. - builder.size(getSafeRemoteViewsImageSize(context, 10f)) - val cornersTransformation = - RoundedRectTransformation(cornerRadius.toFloat()) + val transformations = buildList { if (imageSettings.forceSquareCovers) { - builder.transformations( - SquareCropTransformation.INSTANCE, cornersTransformation) + add(SquareCropTransformation.INSTANCE) + } + if (cornerRadius > 0) { + add(WidgetBitmapTransformation(15f)) + add(RoundedRectTransformation(cornerRadius.toFloat())) } else { - builder.transformations(cornersTransformation) + add(WidgetBitmapTransformation(3f)) } - } else { - if (imageSettings.forceSquareCovers) { - builder.transformations(SquareCropTransformation.INSTANCE) - } - builder.size(getSafeRemoteViewsImageSize(context)) } + + return builder.size(Size.ORIGINAL).transformations(transformations) } override fun onCompleted(bitmap: Bitmap?) { diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt index 3d02fa1ab..953af14c8 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt @@ -27,7 +27,6 @@ import android.widget.RemoteViews import androidx.annotation.DrawableRes import androidx.annotation.IdRes import androidx.annotation.LayoutRes -import kotlin.math.sqrt import org.oxycblt.auxio.util.isLandscape import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent @@ -46,24 +45,6 @@ fun newRemoteViews(context: Context, @LayoutRes layoutRes: Int): RemoteViews { return views } -/** - * Get an image size guaranteed to not exceed the [RemoteViews] bitmap memory limit, assuming that - * there is only one image. - * - * @param context [Context] required to perform calculation. - * @param reduce Optional multiplier to reduce the image size. Recommended value is 3 to avoid - * device-specific variations in memory limit. - * @return The dimension of a bitmap that can be safely used in [RemoteViews]. - */ -fun getSafeRemoteViewsImageSize(context: Context, reduce: Float = 3f): Int { - val metrics = context.resources.displayMetrics - val sw = metrics.widthPixels - val sh = metrics.heightPixels - // Maximum size is 1/3 total screen area * 4 bytes per pixel. Reverse - // that to obtain the image size. - return sqrt((6f / 4f / reduce) * sw * sh).toInt() -} - /** * Set the background resource of a [RemoteViews] View. * diff --git a/app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png new file mode 100644 index 000000000..ee3339aa0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png new file mode 100644 index 000000000..fabe49c28 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png new file mode 100644 index 000000000..16c932bde Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png new file mode 100644 index 000000000..37710f06b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png new file mode 100644 index 000000000..b92809953 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6408e9478..d59a2038b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -153,6 +153,7 @@ Shuffle Shuffle all + Start playback OK Cancel @@ -162,6 +163,7 @@ Reset Add + More Path style Absolute @@ -205,6 +207,10 @@ Donate to the project to get your name added here! Search your library… + + Starts Auxio using the previously saved state. If no saved state is available, all songs will be shuffled. Playback will start immediately. + \n\nWARNING: Be careful controlling this service, if you close it and then try to use it again, you will probably crash the app. + diff --git a/fastlane/metadata/android/en-US/changelogs/48.txt b/fastlane/metadata/android/en-US/changelogs/48.txt new file mode 100644 index 000000000..44e3fc9d1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/48.txt @@ -0,0 +1,3 @@ +Auxio 3.5.0 adds support for android auto alongside various playback and music quality of life improvements. +This release fixes a critical bug with the music loader. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.2 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/49.txt b/fastlane/metadata/android/en-US/changelogs/49.txt new file mode 100644 index 000000000..62d3f517b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/49.txt @@ -0,0 +1,3 @@ +Auxio 3.5.0 adds support for android auto alongside various playback and music quality of life improvements. +This release adds basic Tasker integration while fixing a few issues that affected certain devices. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.3 diff --git a/fastlane/metadata/android/en-US/changelogs/50.txt b/fastlane/metadata/android/en-US/changelogs/50.txt new file mode 100644 index 000000000..bd58c45f8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/50.txt @@ -0,0 +1,2 @@ +Auxio 3.6.0 improves support for android auto and fixes several small regressions. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.3