Merge branch 'master' into dev

This commit is contained in:
Alexander Capehart 2024-10-14 14:33:24 -06:00
commit 8d767a0aac
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
55 changed files with 2913 additions and 2109 deletions

View file

@ -11,6 +11,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Install ninja-build
run: sudo apt-get install -y ninja-build
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Clone submodules - name: Clone submodules

View file

@ -16,6 +16,42 @@
- Excessive CPU no longer spent showing music loading process - Excessive CPU no longer spent showing music loading process
- Fixed playback sheet flickering on warm start - 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 ## 3.5.1
#### What's Fixed #### What's Fixed

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.5.1"> <a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.6.0">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.5.1&color=64B5F6&style=flat"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.6.0&color=64B5F6&style=flat">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat"> <img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">

View file

@ -16,13 +16,13 @@ android {
// it here so that binary stripping will work. // it here so that binary stripping will work.
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
// NDK use is unified // NDK use is unified
ndkVersion = "25.2.9519653" ndkVersion "26.3.11579264"
namespace "org.oxycblt.auxio" namespace "org.oxycblt.auxio"
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "3.5.1" versionName "3.6.0"
versionCode 47 versionCode 50
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
@ -118,6 +118,9 @@ dependencies {
// Media // Media
implementation "androidx.media:media:1.7.0" implementation "androidx.media:media:1.7.0"
// Android Auto
implementation "androidx.car.app:app:1.4.0"
// Preferences // Preferences
implementation "androidx.preference:preference-ktx:1.2.1" implementation "androidx.preference:preference-ktx:1.2.1"
@ -130,7 +133,6 @@ dependencies {
// --- THIRD PARTY --- // --- THIRD PARTY ---
// Exoplayer (Vendored) // Exoplayer (Vendored)
implementation project(":media-lib-session")
implementation project(":media-lib-exoplayer") implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg") implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
@ -155,6 +157,12 @@ dependencies {
// Speed dial // Speed dial
implementation "com.leinardi.android:speed-dial:3.3.0" 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 // Testing
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2" testImplementation "junit:junit:4.13.2"

View file

@ -94,7 +94,6 @@
android:exported="true" android:exported="true"
android:roundIcon="@mipmap/ic_launcher"> android:roundIcon="@mipmap/ic_launcher">
<intent-filter> <intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
<action android:name="android.media.browse.MediaBrowserService"/> <action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter> </intent-filter>
</service> </service>
@ -135,5 +134,15 @@
android:resource="@xml/widget_info" /> android:resource="@xml/widget_info" />
</receiver> </receiver>
<!-- Tasker 'start service' integration -->
<activity
android:name=".tasker.ActivityConfigStartAction"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/lbl_start_playback">
<intent-filter>
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
</intent-filter>
</activity>
</application> </application>
</manifest> </manifest>

View file

@ -19,92 +19,147 @@
package org.oxycblt.auxio package org.oxycblt.auxio
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle
import android.os.IBinder 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.core.app.ServiceCompat
import androidx.media3.session.MediaLibraryService import androidx.media.MediaBrowserServiceCompat
import androidx.media3.session.MediaSession import androidx.media.utils.MediaConstants
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.service.IndexerServiceFragment import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
import org.oxycblt.auxio.util.logD
@AndroidEntryPoint @AndroidEntryPoint
class AuxioService : MediaLibraryService(), ForegroundListener { class AuxioService :
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment 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") @SuppressLint("WrongConstant")
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
mediaSessionFragment.attach(this, this) playbackFragment = playbackFragmentFactory.create(this, this)
indexingFragment.attach(this) sessionToken = playbackFragment.attach()
} musicFragment = musicFragmentFactory.create(this, this, this)
musicFragment.attach()
override fun onBind(intent: Intent?): IBinder? {
start(intent)
return super.onBind(intent)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// TODO: Start command occurring from a foreign service basically implies a detached // TODO: Start command occurring from a foreign service basically implies a detached
// service, we might need more handling here. // service, we might need more handling here.
start(intent) onHandleForeground(intent)
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
private fun start(intent: Intent?) { override fun onBind(intent: Intent): IBinder? {
val nativeStart = intent?.getBooleanExtra(INTENT_KEY_NATIVE_START, false) ?: false onHandleForeground(intent)
if (!nativeStart) { return super.onBind(intent)
// Some foreign code started us, no guarantees about foreground stability. Figure }
// out what to do.
mediaSessionFragment.handleNonNativeStart() private fun onHandleForeground(intent: Intent?) {
} val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
indexingFragment.start() musicFragment.start()
playbackFragment.start(startId)
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent) super.onTaskRemoved(rootIntent)
mediaSessionFragment.handleTaskRemoved() playbackFragment.handleTaskRemoved()
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
indexingFragment.release() musicFragment.release()
mediaSessionFragment.release() playbackFragment.release()
} }
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = override fun onGetRoot(
mediaSessionFragment.mediaSession clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot {
return musicFragment.getRoot()
}
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
updateForeground(ForegroundListener.Change.MEDIA_SESSION) musicFragment.getItem(itemId, result)
}
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaItem>>) {
val maximumRootChildLimit = getRootChildrenLimit()
musicFragment.getChildren(parentId, maximumRootChildLimit, result, null)
}
override fun onLoadChildren(
parentId: String,
result: Result<MutableList<MediaItem>>,
options: Bundle
) {
val maximumRootChildLimit = getRootChildrenLimit()
musicFragment.getChildren(parentId, maximumRootChildLimit, result, options.getPage())
}
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaItem>>) {
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) { override fun updateForeground(change: ForegroundListener.Change) {
if (mediaSessionFragment.hasNotification()) { val mediaNotification = playbackFragment.notification
if (mediaNotification != null) {
if (change == ForegroundListener.Change.MEDIA_SESSION) { if (change == ForegroundListener.Change.MEDIA_SESSION) {
mediaSessionFragment.createNotification { startForeground(mediaNotification.code, mediaNotification.build())
startForeground(it.notificationId, it.notification)
}
} }
// Nothing changed, but don't show anything music related since we can always // Nothing changed, but don't show anything music related since we can always
// index during playback. // index during playback.
} else { } else {
indexingFragment.createNotification { musicFragment.createNotification {
if (it != null) { if (it != null) {
startForeground(it.code, it.build()) startForeground(it.code, it.build())
isForeground = true
} else { } else {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
isForeground = false
} }
} }
} }
} }
override fun invalidateMusic(mediaId: String) {
logD(mediaId)
notifyChildrenChanged(mediaId)
}
companion object { companion object {
var isForeground = false
private set
// This is only meant for Auxio to internally ensure that it's state management will work. // 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 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)
}

View file

@ -59,6 +59,10 @@ object IntegerTable {
const val INDEXER_NOTIFICATION_CODE = 0xA0A1 const val INDEXER_NOTIFICATION_CODE = 0xA0A1
/** MainActivity Intent request code */ /** MainActivity Intent request code */
const val REQUEST_CODE = 0xA0C0 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 */ /** RepeatMode.NONE */
const val REPEAT_MODE_NONE = 0xA100 const val REPEAT_MODE_NONE = 0xA100
/** RepeatMode.ALL */ /** RepeatMode.ALL */

View file

@ -71,11 +71,11 @@ class MainActivity : AppCompatActivity() {
startService( startService(
Intent(this, AuxioService::class.java) 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)) { if (!startIntentAction(intent)) {
// No intent action to do, just restore the previously saved state. // No intent action to do, just restore the previously saved state.
playbackModel.playDeferred(DeferredPlayback.RestoreState) playbackModel.playDeferred(DeferredPlayback.RestoreState(false))
} }
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<out MusicParent>?
fun album(uid: Music.UID): Detail<Album>?
fun artist(uid: Music.UID): Detail<Artist>?
fun genre(uid: Music.UID): Detail<Genre>?
fun playlist(uid: Music.UID): Detail<Playlist>?
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<out MusicParent>? {
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<Album>? {
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<Artist>? {
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, Collection<Album>>)[
DetailSection.Albums.Category.APPEARANCES] = artist.implicitAlbums
}
val sections =
grouping.mapTo(mutableListOf<DetailSection>()) { (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<Genre>? {
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<Playlist>? {
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<P : MusicParent>(val parent: P, val sections: List<DetailSection>)
sealed interface DetailSection {
val order: Int
val stringRes: Int
abstract class PlainSection<T : Music> : DetailSection {
abstract val items: List<T>
}
data class Artists(override val items: List<Artist>) : PlainSection<Artist>() {
override val order = 0
override val stringRes = R.string.lbl_artists
}
data class Albums(val category: Category, override val items: List<Album>) :
PlainSection<Album>() {
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<Song>) : PlainSection<Song>() {
override val order = 12
override val stringRes = R.string.lbl_songs
}
data class Discs(val discs: Map<Disc?, List<Song>>) : DetailSection {
override val order = 13
override val stringRes = R.string.lbl_songs
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.AudioProperties import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
@ -69,8 +69,9 @@ constructor(
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
private val audioPropertiesFactory: AudioProperties.Factory, private val audioPropertiesFactory: AudioProperties.Factory,
private val playbackSettings: PlaybackSettings private val playbackSettings: PlaybackSettings,
) : ViewModel(), MusicRepository.UpdateListener { detailGeneratorFactory: DetailGenerator.Factory
) : ViewModel(), DetailGenerator.Invalidator {
private val _toShow = MutableEvent<Show>() private val _toShow = MutableEvent<Show>()
/** /**
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently. * A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
@ -133,13 +134,8 @@ constructor(
get() = _artistSongInstructions get() = _artistSongInstructions
/** The current [Sort] used for [Song]s in [artistSongList]. */ /** The current [Sort] used for [Song]s in [artistSongList]. */
var artistSongSort: Sort val artistSongSort: Sort
get() = listSettings.artistSongSort 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. */ /** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */
val playInArtistWith val playInArtistWith
@ -162,13 +158,8 @@ constructor(
get() = _genreSongInstructions get() = _genreSongInstructions
/** The current [Sort] used for [Song]s in [genreSongList]. */ /** The current [Sort] used for [Song]s in [genreSongList]. */
var genreSongSort: Sort val genreSongSort: Sort
get() = listSettings.genreSongSort 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. */ /** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
val playInGenreWith val playInGenreWith
@ -204,54 +195,35 @@ constructor(
playbackSettings.inParentPlaybackMode playbackSettings.inParentPlaybackMode
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value)) ?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
private val detailGenerator = detailGeneratorFactory.create(this)
init { init {
musicRepository.addUpdateListener(this) detailGenerator.attach()
} }
override fun onCleared() { override fun onCleared() {
musicRepository.removeUpdateListener(this) detailGenerator.release()
} }
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun invalidate(type: MusicType, replace: Int?) {
// If we are showing any item right now, we will need to refresh it (and any information when (type) {
// related to it) with the new library in order to prevent stale items from showing up MusicType.ALBUMS -> {
// in the UI. val album = detailGenerator.album(currentAlbum.value?.uid ?: return)
val deviceLibrary = musicRepository.deviceLibrary refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace)
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}")
} }
MusicType.ARTISTS -> {
val album = currentAlbum.value val artist = detailGenerator.artist(currentArtist.value?.uid ?: return)
if (album != null) { refreshDetail(
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList) artist, _currentArtist, _artistSongList, _artistSongInstructions, replace)
logD("Updated album to ${currentAlbum.value}")
} }
MusicType.GENRES -> {
val artist = currentArtist.value val genre = detailGenerator.genre(currentGenre.value?.uid ?: return)
if (artist != null) { refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace)
_currentArtist.value =
deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
logD("Updated artist to ${currentArtist.value}")
} }
MusicType.PLAYLISTS -> {
val genre = currentGenre.value refreshPlaylist(currentPlaylist.value?.uid ?: return)
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}")
} }
else -> error("Unexpected music type $type")
} }
} }
@ -356,8 +328,11 @@ constructor(
*/ */
fun setAlbum(uid: Music.UID) { fun setAlbum(uid: Music.UID) {
logD("Opening album $uid") logD("Opening album $uid")
_currentAlbum.value = if (uid === _currentAlbum.value?.uid) {
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList) return
}
val album = detailGenerator.album(uid)
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null)
if (_currentAlbum.value == null) { if (_currentAlbum.value == null) {
logW("Given album UID was invalid") logW("Given album UID was invalid")
} }
@ -370,7 +345,6 @@ constructor(
*/ */
fun applyAlbumSongSort(sort: Sort) { fun applyAlbumSongSort(sort: Sort) {
listSettings.albumSongSort = sort listSettings.albumSongSort = sort
_currentAlbum.value?.let { refreshAlbumList(it, true) }
} }
/** /**
@ -381,11 +355,11 @@ constructor(
*/ */
fun setArtist(uid: Music.UID) { fun setArtist(uid: Music.UID) {
logD("Opening artist $uid") logD("Opening artist $uid")
_currentArtist.value = if (uid === _currentArtist.value?.uid) {
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList) return
if (_currentArtist.value == null) {
logW("Given artist UID was invalid")
} }
val artist = detailGenerator.artist(uid)
refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null)
} }
/** /**
@ -395,7 +369,6 @@ constructor(
*/ */
fun applyArtistSongSort(sort: Sort) { fun applyArtistSongSort(sort: Sort) {
listSettings.artistSongSort = sort listSettings.artistSongSort = sort
_currentArtist.value?.let { refreshArtistList(it, true) }
} }
/** /**
@ -406,11 +379,11 @@ constructor(
*/ */
fun setGenre(uid: Music.UID) { fun setGenre(uid: Music.UID) {
logD("Opening genre $uid") logD("Opening genre $uid")
_currentGenre.value = if (uid === _currentGenre.value?.uid) {
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) return
if (_currentGenre.value == null) {
logW("Given genre UID was invalid")
} }
val genre = detailGenerator.genre(uid)
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null)
} }
/** /**
@ -420,7 +393,6 @@ constructor(
*/ */
fun applyGenreSongSort(sort: Sort) { fun applyGenreSongSort(sort: Sort) {
listSettings.genreSongSort = sort listSettings.genreSongSort = sort
_currentGenre.value?.let { refreshGenreList(it, true) }
} }
/** /**
@ -431,11 +403,10 @@ constructor(
*/ */
fun setPlaylist(uid: Music.UID) { fun setPlaylist(uid: Music.UID) {
logD("Opening playlist $uid") logD("Opening playlist $uid")
_currentPlaylist.value = if (uid === _currentPlaylist.value?.uid) {
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList) return
if (_currentPlaylist.value == null) {
logW("Given playlist UID was invalid")
} }
refreshPlaylist(uid)
} }
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */ /** Start a playlist editing session. Does nothing if a playlist is not being shown. */
@ -443,7 +414,7 @@ constructor(
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
logD("Starting playlist edit") logD("Starting playlist edit")
_editedPlaylist.value = playlist.songs _editedPlaylist.value = playlist.songs
refreshPlaylistList(playlist) refreshPlaylist(playlist.uid)
} }
/** /**
@ -474,9 +445,8 @@ constructor(
// Nothing to do. // Nothing to do.
return false return false
} }
logD("Discarding playlist edits")
_editedPlaylist.value = null _editedPlaylist.value = null
refreshPlaylistList(playlist) refreshPlaylist(playlist.uid)
return true return true
} }
@ -488,7 +458,7 @@ constructor(
fun applyPlaylistSongSort(sort: Sort) { fun applyPlaylistSongSort(sort: Sort) {
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
_editedPlaylist.value = sort.songs(_editedPlaylist.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 { fun movePlaylistSongs(from: Int, to: Int): Boolean {
val playlist = _currentPlaylist.value ?: return false val playlist = _currentPlaylist.value ?: return false
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList() val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
val realFrom = from - 2 val realFrom = from - 1
val realTo = to - 2 val realTo = to - 1
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) { if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
return false return false
} }
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]") logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo)) editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
_editedPlaylist.value = editedPlaylist _editedPlaylist.value = editedPlaylist
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to)) refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
return true return true
} }
@ -521,20 +491,20 @@ constructor(
fun removePlaylistSong(at: Int) { fun removePlaylistSong(at: Int) {
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList() val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
val realAt = at - 2 val realAt = at - 1
if (realAt !in editedPlaylist.indices) { if (realAt !in editedPlaylist.indices) {
return return
} }
logD("Removing playlist song at $realAt [$at]") logD("Removing playlist song at $realAt [$at]")
editedPlaylist.removeAt(realAt) editedPlaylist.removeAt(realAt)
_editedPlaylist.value = editedPlaylist _editedPlaylist.value = editedPlaylist
refreshPlaylistList( refreshPlaylist(
playlist, playlist.uid,
if (editedPlaylist.isNotEmpty()) { if (editedPlaylist.isNotEmpty()) {
UpdateInstructions.Remove(at, 1) UpdateInstructions.Remove(at, 1)
} else { } else {
logD("Playlist will be empty after removal, removing header") 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) { private fun <T : MusicParent> refreshDetail(
logD("Refreshing album list") detail: Detail<T>?,
val list = mutableListOf<Item>() parent: MutableStateFlow<T?>,
val header = SortHeader(R.string.lbl_songs) list: MutableStateFlow<List<Item>>,
list.add(header) instructions: MutableEvent<UpdateInstructions>,
val instructions = replace: Int?
if (replace) { ) {
// Intentional so that the header item isn't replaced with the songs if (detail == null) {
UpdateInstructions.Replace(list.size) parent.value = null
} else { return
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)
} }
val newList = mutableListOf<Item>()
logD("Update album list to ${list.size} items with $instructions") var newInstructions: UpdateInstructions = UpdateInstructions.Diff
_albumSongInstructions.put(instructions) for ((i, section) in detail.sections.withIndex()) {
_albumSongList.value = list val items =
} when (section) {
is DetailSection.PlainSection<*> -> {
private fun refreshArtistList(artist: Artist, replace: Boolean = false) { val header =
logD("Refreshing artist list") if (section is DetailSection.Songs) SortHeader(section.stringRes)
val list = mutableListOf<Item>() else BasicHeader(section.stringRes)
newList.add(Divider(header))
val grouping = newList.add(header)
artist.explicitAlbums.groupByTo(sortedMapOf()) { section.items
// Remap the complicated ReleaseType data structure into an easier }
// "AlbumGrouping" enum that will automatically group and sort is DetailSection.Discs -> {
// the artist's albums. val header = SortHeader(section.stringRes)
when (it.releaseType.refinement) { newList.add(Divider(header))
ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE newList.add(header)
ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value }
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
}
} }
} // Currently only the final section (songs, which can be sorted) are invalidatable
// and thus need to be replaced.
if (artist.implicitAlbums.isNotEmpty()) { if (replace == -1 && i == detail.sections.lastIndex) {
// 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, Collection<Album>>)[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) {
// Intentional so that the header item isn't replaced with the songs // 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)
} }
parent.value = detail.parent
logD("Updating artist list to ${list.size} items with $instructions") instructions.put(newInstructions)
_artistSongInstructions.put(instructions) list.value = newList
_artistSongList.value = list.toList()
} }
private fun refreshGenreList(genre: Genre, replace: Boolean = false) { private fun refreshPlaylist(
logD("Refreshing genre list") uid: Music.UID,
val list = mutableListOf<Item>()
// 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,
instructions: UpdateInstructions = UpdateInstructions.Diff instructions: UpdateInstructions = UpdateInstructions.Diff
) { ) {
logD("Refreshing playlist list") logD("Refreshing playlist list")
val list = mutableListOf<Item>() val edited = editedPlaylist.value
if (edited == null) {
val songs = editedPlaylist.value ?: playlist.songs val playlist = detailGenerator.playlist(uid)
if (songs.isNotEmpty()) { refreshDetail(
val header = EditHeader(R.string.lbl_songs) playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null)
list.add(header) return
list.addAll(songs) }
val list = mutableListOf<Item>()
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) _playlistSongInstructions.put(instructions)
_playlistSongList.value = list _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)
}
} }
/** /**

View file

@ -35,10 +35,10 @@ import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.resolveNumber
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater 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. * 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) { fun bind(discHeader: DiscHeader) {
val disc = discHeader.inner val disc = discHeader.inner
if (disc != null) { binding.discNumber.text = disc.resolveNumber(binding.context)
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number) binding.discName.apply {
binding.discName.apply { text = disc?.name
text = disc.name isGone = disc?.name == null
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
} }
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Song>
fun albums(): List<Album>
fun artists(): List<Artist>
fun genres(): List<Genre>
fun playlists(): List<Playlist>
fun tabs(): List<MusicType>
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<Tab.Visible>().map { it.type }
}

View file

@ -27,4 +27,6 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface HomeModule { interface HomeModule {
@Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings @Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
@Binds fun homeGeneratorFactory(factory: HomeGeneratorFactoryImpl): HomeGenerator.Factory
} }

View file

@ -42,9 +42,9 @@ interface HomeSettings : Settings<HomeSettings.Listener> {
interface Listener { interface Listener {
/** Called when the [homeTabs] configuration changes. */ /** Called when the [homeTabs] configuration changes. */
fun onTabsChanged() fun onTabsChanged() {}
/** Called when the [shouldHideCollaborators] configuration changes. */ /** Called when the [shouldHideCollaborators] configuration changes. */
fun onHideCollaboratorsChanged() fun onHideCollaboratorsChanged() {}
} }
} }

View file

@ -30,7 +30,6 @@ import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
@ -49,12 +48,10 @@ import org.oxycblt.auxio.util.logD
class HomeViewModel class HomeViewModel
@Inject @Inject
constructor( constructor(
private val homeSettings: HomeSettings,
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings,
private val musicRepository: MusicRepository, homeGeneratorFactory: HomeGenerator.Factory
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener { ) : ViewModel(), HomeGenerator.Invalidator {
private val _songList = MutableStateFlow(listOf<Song>()) private val _songList = MutableStateFlow(listOf<Song>())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
val songList: StateFlow<List<Song>> val songList: StateFlow<List<Song>>
@ -132,11 +129,13 @@ constructor(
val playlistSort: Sort val playlistSort: Sort
get() = listSettings.playlistSort get() = listSettings.playlistSort
private val homeGenerator = homeGeneratorFactory.create(this)
/** /**
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible * A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
* [Tab]s. * [Tab]s.
*/ */
var currentTabTypes = makeTabTypes() var currentTabTypes = homeGenerator.tabs()
private set private set
private val _currentTabType = MutableStateFlow(currentTabTypes[0]) private val _currentTabType = MutableStateFlow(currentTabTypes[0])
@ -161,63 +160,44 @@ constructor(
get() = _showOuter get() = _showOuter
init { init {
musicRepository.addUpdateListener(this) homeGenerator.attach()
homeSettings.registerListener(this)
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
musicRepository.removeUpdateListener(this) homeGenerator.release()
homeSettings.unregisterListener(this)
} }
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
val deviceLibrary = musicRepository.deviceLibrary when (type) {
if (changes.deviceLibrary && deviceLibrary != null) { MusicType.SONGS -> {
logD("Refreshing library") _songInstructions.put(instructions)
// Get the each list of items in the library to use as our list data. _songList.value = homeGenerator.songs()
// Applying the preferred sorting to them. }
_songInstructions.put(UpdateInstructions.Diff) MusicType.ALBUMS -> {
_songList.value = listSettings.songSort.songs(deviceLibrary.songs) _albumInstructions.put(instructions)
_albumInstructions.put(UpdateInstructions.Diff) _albumList.value = homeGenerator.albums()
_albumList.value = listSettings.albumSort.albums(deviceLibrary.albums) }
_artistInstructions.put(UpdateInstructions.Diff) MusicType.ARTISTS -> {
_artistList.value = _artistInstructions.put(instructions)
listSettings.artistSort.artists( _artistList.value = homeGenerator.artists()
if (homeSettings.shouldHideCollaborators) { }
logD("Filtering collaborator artists") MusicType.GENRES -> {
// Hide Collaborators is enabled, filter out collaborators. _genreInstructions.put(instructions)
deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() } _genreList.value = homeGenerator.genres()
} else { }
logD("Using all artists") MusicType.PLAYLISTS -> {
deviceLibrary.artists _playlistInstructions.put(instructions)
}) _playlistList.value = homeGenerator.playlists()
_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 onTabsChanged() { override fun invalidateTabs() {
// Tabs changed, update the current tabs and set up a re-create event. currentTabTypes = homeGenerator.tabs()
currentTabTypes = makeTabTypes()
logD("Updating tabs: ${currentTabType.value}")
_shouldRecreate.put(Unit) _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]. * Apply a new [Sort] to [songList].
* *
@ -225,8 +205,6 @@ constructor(
*/ */
fun applySongSort(sort: Sort) { fun applySongSort(sort: Sort) {
listSettings.songSort = 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) { fun applyAlbumSort(sort: Sort) {
listSettings.albumSort = 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) { fun applyArtistSort(sort: Sort) {
listSettings.artistSort = 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) { fun applyGenreSort(sort: Sort) {
listSettings.genreSort = 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) { fun applyPlaylistSort(sort: Sort) {
listSettings.playlistSort = sort listSettings.playlistSort = sort
_playlistInstructions.put(UpdateInstructions.Replace(0))
_playlistList.value = listSettings.playlistSort.playlists(_playlistList.value)
} }
/** /**
@ -300,15 +270,6 @@ constructor(
fun showAbout() { fun showAbout() {
_showOuter.put(Outer.About) _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<Tab.Visible>().map { it.type }
} }
sealed interface Outer { sealed interface Outer {

View file

@ -37,40 +37,24 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
private val width = context.resources.configuration.smallestScreenWidthDp private val width = context.resources.configuration.smallestScreenWidthDp
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val icon: Int val homeTab = tabs[position]
val string: Int val icon =
when (homeTab) {
when (tabs[position]) { MusicType.SONGS -> R.drawable.ic_song_24
MusicType.SONGS -> { MusicType.ALBUMS -> R.drawable.ic_album_24
icon = R.drawable.ic_song_24 MusicType.ARTISTS -> R.drawable.ic_artist_24
string = R.string.lbl_songs 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. // Use expected sw* size thresholds when choosing a configuration.
when { when {
// On small screens, only display an icon. // 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. // 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. // On medium-size screens, display text.
else -> tab.setIcon(icon).setText(string) else -> tab.setIcon(icon).setText(homeTab.nameRes)
} }
} }
} }

View file

@ -107,7 +107,10 @@ class RoundedRectTransformation(
} }
private fun calculateOutputSize(input: Bitmap, size: Size): Pair<Int, Int> { private fun calculateOutputSize(input: Bitmap, size: Size): Pair<Int, Int> {
// 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 = val multiplier =
DecodeUtils.computeSizeMultiplier( DecodeUtils.computeSizeMultiplier(
srcWidth = input.width, srcWidth = input.width,

View file

@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
interface ListSettings : Settings<Unit> { interface ListSettings : Settings<ListSettings.Listener> {
/** The [Sort] mode used in Song lists. */ /** The [Sort] mode used in Song lists. */
var songSort: Sort var songSort: Sort
/** The [Sort] mode used in Album lists. */ /** The [Sort] mode used in Album lists. */
@ -43,10 +43,28 @@ interface ListSettings : Settings<Unit> {
var artistSongSort: Sort var artistSongSort: Sort
/** The [Sort] mode used in a Genre's Song list. */ /** The [Sort] mode used in a Genre's Song list. */
var genreSongSort: Sort 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) : class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) :
Settings.Impl<Unit>(context), ListSettings { Settings.Impl<ListSettings.Listener>(context), ListSettings {
override var songSort: Sort override var songSort: Sort
get() = get() =
Sort.fromIntCode( Sort.fromIntCode(
@ -145,4 +163,17 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont
apply() 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()
}
}
} }

View file

@ -19,6 +19,7 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
/** /**
* General configuration enum to control what kind of music is being worked with. * 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 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 { companion object {
/** /**
* Convert a [MusicType] integer representation into an instance. * Convert a [MusicType] integer representation into an instance.

View file

@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped 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 class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao abstract fun cachedSongsDao(): CachedSongsDao
} }

View file

@ -18,6 +18,8 @@
package org.oxycblt.auxio.music.info package org.oxycblt.auxio.music.info
import android.content.Context
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
/** /**
@ -34,3 +36,7 @@ class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
override fun compareTo(other: Disc) = number.compareTo(other.number) 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)

View file

@ -70,12 +70,12 @@ sealed interface Name : Comparable<Name> {
final override fun compareTo(other: Name) = final override fun compareTo(other: Name) =
when (other) { when (other) {
is Known -> { is Known -> {
// Progressively compare the sort tokens between each known name. val result =
sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) -> sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) ->
acc.takeIf { it != 0 } ?: token.compareTo(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 is Unknown -> 1
} }

View file

@ -100,6 +100,7 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
private fun populateWithId3v2(rawSong: RawSong, textFrames: Map<String, List<String>>) { private fun populateWithId3v2(rawSong: RawSong, textFrames: Map<String, List<String>>) {
// Song // Song
logD(textFrames)
(textFrames["TXXX:musicbrainz release track id"] (textFrames["TXXX:musicbrainz release track id"]
?: textFrames["TXXX:musicbrainz_releasetrackid"]) ?: textFrames["TXXX:musicbrainz_releasetrackid"])
?.let { rawSong.musicBrainzId = it.first() } ?.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 { (textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
rawSong.artistMusicBrainzIds = it 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:artistssort"]
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
?: textFrames["TSOP"]) ?: textFrames["TSOP"] ?: textFrames["artistsort"]
?: textFrames["TXXX:artist sort"])
?.let { rawSong.artistSortNames = it } ?.let { rawSong.artistSortNames = it }
// Album artist // Album artist
@ -159,13 +163,14 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
?.let { rawSong.albumArtistMusicBrainzIds = it } ?.let { rawSong.albumArtistMusicBrainzIds = it }
(textFrames["TXXX:albumartists"] (textFrames["TXXX:albumartists"]
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
?: textFrames["TPE2"]) ?: textFrames["TPE2"] ?: textFrames["TXXX:albumartist"]
?: textFrames["TXXX:album artist"])
?.let { rawSong.albumArtistNames = it } ?.let { rawSong.albumArtistNames = it }
(textFrames["TXXX:albumartistssort"] (textFrames["TXXX:albumartistssort"]
?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"] ?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"]
?: textFrames["TXXX:albumartistsort"] ?: textFrames["TXXX:albumartistsort"]
// This is a non-standard iTunes extension // This is a non-standard iTunes extension
?: textFrames["TSO2"]) ?: textFrames["TSO2"] ?: textFrames["TXXX:album artist sort"])
?.let { rawSong.albumArtistSortNames = it } ?.let { rawSong.albumArtistSortNames = it }
// Genre // Genre
@ -273,7 +278,8 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
} }
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artistssort"] (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 } ?.let { rawSong.artistSortNames = it }
// Album artist // Album artist
@ -281,12 +287,12 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
rawSong.albumArtistMusicBrainzIds = it rawSong.albumArtistMusicBrainzIds = it
} }
(comments["albumartists"] (comments["albumartists"]
?: comments["album_artists"] ?: comments["album artists"] ?: comments["album_artists"] ?: comments["album artists"] ?: comments["albumartist"]
?: comments["albumartist"]) ?: comments["album artist"])
?.let { rawSong.albumArtistNames = it } ?.let { rawSong.albumArtistNames = it }
(comments["albumartistssort"] (comments["albumartistssort"]
?: comments["albumartists_sort"] ?: comments["albumartists sort"] ?: comments["albumartists_sort"] ?: comments["albumartists sort"]
?: comments["albumartistsort"]) ?: comments["albumartistsort"] ?: comments["album artist sort"])
?.let { rawSong.albumArtistSortNames = it } ?.let { rawSong.albumArtistSortNames = it }
// Genre // Genre

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2024 Auxio Project * 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 * 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 * 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.content.Context
import android.os.PowerManager import android.os.PowerManager
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundListener
import org.oxycblt.auxio.ForegroundServiceNotification
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings 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.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
class IndexerServiceFragment class Indexer
@Inject private constructor(
constructor( override val workerContext: Context,
@ApplicationContext override val workerContext: Context, private val foregroundListener: ForegroundListener,
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings, private val musicSettings: MusicSettings,
private val contentObserver: SystemContentObserver, private val imageLoader: ImageLoader,
private val imageLoader: ImageLoader private val contentObserver: SystemContentObserver
) : ) :
MusicRepository.IndexingWorker, MusicRepository.IndexingWorker,
MusicRepository.IndexingListener, MusicRepository.IndexingListener,
MusicRepository.UpdateListener, MusicRepository.UpdateListener,
MusicSettings.Listener { 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 indexJob = Job()
private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) private val indexScope = CoroutineScope(indexJob + Dispatchers.IO)
private var currentIndexJob: Job? = null private var currentIndexJob: Job? = null
private val indexingNotification = IndexingNotification(workerContext) private val indexingNotification = IndexingNotification(workerContext)
private val observingNotification = ObservingNotification(workerContext) private val observingNotification = ObservingNotification(workerContext)
private var foregroundListener: ForegroundListener? = null
private val wakeLock = private val wakeLock =
workerContext workerContext
.getSystemServiceCompat(PowerManager::class) .getSystemServiceCompat(PowerManager::class)
.newWakeLock( .newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
fun attach(listener: ForegroundListener) { fun attach() {
foregroundListener = listener
musicSettings.registerListener(this) musicSettings.registerListener(this)
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this) musicRepository.addIndexingListener(this)
@ -76,7 +94,6 @@ constructor(
musicRepository.removeIndexingListener(this) musicRepository.removeIndexingListener(this)
musicRepository.removeUpdateListener(this) musicRepository.removeUpdateListener(this)
musicSettings.unregisterListener(this) musicSettings.unregisterListener(this)
foregroundListener = null
} }
fun start() { fun start() {
@ -85,7 +102,7 @@ constructor(
} }
} }
fun createNotification(post: (IndexerNotification?) -> Unit) { fun createNotification(post: (ForegroundServiceNotification?) -> Unit) {
val state = musicRepository.indexingState val state = musicRepository.indexingState
if (state is IndexingState.Indexing) { if (state is IndexingState.Indexing) {
// There are a few reasons why we stay in the foreground with automatic rescanning: // There are a few reasons why we stay in the foreground with automatic rescanning:
@ -118,7 +135,7 @@ constructor(
override val scope = indexScope override val scope = indexScope
override fun onIndexingStateChanged() { override fun onIndexingStateChanged() {
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) foregroundListener.updateForeground(ForegroundListener.Change.INDEXER)
val state = musicRepository.indexingState val state = musicRepository.indexingState
if (state is IndexingState.Indexing) { if (state is IndexingState.Indexing) {
wakeLock.acquireSafe() wakeLock.acquireSafe()
@ -157,9 +174,9 @@ constructor(
// notification if we were actively loading when the automatic rescanning // notification if we were actively loading when the automatic rescanning
// setting changed. In such a case, the state will still be updated when // setting changed. In such a case, the state will still be updated when
// the music loading process ends. // the music loading process ends.
if (currentIndexJob == null) { if (musicRepository.indexingState == null) {
logD("Not loading, updating idle session") logD("Not loading, updating idle session")
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) foregroundListener.updateForeground(ForegroundListener.Change.INDEXER)
} }
} }

View file

@ -20,11 +20,9 @@ package org.oxycblt.auxio.music.service
import android.content.Context import android.content.Context
import android.os.SystemClock import android.os.SystemClock
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.ForegroundServiceNotification
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.IndexingProgress import org.oxycblt.auxio.music.IndexingProgress
@ -32,52 +30,13 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent
/** /**
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that * A dynamic [ForegroundServiceNotification] that shows the current music loading state.
* signal a Service's ongoing foreground state.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class IndexerNotification(context: Context, info: ChannelInfo) :
NotificationCompat.Builder(context, info.id) {
private val notificationManager = NotificationManagerCompat.from(context)
init {
// Set up the notification channel. Foreground notifications are non-substantial, and
// thus make no sense to have lights, vibration, or lead to a notification badge.
val channel =
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(info.nameRes))
.setLightsEnabled(false)
.setVibrationEnabled(false)
.setShowBadge(false)
.build()
notificationManager.createNotificationChannel(channel)
}
/**
* The code used to identify this notification.
*
* @see NotificationManagerCompat.notify
*/
abstract val code: Int
/**
* Reduced representation of a [NotificationChannelCompat].
*
* @param id The ID of the channel.
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
*/
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
}
/**
* A dynamic [IndexerNotification] that shows the current music loading state.
* *
* @param context [Context] required to create the notification. * @param context [Context] required to create the notification.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class IndexingNotification(private val context: Context) : class IndexingNotification(private val context: Context) :
IndexerNotification(context, indexerChannel) { ForegroundServiceNotification(context, indexerChannel) {
private var lastUpdateTime = -1L private var lastUpdateTime = -1L
init { 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 * A static [ForegroundServiceNotification] that signals to the user that the app is currently
* music library for changes. * monitoring the music library for changes.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) { class ObservingNotification(context: Context) :
ForegroundServiceNotification(context, indexerChannel) {
init { init {
setSmallIcon(R.drawable.ic_indexer_24) setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE) setCategory(NotificationCompat.CATEGORY_SERVICE)
@ -156,5 +116,5 @@ class ObservingNotification(context: Context) : IndexerNotification(context, ind
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val indexerChannel = private val indexerChannel =
IndexerNotification.ChannelInfo( ForegroundServiceNotification.ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<ControllerInfo, String>()
private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
private var invalidator: Invalidator? = null
interface Invalidator {
fun invalidate(ids: Map<String, Int>)
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<String, Int>()
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<MediaItem>? {
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<MediaItem>? {
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<MediaItem>? {
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<MediaItem>? {
val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it }
return deferred.await().concat().paginate(page, pageSize)
}
private fun SearchEngine.Items.concat(): MutableList<MediaItem> {
val music = mutableListOf<MediaItem>()
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<MediaItem>.paginate(page: Int, pageSize: Int): List<MediaItem>? {
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)
}
}

View file

@ -19,15 +19,12 @@
package org.oxycblt.auxio.music.service package org.oxycblt.auxio.music.service
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Bundle 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.annotation.StringRes
import androidx.media.utils.MediaConstants 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.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album 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.MusicParent
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationDs
import org.oxycblt.auxio.util.getPlural 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 { sealed interface MediaSessionUID {
enum class Category( data class Tab(val node: TabNode) : MediaSessionUID {
val id: String, override fun toString() = "$ID_CATEGORY:${node.id}"
@StringRes val nameRes: Int,
@DrawableRes val bitmapRes: Int?,
val mediaType: Int?
) : MediaSessionUID {
ROOT("root", R.string.info_app_name, null, null),
SONGS(
"songs",
R.string.lbl_songs,
R.drawable.ic_song_bitmap_24,
MediaMetadata.MEDIA_TYPE_MUSIC),
ALBUMS(
"albums",
R.string.lbl_albums,
R.drawable.ic_album_bitmap_24,
MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
ARTISTS(
"artists",
R.string.lbl_artists,
R.drawable.ic_artist_bitmap_24,
MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
GENRES(
"genres",
R.string.lbl_genres,
R.drawable.ic_genre_bitmap_24,
MediaMetadata.MEDIA_TYPE_FOLDER_GENRES),
PLAYLISTS(
"playlists",
R.string.lbl_playlists,
R.drawable.ic_playlist_bitmap_24,
MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
override fun toString() = "$ID_CATEGORY:$id"
companion object {
val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES)
val USER_MUSIC = listOf(ROOT, PLAYLISTS)
val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS)
}
} }
data class Single(val uid: Music.UID) : MediaSessionUID { data class SingleItem(val uid: Music.UID) : MediaSessionUID {
override fun toString() = "$ID_ITEM:$uid" 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 { companion object {
const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category" const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category"
const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item" const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item"
@ -283,28 +57,154 @@ sealed interface MediaSessionUID {
return null return null
} }
return when (parts[0]) { return when (parts[0]) {
ID_CATEGORY -> ID_CATEGORY -> Tab(TabNode.fromString(parts[1]) ?: return null)
when (parts[1]) { ID_ITEM -> SingleItem(Music.UID.fromString(parts[1]) ?: return null)
Category.ROOT.id -> Category.ROOT
Category.SONGS.id -> Category.SONGS
Category.ALBUMS.id -> Category.ALBUMS
Category.ARTISTS.id -> Category.ARTISTS
Category.GENRES.id -> Category.GENRES
Category.PLAYLISTS.id -> Category.PLAYLISTS
else -> null
}
ID_ITEM -> {
val uids = parts[1].split(">", limit = 2)
if (uids.size == 1) {
Music.UID.fromString(uids[0])?.let { Single(it) }
} else {
Music.UID.fromString(uids[0])?.let { parent ->
Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) }
}
}
}
else -> return null 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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>)
}
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<MediaItem>? {
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<MediaItem> {
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<MediaItem> {
val music = mutableListOf<MediaItem>()
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<MediaItem>? {
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<MediaItem>? {
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"
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>) {
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<MediaItem>) =
result.dispatch {
musicBrowser.getItem(
mediaId,
)
}
fun getChildren(
mediaId: String,
maxTabs: Int,
result: Result<MutableList<MediaItem>>,
page: Page?
) = result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.expose(page) }
fun search(query: String, result: Result<MutableList<MediaItem>>, page: Page?) =
result.dispatchAsync { musicBrowser.search(query).expose(page) }
private fun <T> Result<T>.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 <T> Result<T>.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 <T> List<T>.expose(page: Page?): MutableList<T> {
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()
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}
}

View file

@ -46,8 +46,6 @@ import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song 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.PlaybackSettings
import org.oxycblt.auxio.playback.msToSecs import org.oxycblt.auxio.playback.msToSecs
import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.persist.PersistenceRepository
@ -92,7 +90,6 @@ class ExoPlaybackStateHolder(
fun attach() { fun attach() {
imageSettings.registerListener(this) imageSettings.registerListener(this)
player.addListener(this) player.addListener(this)
replayGainProcessor.attach()
playbackManager.registerStateHolder(this) playbackManager.registerStateHolder(this)
playbackSettings.registerListener(this) playbackSettings.registerListener(this)
musicRepository.addUpdateListener(this) musicRepository.addUpdateListener(this)
@ -111,10 +108,6 @@ class ExoPlaybackStateHolder(
override var parent: MusicParent? = null override var parent: MusicParent? = null
private set private set
val mediaSessionPlayer: Player
get() =
MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository)
override val progression: Progression override val progression: Progression
get() { get() {
val mediaItem = player.currentMediaItem ?: return Progression.nil() val mediaItem = player.currentMediaItem ?: return Progression.nil()
@ -147,10 +140,7 @@ class ExoPlaybackStateHolder(
} else { } else {
emptyList() emptyList()
} }
return RawQueue( return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex)
heap.mapNotNull { it.toSong(deviceLibrary) },
shuffledMapping,
player.currentMediaItemIndex)
} }
override fun handleDeferred(action: DeferredPlayback): Boolean { override fun handleDeferred(action: DeferredPlayback): Boolean {
@ -164,10 +154,18 @@ class ExoPlaybackStateHolder(
is DeferredPlayback.RestoreState -> { is DeferredPlayback.RestoreState -> {
logD("Restoring playback state") logD("Restoring playback state")
restoreScope.launch { restoreScope.launch {
persistenceRepository.readState()?.let { val state = persistenceRepository.readState()
// Apply the saved state on the main thread to prevent code expecting withContext(Dispatchers.Main) {
// state updates on the main thread from crashing. if (state != null) {
withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) } // 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) { override fun newPlayback(command: PlaybackCommand) {
parent = command.parent parent = command.parent
player.shuffleModeEnabled = command.shuffled player.shuffleModeEnabled = command.shuffled
player.setMediaItems(command.queue.map { it.toMediaItem(context, null) }) player.setMediaItems(command.queue.map { it.buildMediaItem() })
val startIndex = val startIndex =
command.song command.song
?.let { command.queue.indexOf(it) } ?.let { command.queue.indexOf(it) }
@ -309,16 +307,16 @@ class ExoPlaybackStateHolder(
} }
if (nextIndex == C.INDEX_UNSET) { if (nextIndex == C.INDEX_UNSET) {
player.addMediaItems(songs.map { it.toMediaItem(context, null) }) player.addMediaItems(songs.map { it.buildMediaItem() })
} else { } else {
player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) }) player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() })
} }
playbackManager.ack(this, ack) playbackManager.ack(this, ack)
deferSave() deferSave()
} }
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) { override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
player.addMediaItems(songs.map { it.toMediaItem(context, null) }) player.addMediaItems(songs.map { it.buildMediaItem() })
playbackManager.ack(this, ack) playbackManager.ack(this, ack)
deferSave() deferSave()
} }
@ -370,12 +368,6 @@ class ExoPlaybackStateHolder(
repeatMode: RepeatMode, repeatMode: RepeatMode,
ack: StateAck.NewPlayback? 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 sendNewPlaybackEvent = false
var shouldSeek = false var shouldSeek = false
if (this.parent != parent) { if (this.parent != parent) {
@ -383,7 +375,7 @@ class ExoPlaybackStateHolder(
sendNewPlaybackEvent = true sendNewPlaybackEvent = true
} }
if (rawQueue != resolveQueue()) { if (rawQueue != resolveQueue()) {
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() })
if (rawQueue.isShuffled) { if (rawQueue.isShuffled) {
player.shuffleModeEnabled = true player.shuffleModeEnabled = true
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
@ -548,6 +540,50 @@ class ExoPlaybackStateHolder(
currentSaveJob = saveScope.launch { block() } 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<Int> {
val timeline = currentTimeline
if (timeline.isEmpty) {
return emptyList()
}
val queue = mutableListOf<Int>()
// 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 class Factory
@Inject @Inject
constructor( constructor(
@ -563,7 +599,7 @@ class ExoPlaybackStateHolder(
) { ) {
fun create(): ExoPlaybackStateHolder { fun create(): ExoPlaybackStateHolder {
// Since Auxio is a music player, only specify an audio renderer to save // 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, _, _ -> val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
arrayOf( arrayOf(
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Song>, 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<Song>, 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<Song>,
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<Song>) {
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MediaItem>, 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<MediaItem>,
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<MediaItem>) {
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<MediaItem>) = notAllowed()
override fun addMediaItem(mediaItem: MediaItem) = notAllowed()
override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed()
override fun addMediaItems(mediaItems: MutableList<MediaItem>) = notAllowed()
override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed()
override fun replaceMediaItems(
fromIndex: Int,
toIndex: Int,
mediaItems: MutableList<MediaItem>
) = 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<Int> {
val timeline = currentTimeline
if (timeline.isEmpty) {
return emptyList()
}
val queue = mutableListOf<Int>()
// 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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<SessionResult> =
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<LibraryResult<MediaItem>> =
Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params))
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String
): ListenableFuture<LibraryResult<MediaItem>> {
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<MediaItem>,
startIndex: Int,
startPositionMs: Long
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
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<LibraryResult<ImmutableList<MediaItem>>> {
val children =
mediaItemBrowser.getChildren(parentId, page, pageSize)?.let {
LibraryResult.ofItemList(it, params)
}
?: LibraryResult.ofError<ImmutableList<MediaItem>>(
LibraryResult.RESULT_ERROR_BAD_VALUE)
return Futures.immediateFuture(children)
}
override fun onSearch(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<Void>> =
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<CommandButton>) {
mediaSession.setCustomLayout(layout)
}
override fun invalidate(ids: Map<String, Int>) {
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<CommandButton>)
}
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<CommandButton> {
val actions = mutableListOf<CommandButton>()
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<Song>, 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)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

View file

@ -281,7 +281,8 @@ data class QueueChange(val type: Type, val instructions: UpdateInstructions) {
/** Possible long-running background tasks handled by the background playback task. */ /** Possible long-running background tasks handled by the background playback task. */
sealed interface DeferredPlayback { sealed interface DeferredPlayback {
/** Restore the previously saved playback state. */ /** 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. * Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut.

View file

@ -61,7 +61,7 @@ interface SearchEngine {
val artists: Collection<Artist>? = null, val artists: Collection<Artist>? = null,
val genres: Collection<Genre>? = null, val genres: Collection<Genre>? = null,
val playlists: Collection<Playlist>? = null val playlists: Collection<Playlist>? = null
) ) {}
} }
class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) : class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) :

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Unit>) :
TaskerPluginConfigHelperNoOutputOrInput<StartActionRunner>(config) {
override val runnerClass: Class<StartActionRunner>
get() = StartActionRunner::class.java
override fun addToStringBlurb(input: TaskerInput<Unit>, 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<Unit>): TaskerPluginResult<Unit> {
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()
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -22,7 +22,7 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build import android.os.Build
import coil.request.ImageRequest import coil.request.ImageRequest
import dagger.hilt.android.qualifiers.ApplicationContext import coil.size.Size
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.BitmapProvider
@ -46,17 +46,28 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class WidgetComponent class WidgetComponent
@Inject private constructor(
constructor( private val context: Context,
@ApplicationContext private val context: Context,
private val imageSettings: ImageSettings, private val imageSettings: ImageSettings,
private val bitmapProvider: BitmapProvider, private val bitmapProvider: BitmapProvider,
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val uiSettings: UISettings private val uiSettings: UISettings
) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { ) : 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() private val widgetProvider = WidgetProvider()
init { fun attach() {
playbackManager.addListener(this) playbackManager.addListener(this)
uiSettings.registerListener(this) uiSettings.registerListener(this)
imageSettings.registerListener(this) imageSettings.registerListener(this)
@ -96,24 +107,19 @@ constructor(
0 0
} }
return if (cornerRadius > 0) { val transformations = buildList {
// If rounded, reduce the bitmap size further to obtain more pronounced
// rounded corners.
builder.size(getSafeRemoteViewsImageSize(context, 10f))
val cornersTransformation =
RoundedRectTransformation(cornerRadius.toFloat())
if (imageSettings.forceSquareCovers) { if (imageSettings.forceSquareCovers) {
builder.transformations( add(SquareCropTransformation.INSTANCE)
SquareCropTransformation.INSTANCE, cornersTransformation) }
if (cornerRadius > 0) {
add(WidgetBitmapTransformation(15f))
add(RoundedRectTransformation(cornerRadius.toFloat()))
} else { } 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?) { override fun onCompleted(bitmap: Bitmap?) {

View file

@ -27,7 +27,6 @@ import android.widget.RemoteViews
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import kotlin.math.sqrt
import org.oxycblt.auxio.util.isLandscape import org.oxycblt.auxio.util.isLandscape
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent
@ -46,24 +45,6 @@ fun newRemoteViews(context: Context, @LayoutRes layoutRes: Int): RemoteViews {
return views 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. * Set the background resource of a [RemoteViews] View.
* *

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

View file

@ -153,6 +153,7 @@
<string name="lbl_shuffle_shortcut_short">Shuffle</string> <string name="lbl_shuffle_shortcut_short">Shuffle</string>
<!-- Limit to 25 characters --> <!-- Limit to 25 characters -->
<string name="lbl_shuffle_shortcut_long">Shuffle all</string> <string name="lbl_shuffle_shortcut_long">Shuffle all</string>
<string name="lbl_start_playback">Start playback</string>
<string name="lbl_ok">OK</string> <string name="lbl_ok">OK</string>
<string name="lbl_cancel">Cancel</string> <string name="lbl_cancel">Cancel</string>
@ -162,6 +163,7 @@
<string name="lbl_reset">Reset</string> <string name="lbl_reset">Reset</string>
<!-- As in to add a new folder in the "Music folders" setting --> <!-- As in to add a new folder in the "Music folders" setting -->
<string name="lbl_add">Add</string> <string name="lbl_add">Add</string>
<string name="lbl_more">More</string>
<string name="lbl_path_style">Path style</string> <string name="lbl_path_style">Path style</string>
<string name="lbl_path_style_absolute">Absolute</string> <string name="lbl_path_style_absolute">Absolute</string>
@ -205,6 +207,10 @@
<string name="lng_supporters_promo">Donate to the project to get your name added here!</string> <string name="lng_supporters_promo">Donate to the project to get your name added here!</string>
<!-- As in music library --> <!-- As in music library -->
<string name="lng_search_library">Search your library…</string> <string name="lng_search_library">Search your library…</string>
<string name="lng_tasker_start">
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.
</string>
<!-- Settings namespace | Settings-related labels --> <!-- Settings namespace | Settings-related labels -->
<eat-comment /> <eat-comment />

View file

@ -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

View file

@ -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

View file

@ -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