commit
18f96ed3ec
47 changed files with 2733 additions and 2085 deletions
2
.github/workflows/android.yml
vendored
2
.github/workflows/android.yml
vendored
|
@ -11,6 +11,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Install ninja-build
|
||||
run: sudo apt-get install -y ninja-build
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Clone submodules
|
||||
|
|
18
CHANGELOG.md
18
CHANGELOG.md
|
@ -1,5 +1,23 @@
|
|||
# Changelog
|
||||
|
||||
## 3.6.0
|
||||
|
||||
#### What's New
|
||||
- Added support for playback from google assistant
|
||||
|
||||
#### What's Improved
|
||||
- Home and detail UIs in Android Auto now reflect app sort settings
|
||||
- Album view now shows discs in android auto
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed playback briefly pausing when adding songs to playlist
|
||||
- Fixed media lists in Android Auto being truncated in some cases
|
||||
- Possibly fixed duplicated song items depending on album/all children
|
||||
- Possibly fixed truncated tab lists in android auto
|
||||
|
||||
#### Dev/Meta
|
||||
- Moved to raw media session apis rather than media3 session
|
||||
|
||||
## 3.5.3
|
||||
|
||||
#### What's New
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
<h1 align="center"><b>Auxio</b></h1>
|
||||
<h4 align="center">A simple, rational music player for android.</h4>
|
||||
<p align="center">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.5.3">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.5.3&color=64B5F6&style=flat">
|
||||
<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.6.0&color=64B5F6&style=flat">
|
||||
</a>
|
||||
<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">
|
||||
|
|
|
@ -21,8 +21,8 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId namespace
|
||||
versionName "3.5.3"
|
||||
versionCode 49
|
||||
versionName "3.6.0"
|
||||
versionCode 50
|
||||
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
|
@ -114,6 +114,9 @@ dependencies {
|
|||
// Media
|
||||
implementation "androidx.media:media:1.7.0"
|
||||
|
||||
// Android Auto
|
||||
implementation "androidx.car.app:app:1.4.0"
|
||||
|
||||
// Preferences
|
||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
|
||||
|
@ -126,7 +129,6 @@ dependencies {
|
|||
// --- THIRD PARTY ---
|
||||
|
||||
// Exoplayer (Vendored)
|
||||
implementation project(":media-lib-session")
|
||||
implementation project(":media-lib-exoplayer")
|
||||
implementation project(":media-lib-decoder-ffmpeg")
|
||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
|
||||
|
@ -154,6 +156,9 @@ dependencies {
|
|||
// Tasker integration
|
||||
implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10'
|
||||
|
||||
// Fuzzy search
|
||||
implementation 'org.apache.commons:commons-text:1.9'
|
||||
|
||||
// Testing
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
|
|
|
@ -94,7 +94,6 @@
|
|||
android:exported="true"
|
||||
android:roundIcon="@mipmap/ic_launcher">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
|
|
@ -19,32 +19,41 @@
|
|||
package org.oxycblt.auxio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.service.IndexerServiceFragment
|
||||
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment
|
||||
import org.oxycblt.auxio.music.service.MusicServiceFragment
|
||||
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuxioService : MediaLibraryService(), ForegroundListener {
|
||||
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
|
||||
class AuxioService :
|
||||
MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator {
|
||||
@Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory
|
||||
private lateinit var playbackFragment: PlaybackServiceFragment
|
||||
|
||||
@Inject lateinit var indexingFragment: IndexerServiceFragment
|
||||
@Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory
|
||||
private lateinit var musicFragment: MusicServiceFragment
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
mediaSessionFragment.attach(this, this)
|
||||
indexingFragment.attach(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
onHandleForeground(intent)
|
||||
return super.onBind(intent)
|
||||
playbackFragment = playbackFragmentFactory.create(this, this)
|
||||
sessionToken = playbackFragment.attach()
|
||||
musicFragment = musicFragmentFactory.create(this, this, this)
|
||||
musicFragment.attach()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
@ -54,42 +63,81 @@ class AuxioService : MediaLibraryService(), ForegroundListener {
|
|||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
onHandleForeground(intent)
|
||||
return super.onBind(intent)
|
||||
}
|
||||
|
||||
private fun onHandleForeground(intent: Intent?) {
|
||||
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
|
||||
indexingFragment.start()
|
||||
mediaSessionFragment.start(startId)
|
||||
musicFragment.start()
|
||||
playbackFragment.start(startId)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
mediaSessionFragment.handleTaskRemoved()
|
||||
playbackFragment.handleTaskRemoved()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
indexingFragment.release()
|
||||
mediaSessionFragment.release()
|
||||
musicFragment.release()
|
||||
playbackFragment.release()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
|
||||
mediaSessionFragment.mediaSession
|
||||
override fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): BrowserRoot {
|
||||
return musicFragment.getRoot()
|
||||
}
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
updateForeground(ForegroundListener.Change.MEDIA_SESSION)
|
||||
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
|
||||
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) {
|
||||
if (mediaSessionFragment.hasNotification()) {
|
||||
val mediaNotification = playbackFragment.notification
|
||||
if (mediaNotification != null) {
|
||||
if (change == ForegroundListener.Change.MEDIA_SESSION) {
|
||||
mediaSessionFragment.createNotification {
|
||||
startForeground(it.notificationId, it.notification)
|
||||
isForeground = true
|
||||
}
|
||||
startForeground(mediaNotification.code, mediaNotification.build())
|
||||
}
|
||||
// Nothing changed, but don't show anything music related since we can always
|
||||
// index during playback.
|
||||
} else {
|
||||
indexingFragment.createNotification {
|
||||
musicFragment.createNotification {
|
||||
if (it != null) {
|
||||
startForeground(it.code, it.build())
|
||||
isForeground = true
|
||||
|
@ -101,6 +149,11 @@ class AuxioService : MediaLibraryService(), ForegroundListener {
|
|||
}
|
||||
}
|
||||
|
||||
override fun invalidateMusic(mediaId: String) {
|
||||
logD(mediaId)
|
||||
notifyChildrenChanged(mediaId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isForeground = false
|
||||
private set
|
||||
|
@ -118,3 +171,42 @@ interface ForegroundListener {
|
|||
INDEXER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
|
||||
* signal a Service's ongoing foreground state.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) :
|
||||
NotificationCompat.Builder(context, info.id) {
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
|
||||
init {
|
||||
// Set up the notification channel. Foreground notifications are non-substantial, and
|
||||
// thus make no sense to have lights, vibration, or lead to a notification badge.
|
||||
val channel =
|
||||
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(context.getString(info.nameRes))
|
||||
.setLightsEnabled(false)
|
||||
.setVibrationEnabled(false)
|
||||
.setShowBadge(false)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* The code used to identify this notification.
|
||||
*
|
||||
* @see NotificationManagerCompat.notify
|
||||
*/
|
||||
abstract val code: Int
|
||||
|
||||
/**
|
||||
* Reduced representation of a [NotificationChannelCompat].
|
||||
*
|
||||
* @param id The ID of the channel.
|
||||
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
|
||||
*/
|
||||
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
|
||||
}
|
||||
|
|
|
@ -79,7 +79,7 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
startIntentAction(intent)
|
||||
}
|
||||
|
|
240
app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt
Normal file
240
app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt
Normal 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
|
||||
}
|
||||
}
|
30
app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt
Normal file
30
app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt
Normal 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
|
||||
}
|
|
@ -18,7 +18,6 @@
|
|||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
@ -43,10 +42,11 @@ import org.oxycblt.auxio.music.Album
|
|||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.ReleaseType
|
||||
import org.oxycblt.auxio.music.metadata.AudioProperties
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
|
@ -69,8 +69,9 @@ constructor(
|
|||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val audioPropertiesFactory: AudioProperties.Factory,
|
||||
private val playbackSettings: PlaybackSettings
|
||||
) : ViewModel(), MusicRepository.UpdateListener {
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
detailGeneratorFactory: DetailGenerator.Factory
|
||||
) : ViewModel(), DetailGenerator.Invalidator {
|
||||
private val _toShow = MutableEvent<Show>()
|
||||
/**
|
||||
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
|
||||
|
@ -133,13 +134,8 @@ constructor(
|
|||
get() = _artistSongInstructions
|
||||
|
||||
/** The current [Sort] used for [Song]s in [artistSongList]. */
|
||||
var artistSongSort: Sort
|
||||
val artistSongSort: Sort
|
||||
get() = listSettings.artistSongSort
|
||||
set(value) {
|
||||
listSettings.artistSongSort = value
|
||||
// Refresh the artist list to reflect the new sort.
|
||||
currentArtist.value?.let { refreshArtistList(it, true) }
|
||||
}
|
||||
|
||||
/** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */
|
||||
val playInArtistWith
|
||||
|
@ -162,13 +158,8 @@ constructor(
|
|||
get() = _genreSongInstructions
|
||||
|
||||
/** The current [Sort] used for [Song]s in [genreSongList]. */
|
||||
var genreSongSort: Sort
|
||||
val genreSongSort: Sort
|
||||
get() = listSettings.genreSongSort
|
||||
set(value) {
|
||||
listSettings.genreSongSort = value
|
||||
// Refresh the genre list to reflect the new sort.
|
||||
currentGenre.value?.let { refreshGenreList(it, true) }
|
||||
}
|
||||
|
||||
/** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
|
||||
val playInGenreWith
|
||||
|
@ -204,54 +195,35 @@ constructor(
|
|||
playbackSettings.inParentPlaybackMode
|
||||
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
|
||||
|
||||
private val detailGenerator = detailGeneratorFactory.create(this)
|
||||
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
detailGenerator.attach()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
musicRepository.removeUpdateListener(this)
|
||||
detailGenerator.release()
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
// If we are showing any item right now, we will need to refresh it (and any information
|
||||
// related to it) with the new library in order to prevent stale items from showing up
|
||||
// in the UI.
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||
val song = currentSong.value
|
||||
if (song != null) {
|
||||
_currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo)
|
||||
logD("Updated song to ${currentSong.value}")
|
||||
override fun invalidate(type: MusicType, replace: Int?) {
|
||||
when (type) {
|
||||
MusicType.ALBUMS -> {
|
||||
val album = detailGenerator.album(currentAlbum.value?.uid ?: return)
|
||||
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace)
|
||||
}
|
||||
|
||||
val album = currentAlbum.value
|
||||
if (album != null) {
|
||||
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
|
||||
logD("Updated album to ${currentAlbum.value}")
|
||||
MusicType.ARTISTS -> {
|
||||
val artist = detailGenerator.artist(currentArtist.value?.uid ?: return)
|
||||
refreshDetail(
|
||||
artist, _currentArtist, _artistSongList, _artistSongInstructions, replace)
|
||||
}
|
||||
|
||||
val artist = currentArtist.value
|
||||
if (artist != null) {
|
||||
_currentArtist.value =
|
||||
deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
|
||||
logD("Updated artist to ${currentArtist.value}")
|
||||
MusicType.GENRES -> {
|
||||
val genre = detailGenerator.genre(currentGenre.value?.uid ?: return)
|
||||
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace)
|
||||
}
|
||||
|
||||
val genre = currentGenre.value
|
||||
if (genre != null) {
|
||||
_currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList)
|
||||
logD("Updated genre to ${currentGenre.value}")
|
||||
}
|
||||
}
|
||||
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (changes.userLibrary && userLibrary != null) {
|
||||
val playlist = currentPlaylist.value
|
||||
if (playlist != null) {
|
||||
_currentPlaylist.value =
|
||||
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
|
||||
logD("Updated playlist to ${currentPlaylist.value}")
|
||||
MusicType.PLAYLISTS -> {
|
||||
refreshPlaylist(currentPlaylist.value?.uid ?: return)
|
||||
}
|
||||
else -> error("Unexpected music type $type")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -356,8 +328,11 @@ constructor(
|
|||
*/
|
||||
fun setAlbum(uid: Music.UID) {
|
||||
logD("Opening album $uid")
|
||||
_currentAlbum.value =
|
||||
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
|
||||
if (uid === _currentAlbum.value?.uid) {
|
||||
return
|
||||
}
|
||||
val album = detailGenerator.album(uid)
|
||||
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null)
|
||||
if (_currentAlbum.value == null) {
|
||||
logW("Given album UID was invalid")
|
||||
}
|
||||
|
@ -370,7 +345,6 @@ constructor(
|
|||
*/
|
||||
fun applyAlbumSongSort(sort: Sort) {
|
||||
listSettings.albumSongSort = sort
|
||||
_currentAlbum.value?.let { refreshAlbumList(it, true) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -381,11 +355,11 @@ constructor(
|
|||
*/
|
||||
fun setArtist(uid: Music.UID) {
|
||||
logD("Opening artist $uid")
|
||||
_currentArtist.value =
|
||||
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
|
||||
if (_currentArtist.value == null) {
|
||||
logW("Given artist UID was invalid")
|
||||
if (uid === _currentArtist.value?.uid) {
|
||||
return
|
||||
}
|
||||
val artist = detailGenerator.artist(uid)
|
||||
refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -395,7 +369,6 @@ constructor(
|
|||
*/
|
||||
fun applyArtistSongSort(sort: Sort) {
|
||||
listSettings.artistSongSort = sort
|
||||
_currentArtist.value?.let { refreshArtistList(it, true) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -406,11 +379,11 @@ constructor(
|
|||
*/
|
||||
fun setGenre(uid: Music.UID) {
|
||||
logD("Opening genre $uid")
|
||||
_currentGenre.value =
|
||||
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
|
||||
if (_currentGenre.value == null) {
|
||||
logW("Given genre UID was invalid")
|
||||
if (uid === _currentGenre.value?.uid) {
|
||||
return
|
||||
}
|
||||
val genre = detailGenerator.genre(uid)
|
||||
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -420,7 +393,6 @@ constructor(
|
|||
*/
|
||||
fun applyGenreSongSort(sort: Sort) {
|
||||
listSettings.genreSongSort = sort
|
||||
_currentGenre.value?.let { refreshGenreList(it, true) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -431,11 +403,10 @@ constructor(
|
|||
*/
|
||||
fun setPlaylist(uid: Music.UID) {
|
||||
logD("Opening playlist $uid")
|
||||
_currentPlaylist.value =
|
||||
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
|
||||
if (_currentPlaylist.value == null) {
|
||||
logW("Given playlist UID was invalid")
|
||||
if (uid === _currentPlaylist.value?.uid) {
|
||||
return
|
||||
}
|
||||
refreshPlaylist(uid)
|
||||
}
|
||||
|
||||
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
|
||||
|
@ -443,7 +414,7 @@ constructor(
|
|||
val playlist = _currentPlaylist.value ?: return
|
||||
logD("Starting playlist edit")
|
||||
_editedPlaylist.value = playlist.songs
|
||||
refreshPlaylistList(playlist)
|
||||
refreshPlaylist(playlist.uid)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -474,9 +445,8 @@ constructor(
|
|||
// Nothing to do.
|
||||
return false
|
||||
}
|
||||
logD("Discarding playlist edits")
|
||||
_editedPlaylist.value = null
|
||||
refreshPlaylistList(playlist)
|
||||
refreshPlaylist(playlist.uid)
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -488,7 +458,7 @@ constructor(
|
|||
fun applyPlaylistSongSort(sort: Sort) {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
_editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return)
|
||||
refreshPlaylistList(playlist, UpdateInstructions.Replace(2))
|
||||
refreshPlaylist(playlist.uid, UpdateInstructions.Replace(2))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -509,7 +479,7 @@ constructor(
|
|||
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
|
||||
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
|
||||
_editedPlaylist.value = editedPlaylist
|
||||
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
|
||||
refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -528,8 +498,8 @@ constructor(
|
|||
logD("Removing playlist song at $realAt [$at]")
|
||||
editedPlaylist.removeAt(realAt)
|
||||
_editedPlaylist.value = editedPlaylist
|
||||
refreshPlaylistList(
|
||||
playlist,
|
||||
refreshPlaylist(
|
||||
playlist.uid,
|
||||
if (editedPlaylist.isNotEmpty()) {
|
||||
UpdateInstructions.Remove(at, 1)
|
||||
} else {
|
||||
|
@ -552,174 +522,72 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
|
||||
logD("Refreshing album list")
|
||||
val list = mutableListOf<Item>()
|
||||
val header = SortHeader(R.string.lbl_songs)
|
||||
list.add(Divider(header))
|
||||
list.add(header)
|
||||
val instructions =
|
||||
if (replace) {
|
||||
// Intentional so that the header item isn't replaced with the songs
|
||||
UpdateInstructions.Replace(list.size)
|
||||
} else {
|
||||
UpdateInstructions.Diff
|
||||
}
|
||||
|
||||
// To create a good user experience regarding disc numbers, we group the album's
|
||||
// songs up by disc and then delimit the groups by a disc header.
|
||||
val songs = albumSongSort.songs(album.songs)
|
||||
val byDisc = songs.groupBy { it.disc }
|
||||
if (byDisc.size > 1) {
|
||||
logD("Album has more than one disc, interspersing headers")
|
||||
for (entry in byDisc.entries) {
|
||||
list.add(DiscHeader(entry.key))
|
||||
list.addAll(entry.value)
|
||||
}
|
||||
} else {
|
||||
// Album only has one disc, don't add any redundant headers
|
||||
list.addAll(songs)
|
||||
private fun <T : MusicParent> refreshDetail(
|
||||
detail: Detail<T>?,
|
||||
parent: MutableStateFlow<T?>,
|
||||
list: MutableStateFlow<List<Item>>,
|
||||
instructions: MutableEvent<UpdateInstructions>,
|
||||
replace: Int?
|
||||
) {
|
||||
if (detail == null) {
|
||||
parent.value = null
|
||||
return
|
||||
}
|
||||
|
||||
logD("Update album list to ${list.size} items with $instructions")
|
||||
_albumSongInstructions.put(instructions)
|
||||
_albumSongList.value = list
|
||||
}
|
||||
|
||||
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
|
||||
logD("Refreshing artist list")
|
||||
val list = mutableListOf<Item>()
|
||||
|
||||
val grouping =
|
||||
artist.explicitAlbums.groupByTo(sortedMapOf()) {
|
||||
// Remap the complicated ReleaseType data structure into an easier
|
||||
// "AlbumGrouping" enum that will automatically group and sort
|
||||
// the artist's albums.
|
||||
when (it.releaseType.refinement) {
|
||||
ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE
|
||||
ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES
|
||||
null ->
|
||||
when (it.releaseType) {
|
||||
is ReleaseType.Album -> AlbumGrouping.ALBUMS
|
||||
is ReleaseType.EP -> AlbumGrouping.EPS
|
||||
is ReleaseType.Single -> AlbumGrouping.SINGLES
|
||||
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
|
||||
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
|
||||
is ReleaseType.Mix -> AlbumGrouping.DJMIXES
|
||||
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
|
||||
is ReleaseType.Demo -> AlbumGrouping.DEMOS
|
||||
}
|
||||
val newList = mutableListOf<Item>()
|
||||
var newInstructions: UpdateInstructions = UpdateInstructions.Diff
|
||||
for ((i, section) in detail.sections.withIndex()) {
|
||||
val items =
|
||||
when (section) {
|
||||
is DetailSection.PlainSection<*> -> {
|
||||
val header =
|
||||
if (section is DetailSection.Songs) SortHeader(section.stringRes)
|
||||
else BasicHeader(section.stringRes)
|
||||
newList.add(Divider(header))
|
||||
newList.add(header)
|
||||
section.items
|
||||
}
|
||||
is DetailSection.Discs -> {
|
||||
val header = SortHeader(section.stringRes)
|
||||
newList.add(Divider(header))
|
||||
newList.add(header)
|
||||
section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artist.implicitAlbums.isNotEmpty()) {
|
||||
// groupByTo normally returns a mapping to a MutableList mapping. Since MutableList
|
||||
// inherits list, we can cast upwards and save a copy by directly inserting the
|
||||
// implicit album list into the mapping.
|
||||
logD("Implicit albums present, adding to list")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(grouping as MutableMap<AlbumGrouping, Collection<Album>>)[AlbumGrouping.APPEARANCES] =
|
||||
artist.implicitAlbums
|
||||
}
|
||||
|
||||
logD("Release groups for this artist: ${grouping.keys}")
|
||||
|
||||
for (entry in grouping.entries) {
|
||||
val header = BasicHeader(entry.key.headerTitleRes)
|
||||
list.add(Divider(header))
|
||||
list.add(header)
|
||||
list.addAll(ARTIST_ALBUM_SORT.albums(entry.value))
|
||||
}
|
||||
|
||||
// Artists may not be linked to any songs, only include a header entry if we have any.
|
||||
var instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
logD("Songs present in this artist, adding header")
|
||||
val header = SortHeader(R.string.lbl_songs)
|
||||
list.add(Divider(header))
|
||||
list.add(header)
|
||||
if (replace) {
|
||||
// Currently only the final section (songs, which can be sorted) are invalidatable
|
||||
// and thus need to be replaced.
|
||||
if (replace == -1 && i == detail.sections.lastIndex) {
|
||||
// Intentional so that the header item isn't replaced with the songs
|
||||
instructions = UpdateInstructions.Replace(list.size)
|
||||
newInstructions = UpdateInstructions.Replace(newList.size)
|
||||
}
|
||||
list.addAll(artistSongSort.songs(artist.songs))
|
||||
newList.addAll(items)
|
||||
}
|
||||
|
||||
logD("Updating artist list to ${list.size} items with $instructions")
|
||||
_artistSongInstructions.put(instructions)
|
||||
_artistSongList.value = list.toList()
|
||||
parent.value = detail.parent
|
||||
instructions.put(newInstructions)
|
||||
list.value = newList
|
||||
}
|
||||
|
||||
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
|
||||
logD("Refreshing genre list")
|
||||
val list = mutableListOf<Item>()
|
||||
// Genre is guaranteed to always have artists and songs.
|
||||
val artistHeader = BasicHeader(R.string.lbl_artists)
|
||||
list.add(Divider(artistHeader))
|
||||
list.add(artistHeader)
|
||||
list.addAll(GENRE_ARTIST_SORT.artists(genre.artists))
|
||||
|
||||
val songHeader = SortHeader(R.string.lbl_songs)
|
||||
list.add(Divider(songHeader))
|
||||
list.add(songHeader)
|
||||
val instructions =
|
||||
if (replace) {
|
||||
// Intentional so that the header item isn't replaced alongside the songs
|
||||
UpdateInstructions.Replace(list.size)
|
||||
} else {
|
||||
UpdateInstructions.Diff
|
||||
}
|
||||
list.addAll(genreSongSort.songs(genre.songs))
|
||||
|
||||
logD("Updating genre list to ${list.size} items with $instructions")
|
||||
_genreSongInstructions.put(instructions)
|
||||
_genreSongList.value = list
|
||||
}
|
||||
|
||||
private fun refreshPlaylistList(
|
||||
playlist: Playlist,
|
||||
private fun refreshPlaylist(
|
||||
uid: Music.UID,
|
||||
instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||
) {
|
||||
logD("Refreshing playlist list")
|
||||
val edited = editedPlaylist.value
|
||||
if (edited == null) {
|
||||
val playlist = detailGenerator.playlist(uid)
|
||||
refreshDetail(
|
||||
playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null)
|
||||
return
|
||||
}
|
||||
val list = mutableListOf<Item>()
|
||||
|
||||
val songs = editedPlaylist.value ?: playlist.songs
|
||||
if (songs.isNotEmpty()) {
|
||||
if (edited.isNotEmpty()) {
|
||||
val header = EditHeader(R.string.lbl_songs)
|
||||
list.add(Divider(header))
|
||||
list.add(header)
|
||||
list.addAll(songs)
|
||||
list.addAll(edited)
|
||||
}
|
||||
|
||||
logD("Updating playlist list to ${list.size} items with $instructions")
|
||||
_playlistSongInstructions.put(instructions)
|
||||
_playlistSongList.value = list
|
||||
}
|
||||
|
||||
/**
|
||||
* A simpler mapping of [ReleaseType] used for grouping and sorting songs.
|
||||
*
|
||||
* @param headerTitleRes The title string resource to use for a header created out of an
|
||||
* instance of this enum.
|
||||
*/
|
||||
private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
|
||||
ALBUMS(R.string.lbl_albums),
|
||||
EPS(R.string.lbl_eps),
|
||||
SINGLES(R.string.lbl_singles),
|
||||
COMPILATIONS(R.string.lbl_compilations),
|
||||
SOUNDTRACKS(R.string.lbl_soundtracks),
|
||||
DJMIXES(R.string.lbl_mixes),
|
||||
MIXTAPES(R.string.lbl_mixtapes),
|
||||
DEMOS(R.string.lbl_demos),
|
||||
APPEARANCES(R.string.lbl_appears_on),
|
||||
LIVE(R.string.lbl_live_group),
|
||||
REMIXES(R.string.lbl_remix_group),
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
|
||||
val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -35,10 +35,10 @@ import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
|||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.music.info.resolveNumber
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
|
||||
|
@ -111,16 +111,10 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
|||
*/
|
||||
fun bind(discHeader: DiscHeader) {
|
||||
val disc = discHeader.inner
|
||||
if (disc != null) {
|
||||
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
|
||||
binding.discName.apply {
|
||||
text = disc.name
|
||||
isGone = disc.name == null
|
||||
}
|
||||
} else {
|
||||
logD("Disc is null, defaulting to no disc")
|
||||
binding.discNumber.text = binding.context.getString(R.string.def_disc)
|
||||
binding.discName.isGone = true
|
||||
binding.discNumber.text = disc.resolveNumber(binding.context)
|
||||
binding.discName.apply {
|
||||
text = disc?.name
|
||||
isGone = disc?.name == null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
166
app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt
Normal file
166
app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt
Normal 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 }
|
||||
}
|
|
@ -27,4 +27,6 @@ import dagger.hilt.components.SingletonComponent
|
|||
@InstallIn(SingletonComponent::class)
|
||||
interface HomeModule {
|
||||
@Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
|
||||
|
||||
@Binds fun homeGeneratorFactory(factory: HomeGeneratorFactoryImpl): HomeGenerator.Factory
|
||||
}
|
||||
|
|
|
@ -42,9 +42,9 @@ interface HomeSettings : Settings<HomeSettings.Listener> {
|
|||
|
||||
interface Listener {
|
||||
/** Called when the [homeTabs] configuration changes. */
|
||||
fun onTabsChanged()
|
||||
fun onTabsChanged() {}
|
||||
/** Called when the [shouldHideCollaborators] configuration changes. */
|
||||
fun onHideCollaboratorsChanged()
|
||||
fun onHideCollaboratorsChanged() {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -30,7 +30,6 @@ import org.oxycblt.auxio.list.sort.Sort
|
|||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
@ -49,12 +48,10 @@ import org.oxycblt.auxio.util.logD
|
|||
class HomeViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val homeSettings: HomeSettings,
|
||||
private val listSettings: ListSettings,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
|
||||
|
||||
homeGeneratorFactory: HomeGenerator.Factory
|
||||
) : ViewModel(), HomeGenerator.Invalidator {
|
||||
private val _songList = MutableStateFlow(listOf<Song>())
|
||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
val songList: StateFlow<List<Song>>
|
||||
|
@ -132,11 +129,13 @@ constructor(
|
|||
val playlistSort: Sort
|
||||
get() = listSettings.playlistSort
|
||||
|
||||
private val homeGenerator = homeGeneratorFactory.create(this)
|
||||
|
||||
/**
|
||||
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
|
||||
* [Tab]s.
|
||||
*/
|
||||
var currentTabTypes = makeTabTypes()
|
||||
var currentTabTypes = homeGenerator.tabs()
|
||||
private set
|
||||
|
||||
private val _currentTabType = MutableStateFlow(currentTabTypes[0])
|
||||
|
@ -165,63 +164,44 @@ constructor(
|
|||
get() = _showOuter
|
||||
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
homeSettings.registerListener(this)
|
||||
homeGenerator.attach()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicRepository.removeUpdateListener(this)
|
||||
homeSettings.unregisterListener(this)
|
||||
homeGenerator.release()
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||
logD("Refreshing library")
|
||||
// Get the each list of items in the library to use as our list data.
|
||||
// Applying the preferred sorting to them.
|
||||
_songInstructions.put(UpdateInstructions.Diff)
|
||||
_songList.value = listSettings.songSort.songs(deviceLibrary.songs)
|
||||
_albumInstructions.put(UpdateInstructions.Diff)
|
||||
_albumList.value = listSettings.albumSort.albums(deviceLibrary.albums)
|
||||
_artistInstructions.put(UpdateInstructions.Diff)
|
||||
_artistList.value =
|
||||
listSettings.artistSort.artists(
|
||||
if (homeSettings.shouldHideCollaborators) {
|
||||
logD("Filtering collaborator artists")
|
||||
// Hide Collaborators is enabled, filter out collaborators.
|
||||
deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() }
|
||||
} else {
|
||||
logD("Using all artists")
|
||||
deviceLibrary.artists
|
||||
})
|
||||
_genreInstructions.put(UpdateInstructions.Diff)
|
||||
_genreList.value = listSettings.genreSort.genres(deviceLibrary.genres)
|
||||
}
|
||||
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (changes.userLibrary && userLibrary != null) {
|
||||
logD("Refreshing playlists")
|
||||
_playlistInstructions.put(UpdateInstructions.Diff)
|
||||
_playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists)
|
||||
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
|
||||
when (type) {
|
||||
MusicType.SONGS -> {
|
||||
_songInstructions.put(instructions)
|
||||
_songList.value = homeGenerator.songs()
|
||||
}
|
||||
MusicType.ALBUMS -> {
|
||||
_albumInstructions.put(instructions)
|
||||
_albumList.value = homeGenerator.albums()
|
||||
}
|
||||
MusicType.ARTISTS -> {
|
||||
_artistInstructions.put(instructions)
|
||||
_artistList.value = homeGenerator.artists()
|
||||
}
|
||||
MusicType.GENRES -> {
|
||||
_genreInstructions.put(instructions)
|
||||
_genreList.value = homeGenerator.genres()
|
||||
}
|
||||
MusicType.PLAYLISTS -> {
|
||||
_playlistInstructions.put(instructions)
|
||||
_playlistList.value = homeGenerator.playlists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabsChanged() {
|
||||
// Tabs changed, update the current tabs and set up a re-create event.
|
||||
currentTabTypes = makeTabTypes()
|
||||
logD("Updating tabs: ${currentTabType.value}")
|
||||
override fun invalidateTabs() {
|
||||
currentTabTypes = homeGenerator.tabs()
|
||||
_shouldRecreate.put(Unit)
|
||||
}
|
||||
|
||||
override fun onHideCollaboratorsChanged() {
|
||||
// Changes in the hide collaborator setting will change the artist contents
|
||||
// of the library, consider it a library update.
|
||||
logD("Collaborator setting changed, forwarding update")
|
||||
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new [Sort] to [songList].
|
||||
*
|
||||
|
@ -229,8 +209,6 @@ constructor(
|
|||
*/
|
||||
fun applySongSort(sort: Sort) {
|
||||
listSettings.songSort = sort
|
||||
_songInstructions.put(UpdateInstructions.Replace(0))
|
||||
_songList.value = listSettings.songSort.songs(_songList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -240,8 +218,6 @@ constructor(
|
|||
*/
|
||||
fun applyAlbumSort(sort: Sort) {
|
||||
listSettings.albumSort = sort
|
||||
_albumInstructions.put(UpdateInstructions.Replace(0))
|
||||
_albumList.value = listSettings.albumSort.albums(_albumList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -251,8 +227,6 @@ constructor(
|
|||
*/
|
||||
fun applyArtistSort(sort: Sort) {
|
||||
listSettings.artistSort = sort
|
||||
_artistInstructions.put(UpdateInstructions.Replace(0))
|
||||
_artistList.value = listSettings.artistSort.artists(_artistList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -262,8 +236,6 @@ constructor(
|
|||
*/
|
||||
fun applyGenreSort(sort: Sort) {
|
||||
listSettings.genreSort = sort
|
||||
_genreInstructions.put(UpdateInstructions.Replace(0))
|
||||
_genreList.value = listSettings.genreSort.genres(_genreList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -273,8 +245,6 @@ constructor(
|
|||
*/
|
||||
fun applyPlaylistSort(sort: Sort) {
|
||||
listSettings.playlistSort = sort
|
||||
_playlistInstructions.put(UpdateInstructions.Replace(0))
|
||||
_playlistList.value = listSettings.playlistSort.playlists(_playlistList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -314,15 +284,6 @@ constructor(
|
|||
fun showAbout() {
|
||||
_showOuter.put(Outer.About)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list of [MusicType]s representing a simpler version of the [Tab] configuration.
|
||||
*
|
||||
* @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in
|
||||
* the same way as the configuration.
|
||||
*/
|
||||
private fun makeTabTypes() =
|
||||
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
|
||||
}
|
||||
|
||||
sealed interface Outer {
|
||||
|
|
|
@ -37,40 +37,24 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
|
|||
private val width = context.resources.configuration.smallestScreenWidthDp
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
val icon: Int
|
||||
val string: Int
|
||||
|
||||
when (tabs[position]) {
|
||||
MusicType.SONGS -> {
|
||||
icon = R.drawable.ic_song_24
|
||||
string = R.string.lbl_songs
|
||||
val homeTab = tabs[position]
|
||||
val icon =
|
||||
when (homeTab) {
|
||||
MusicType.SONGS -> R.drawable.ic_song_24
|
||||
MusicType.ALBUMS -> R.drawable.ic_album_24
|
||||
MusicType.ARTISTS -> R.drawable.ic_artist_24
|
||||
MusicType.GENRES -> R.drawable.ic_genre_24
|
||||
MusicType.PLAYLISTS -> R.drawable.ic_playlist_24
|
||||
}
|
||||
MusicType.ALBUMS -> {
|
||||
icon = R.drawable.ic_album_24
|
||||
string = R.string.lbl_albums
|
||||
}
|
||||
MusicType.ARTISTS -> {
|
||||
icon = R.drawable.ic_artist_24
|
||||
string = R.string.lbl_artists
|
||||
}
|
||||
MusicType.GENRES -> {
|
||||
icon = R.drawable.ic_genre_24
|
||||
string = R.string.lbl_genres
|
||||
}
|
||||
MusicType.PLAYLISTS -> {
|
||||
icon = R.drawable.ic_playlist_24
|
||||
string = R.string.lbl_playlists
|
||||
}
|
||||
}
|
||||
|
||||
// Use expected sw* size thresholds when choosing a configuration.
|
||||
when {
|
||||
// On small screens, only display an icon.
|
||||
width < 370 -> tab.setIcon(icon).setContentDescription(string)
|
||||
width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes)
|
||||
// On large screens, display an icon and text.
|
||||
width < 600 -> tab.setText(string)
|
||||
width < 600 -> tab.setText(homeTab.nameRes)
|
||||
// On medium-size screens, display text.
|
||||
else -> tab.setIcon(icon).setText(string)
|
||||
else -> tab.setIcon(icon).setText(homeTab.nameRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
|
||||
interface ListSettings : Settings<Unit> {
|
||||
interface ListSettings : Settings<ListSettings.Listener> {
|
||||
/** The [Sort] mode used in Song lists. */
|
||||
var songSort: Sort
|
||||
/** The [Sort] mode used in Album lists. */
|
||||
|
@ -43,10 +43,28 @@ interface ListSettings : Settings<Unit> {
|
|||
var artistSongSort: Sort
|
||||
/** The [Sort] mode used in a Genre's Song list. */
|
||||
var genreSongSort: Sort
|
||||
|
||||
interface Listener {
|
||||
fun onSongSortChanged() {}
|
||||
|
||||
fun onAlbumSortChanged() {}
|
||||
|
||||
fun onAlbumSongSortChanged() {}
|
||||
|
||||
fun onArtistSortChanged() {}
|
||||
|
||||
fun onArtistSongSortChanged() {}
|
||||
|
||||
fun onGenreSortChanged() {}
|
||||
|
||||
fun onGenreSongSortChanged() {}
|
||||
|
||||
fun onPlaylistSortChanged() {}
|
||||
}
|
||||
}
|
||||
|
||||
class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) :
|
||||
Settings.Impl<Unit>(context), ListSettings {
|
||||
Settings.Impl<ListSettings.Listener>(context), ListSettings {
|
||||
override var songSort: Sort
|
||||
get() =
|
||||
Sort.fromIntCode(
|
||||
|
@ -145,4 +163,17 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont
|
|||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String, listener: ListSettings.Listener) {
|
||||
when (key) {
|
||||
getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged()
|
||||
getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged()
|
||||
getString(R.string.set_key_album_songs_sort) -> listener.onAlbumSongSortChanged()
|
||||
getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged()
|
||||
getString(R.string.set_key_artist_songs_sort) -> listener.onArtistSongSortChanged()
|
||||
getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged()
|
||||
getString(R.string.set_key_genre_songs_sort) -> listener.onGenreSongSortChanged()
|
||||
getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
package org.oxycblt.auxio.music
|
||||
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
|
||||
/**
|
||||
* General configuration enum to control what kind of music is being worked with.
|
||||
|
@ -52,6 +53,16 @@ enum class MusicType {
|
|||
PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS
|
||||
}
|
||||
|
||||
val nameRes: Int
|
||||
get() =
|
||||
when (this) {
|
||||
SONGS -> R.string.lbl_songs
|
||||
ALBUMS -> R.string.lbl_albums
|
||||
ARTISTS -> R.string.lbl_artists
|
||||
GENRES -> R.string.lbl_genres
|
||||
PLAYLISTS -> R.string.lbl_playlists
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Convert a [MusicType] integer representation into an instance.
|
||||
|
|
|
@ -18,6 +18,8 @@
|
|||
|
||||
package org.oxycblt.auxio.music.info
|
||||
|
||||
import android.content.Context
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.Item
|
||||
|
||||
/**
|
||||
|
@ -34,3 +36,7 @@ class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
|
|||
|
||||
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)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* IndexerServiceFragment.kt is part of Auxio.
|
||||
* Indexer.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -21,13 +21,13 @@ package org.oxycblt.auxio.music.service
|
|||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.ForegroundListener
|
||||
import org.oxycblt.auxio.ForegroundServiceNotification
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
|
@ -35,34 +35,52 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
|||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
class IndexerServiceFragment
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext override val workerContext: Context,
|
||||
class Indexer
|
||||
private constructor(
|
||||
override val workerContext: Context,
|
||||
private val foregroundListener: ForegroundListener,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val musicSettings: MusicSettings,
|
||||
private val contentObserver: SystemContentObserver,
|
||||
private val imageLoader: ImageLoader
|
||||
private val imageLoader: ImageLoader,
|
||||
private val contentObserver: SystemContentObserver
|
||||
) :
|
||||
MusicRepository.IndexingWorker,
|
||||
MusicRepository.IndexingListener,
|
||||
MusicRepository.UpdateListener,
|
||||
MusicSettings.Listener {
|
||||
class Factory
|
||||
@Inject
|
||||
constructor(
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val musicSettings: MusicSettings,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val contentObserver: SystemContentObserver
|
||||
) {
|
||||
fun create(context: Context, listener: ForegroundListener) =
|
||||
Indexer(
|
||||
context,
|
||||
listener,
|
||||
playbackManager,
|
||||
musicRepository,
|
||||
musicSettings,
|
||||
imageLoader,
|
||||
contentObserver)
|
||||
}
|
||||
|
||||
private val indexJob = Job()
|
||||
private val indexScope = CoroutineScope(indexJob + Dispatchers.IO)
|
||||
private var currentIndexJob: Job? = null
|
||||
private val indexingNotification = IndexingNotification(workerContext)
|
||||
private val observingNotification = ObservingNotification(workerContext)
|
||||
private var foregroundListener: ForegroundListener? = null
|
||||
private val wakeLock =
|
||||
workerContext
|
||||
.getSystemServiceCompat(PowerManager::class)
|
||||
.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
|
||||
|
||||
fun attach(listener: ForegroundListener) {
|
||||
foregroundListener = listener
|
||||
fun attach() {
|
||||
musicSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
musicRepository.addIndexingListener(this)
|
||||
|
@ -76,35 +94,6 @@ constructor(
|
|||
musicRepository.addIndexingListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
musicRepository.removeIndexingListener(this)
|
||||
foregroundListener = null
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (musicRepository.indexingState == null) {
|
||||
requestIndex(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNotification(post: (IndexerNotification?) -> Unit) {
|
||||
val state = musicRepository.indexingState
|
||||
if (state is IndexingState.Indexing) {
|
||||
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
||||
// 1. Newer versions of Android have become more and more restrictive regarding
|
||||
// how a foreground service starts. Thus, it's best to go foreground now so that
|
||||
// we can go foreground later.
|
||||
// 2. If a non-foreground service is killed, the app will probably still be alive,
|
||||
// and thus the music library will not be updated at all.
|
||||
val changed = indexingNotification.updateIndexingState(state.progress)
|
||||
if (changed) {
|
||||
post(indexingNotification)
|
||||
}
|
||||
} else if (musicSettings.shouldBeObserving) {
|
||||
// Not observing and done loading, exit foreground.
|
||||
logD("Exiting foreground")
|
||||
post(observingNotification)
|
||||
} else {
|
||||
post(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
|
@ -118,7 +107,7 @@ constructor(
|
|||
override val scope = indexScope
|
||||
|
||||
override fun onIndexingStateChanged() {
|
||||
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
|
||||
foregroundListener.updateForeground(ForegroundListener.Change.INDEXER)
|
||||
val state = musicRepository.indexingState
|
||||
if (state is IndexingState.Indexing) {
|
||||
wakeLock.acquireSafe()
|
||||
|
@ -157,9 +146,31 @@ constructor(
|
|||
// notification if we were actively loading when the automatic rescanning
|
||||
// setting changed. In such a case, the state will still be updated when
|
||||
// the music loading process ends.
|
||||
if (currentIndexJob == null) {
|
||||
if (musicRepository.indexingState == null) {
|
||||
logD("Not loading, updating idle session")
|
||||
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
|
||||
foregroundListener.updateForeground(ForegroundListener.Change.INDEXER)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNotification(post: (ForegroundServiceNotification?) -> Unit) {
|
||||
val state = musicRepository.indexingState
|
||||
if (state is IndexingState.Indexing) {
|
||||
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
||||
// 1. Newer versions of Android have become more and more restrictive regarding
|
||||
// how a foreground service starts. Thus, it's best to go foreground now so that
|
||||
// we can go foreground later.
|
||||
// 2. If a non-foreground service is killed, the app will probably still be alive,
|
||||
// and thus the music library will not be updated at all.
|
||||
val changed = indexingNotification.updateIndexingState(state.progress)
|
||||
if (changed) {
|
||||
post(indexingNotification)
|
||||
}
|
||||
} else if (musicSettings.shouldBeObserving) {
|
||||
// Not observing and done loading, exit foreground.
|
||||
logD("Exiting foreground")
|
||||
post(observingNotification)
|
||||
} else {
|
||||
post(null)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,11 +20,9 @@ package org.oxycblt.auxio.music.service
|
|||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.ForegroundServiceNotification
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.IndexingProgress
|
||||
|
@ -32,52 +30,13 @@ import org.oxycblt.auxio.util.logD
|
|||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
/**
|
||||
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
|
||||
* signal a Service's ongoing foreground state.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class IndexerNotification(context: Context, info: ChannelInfo) :
|
||||
NotificationCompat.Builder(context, info.id) {
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
|
||||
init {
|
||||
// Set up the notification channel. Foreground notifications are non-substantial, and
|
||||
// thus make no sense to have lights, vibration, or lead to a notification badge.
|
||||
val channel =
|
||||
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(context.getString(info.nameRes))
|
||||
.setLightsEnabled(false)
|
||||
.setVibrationEnabled(false)
|
||||
.setShowBadge(false)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* The code used to identify this notification.
|
||||
*
|
||||
* @see NotificationManagerCompat.notify
|
||||
*/
|
||||
abstract val code: Int
|
||||
|
||||
/**
|
||||
* Reduced representation of a [NotificationChannelCompat].
|
||||
*
|
||||
* @param id The ID of the channel.
|
||||
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
|
||||
*/
|
||||
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
|
||||
}
|
||||
|
||||
/**
|
||||
* A dynamic [IndexerNotification] that shows the current music loading state.
|
||||
* A dynamic [ForegroundServiceNotification] that shows the current music loading state.
|
||||
*
|
||||
* @param context [Context] required to create the notification.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class IndexingNotification(private val context: Context) :
|
||||
IndexerNotification(context, indexerChannel) {
|
||||
ForegroundServiceNotification(context, indexerChannel) {
|
||||
private var lastUpdateTime = -1L
|
||||
|
||||
init {
|
||||
|
@ -133,12 +92,13 @@ class IndexingNotification(private val context: Context) :
|
|||
}
|
||||
|
||||
/**
|
||||
* A static [IndexerNotification] that signals to the user that the app is currently monitoring the
|
||||
* music library for changes.
|
||||
* A static [ForegroundServiceNotification] that signals to the user that the app is currently
|
||||
* monitoring the music library for changes.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) {
|
||||
class ObservingNotification(context: Context) :
|
||||
ForegroundServiceNotification(context, indexerChannel) {
|
||||
init {
|
||||
setSmallIcon(R.drawable.ic_indexer_24)
|
||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
|
@ -156,5 +116,5 @@ class ObservingNotification(context: Context) : IndexerNotification(context, ind
|
|||
|
||||
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
|
||||
private val indexerChannel =
|
||||
IndexerNotification.ChannelInfo(
|
||||
ForegroundServiceNotification.ChannelInfo(
|
||||
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -19,15 +19,12 @@
|
|||
package org.oxycblt.auxio.music.service
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.DrawableRes
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.media.utils.MediaConstants
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import java.io.ByteArrayOutputStream
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Album
|
||||
|
@ -37,242 +34,19 @@ import org.oxycblt.auxio.music.Music
|
|||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.formatDurationDs
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
|
||||
fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem {
|
||||
// TODO: Make custom overflow menu for compat
|
||||
val style =
|
||||
Bundle().apply {
|
||||
putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)
|
||||
}
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(context.getString(nameRes))
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(mediaType)
|
||||
.setExtras(style)
|
||||
if (bitmapRes != null) {
|
||||
val data = ByteArrayOutputStream()
|
||||
BitmapFactory.decodeResource(context.resources, bitmapRes)
|
||||
.compress(Bitmap.CompressFormat.PNG, 100, data)
|
||||
metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON)
|
||||
}
|
||||
return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build()
|
||||
}
|
||||
|
||||
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
|
||||
val mediaSessionUID =
|
||||
if (parent == null) {
|
||||
MediaSessionUID.Single(uid)
|
||||
} else {
|
||||
MediaSessionUID.Joined(parent.uid, uid)
|
||||
}
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setArtist(artists.resolveNames(context))
|
||||
.setAlbumTitle(album.name.resolve(context))
|
||||
.setAlbumArtist(album.artists.resolveNames(context))
|
||||
.setTrackNumber(track)
|
||||
.setDiscNumber(disc?.number)
|
||||
.setGenre(genres.resolveNames(context))
|
||||
.setDisplayTitle(name.resolve(context))
|
||||
.setSubtitle(artists.resolveNames(context))
|
||||
.setRecordingYear(album.dates?.min?.year)
|
||||
.setRecordingMonth(album.dates?.min?.month)
|
||||
.setRecordingDay(album.dates?.min?.day)
|
||||
.setReleaseYear(album.dates?.min?.year)
|
||||
.setReleaseMonth(album.dates?.min?.month)
|
||||
.setReleaseDay(album.dates?.min?.day)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(false)
|
||||
.setArtworkUri(cover.mediaStoreCoverUri)
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
putString("uid", mediaSessionUID.toString())
|
||||
putLong("durationMs", durationMs)
|
||||
})
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Album.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setArtist(artists.resolveNames(context))
|
||||
.setAlbumTitle(name.resolve(context))
|
||||
.setAlbumArtist(artists.resolveNames(context))
|
||||
.setRecordingYear(dates?.min?.year)
|
||||
.setRecordingMonth(dates?.min?.month)
|
||||
.setRecordingDay(dates?.min?.day)
|
||||
.setReleaseYear(dates?.min?.year)
|
||||
.setReleaseMonth(dates?.min?.month)
|
||||
.setReleaseDay(dates?.min?.day)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Artist.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
context.getString(
|
||||
R.string.fmt_two,
|
||||
if (explicitAlbums.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size)
|
||||
} else {
|
||||
context.getString(R.string.def_album_count)
|
||||
},
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
}))
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setGenre(genres.resolveNames(context))
|
||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Genre.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Playlist.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(cover?.single?.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? {
|
||||
val uid = MediaSessionUID.fromString(mediaId) ?: return null
|
||||
return when (uid) {
|
||||
is MediaSessionUID.Single -> {
|
||||
deviceLibrary.findSong(uid.uid)
|
||||
}
|
||||
is MediaSessionUID.Joined -> {
|
||||
deviceLibrary.findSong(uid.childUid)
|
||||
}
|
||||
is MediaSessionUID.Category -> null
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface MediaSessionUID {
|
||||
enum class Category(
|
||||
val id: String,
|
||||
@StringRes val nameRes: Int,
|
||||
@DrawableRes val bitmapRes: Int?,
|
||||
val mediaType: Int?
|
||||
) : MediaSessionUID {
|
||||
ROOT("root", R.string.info_app_name, null, null),
|
||||
SONGS(
|
||||
"songs",
|
||||
R.string.lbl_songs,
|
||||
R.drawable.ic_song_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_MUSIC),
|
||||
ALBUMS(
|
||||
"albums",
|
||||
R.string.lbl_albums,
|
||||
R.drawable.ic_album_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||
ARTISTS(
|
||||
"artists",
|
||||
R.string.lbl_artists,
|
||||
R.drawable.ic_artist_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
|
||||
GENRES(
|
||||
"genres",
|
||||
R.string.lbl_genres,
|
||||
R.drawable.ic_genre_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_GENRES),
|
||||
PLAYLISTS(
|
||||
"playlists",
|
||||
R.string.lbl_playlists,
|
||||
R.drawable.ic_playlist_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
|
||||
|
||||
override fun toString() = "$ID_CATEGORY:$id"
|
||||
|
||||
companion object {
|
||||
val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES)
|
||||
val USER_MUSIC = listOf(ROOT, PLAYLISTS)
|
||||
val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS)
|
||||
}
|
||||
data class Tab(val node: TabNode) : MediaSessionUID {
|
||||
override fun toString() = "$ID_CATEGORY:${node.id}"
|
||||
}
|
||||
|
||||
data class Single(val uid: Music.UID) : MediaSessionUID {
|
||||
data class SingleItem(val uid: Music.UID) : MediaSessionUID {
|
||||
override fun toString() = "$ID_ITEM:$uid"
|
||||
}
|
||||
|
||||
data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID {
|
||||
override fun toString() = "$ID_ITEM:$parentUid>$childUid"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category"
|
||||
const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item"
|
||||
|
@ -283,28 +57,154 @@ sealed interface MediaSessionUID {
|
|||
return null
|
||||
}
|
||||
return when (parts[0]) {
|
||||
ID_CATEGORY ->
|
||||
when (parts[1]) {
|
||||
Category.ROOT.id -> Category.ROOT
|
||||
Category.SONGS.id -> Category.SONGS
|
||||
Category.ALBUMS.id -> Category.ALBUMS
|
||||
Category.ARTISTS.id -> Category.ARTISTS
|
||||
Category.GENRES.id -> Category.GENRES
|
||||
Category.PLAYLISTS.id -> Category.PLAYLISTS
|
||||
else -> null
|
||||
}
|
||||
ID_ITEM -> {
|
||||
val uids = parts[1].split(">", limit = 2)
|
||||
if (uids.size == 1) {
|
||||
Music.UID.fromString(uids[0])?.let { Single(it) }
|
||||
} else {
|
||||
Music.UID.fromString(uids[0])?.let { parent ->
|
||||
Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) }
|
||||
}
|
||||
}
|
||||
}
|
||||
ID_CATEGORY -> Tab(TabNode.fromString(parts[1]) ?: return null)
|
||||
ID_ITEM -> SingleItem(Music.UID.fromString(parts[1]) ?: return null)
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias Sugar = Bundle.(Context) -> Unit
|
||||
|
||||
fun header(@StringRes nameRes: Int): Sugar = {
|
||||
putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes))
|
||||
}
|
||||
|
||||
fun header(name: String): Sugar = {
|
||||
putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, name)
|
||||
}
|
||||
|
||||
fun child(of: MusicParent): Sugar = {
|
||||
putString(MusicBrowser.KEY_CHILD_OF, MediaSessionUID.SingleItem(of.uid).toString())
|
||||
}
|
||||
|
||||
private fun style(style: Int): Sugar = {
|
||||
putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style)
|
||||
}
|
||||
|
||||
private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle {
|
||||
return Bundle().apply { sugars.forEach { this.it(context) } }
|
||||
}
|
||||
|
||||
fun TabNode.toMediaItem(context: Context): MediaItem {
|
||||
val extras =
|
||||
makeExtras(
|
||||
context,
|
||||
style(MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM))
|
||||
val mediaSessionUID = MediaSessionUID.Tab(this)
|
||||
val description =
|
||||
MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(context.getString(nameRes))
|
||||
.setExtras(extras)
|
||||
bitmapRes?.let { res ->
|
||||
val bitmap = BitmapFactory.decodeResource(context.resources, res)
|
||||
description.setIconBitmap(bitmap)
|
||||
}
|
||||
return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
fun Song.toMediaDescription(context: Context, vararg sugar: Sugar): MediaDescriptionCompat {
|
||||
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||
val extras = makeExtras(context, *sugar)
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(artists.resolveNames(context))
|
||||
.setDescription(album.name.resolve(context))
|
||||
.setIconUri(cover.mediaStoreCoverUri)
|
||||
.setMediaUri(uri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Song.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
||||
return MediaItem(toMediaDescription(context, *sugar), MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
|
||||
fun Album.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||
val extras = makeExtras(context, *sugar)
|
||||
val counts = context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
val description =
|
||||
MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(artists.resolveNames(context))
|
||||
.setDescription(counts)
|
||||
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||
val counts =
|
||||
context.getString(
|
||||
R.string.fmt_two,
|
||||
if (explicitAlbums.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size)
|
||||
} else {
|
||||
context.getString(R.string.def_album_count)
|
||||
},
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
val extras = makeExtras(context, *sugar)
|
||||
val description =
|
||||
MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(counts)
|
||||
.setDescription(genres.resolveNames(context))
|
||||
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||
val counts =
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
}
|
||||
val extras = makeExtras(context, *sugar)
|
||||
val description =
|
||||
MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(counts)
|
||||
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||
val counts =
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
}
|
||||
val extras = makeExtras(context, *sugar)
|
||||
val description =
|
||||
MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(counts)
|
||||
.setDescription(durationMs.formatDurationDs(true))
|
||||
.setIconUri(cover?.single?.mediaStoreCoverUri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
79
app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt
Normal file
79
app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -46,9 +46,8 @@ import org.oxycblt.auxio.image.ImageSettings
|
|||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.service.toMediaItem
|
||||
import org.oxycblt.auxio.music.service.toSong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.msToSecs
|
||||
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
|
@ -91,7 +90,6 @@ class ExoPlaybackStateHolder(
|
|||
fun attach() {
|
||||
imageSettings.registerListener(this)
|
||||
player.addListener(this)
|
||||
replayGainProcessor.attach()
|
||||
playbackManager.registerStateHolder(this)
|
||||
playbackSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
|
@ -110,10 +108,6 @@ class ExoPlaybackStateHolder(
|
|||
override var parent: MusicParent? = null
|
||||
private set
|
||||
|
||||
val mediaSessionPlayer: Player
|
||||
get() =
|
||||
MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository)
|
||||
|
||||
override val progression: Progression
|
||||
get() {
|
||||
val mediaItem = player.currentMediaItem ?: return Progression.nil()
|
||||
|
@ -146,10 +140,7 @@ class ExoPlaybackStateHolder(
|
|||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return RawQueue(
|
||||
heap.mapNotNull { it.toSong(deviceLibrary) },
|
||||
shuffledMapping,
|
||||
player.currentMediaItemIndex)
|
||||
return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex)
|
||||
}
|
||||
|
||||
override fun handleDeferred(action: DeferredPlayback): Boolean {
|
||||
|
@ -226,7 +217,7 @@ class ExoPlaybackStateHolder(
|
|||
override fun newPlayback(command: PlaybackCommand) {
|
||||
parent = command.parent
|
||||
player.shuffleModeEnabled = command.shuffled
|
||||
player.setMediaItems(command.queue.map { it.toMediaItem(context, null) })
|
||||
player.setMediaItems(command.queue.map { it.buildMediaItem() })
|
||||
val startIndex =
|
||||
command.song
|
||||
?.let { command.queue.indexOf(it) }
|
||||
|
@ -316,16 +307,16 @@ class ExoPlaybackStateHolder(
|
|||
}
|
||||
|
||||
if (nextIndex == C.INDEX_UNSET) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
||||
player.addMediaItems(songs.map { it.buildMediaItem() })
|
||||
} else {
|
||||
player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) })
|
||||
player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() })
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
||||
player.addMediaItems(songs.map { it.buildMediaItem() })
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
@ -373,16 +364,18 @@ class ExoPlaybackStateHolder(
|
|||
override fun applySavedState(
|
||||
parent: MusicParent?,
|
||||
rawQueue: RawQueue,
|
||||
positionMs: Long,
|
||||
repeatMode: RepeatMode,
|
||||
ack: StateAck.NewPlayback?
|
||||
) {
|
||||
logD("Applying saved state")
|
||||
var sendEvent = false
|
||||
var sendNewPlaybackEvent = false
|
||||
var shouldSeek = false
|
||||
if (this.parent != parent) {
|
||||
this.parent = parent
|
||||
sendEvent = true
|
||||
sendNewPlaybackEvent = true
|
||||
}
|
||||
if (rawQueue != resolveQueue()) {
|
||||
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
|
||||
player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() })
|
||||
if (rawQueue.isShuffled) {
|
||||
player.shuffleModeEnabled = true
|
||||
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
||||
|
@ -392,9 +385,18 @@ class ExoPlaybackStateHolder(
|
|||
player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
|
||||
player.prepare()
|
||||
player.pause()
|
||||
sendEvent = true
|
||||
sendNewPlaybackEvent = true
|
||||
shouldSeek = true
|
||||
}
|
||||
if (sendEvent) {
|
||||
|
||||
repeatMode(repeatMode)
|
||||
// Positions in milliseconds will drift during tight restores (i.e what the music loader
|
||||
// does to sanitize the state), compare by seconds instead.
|
||||
if (positionMs.msToSecs() != player.currentPosition.msToSecs() || shouldSeek) {
|
||||
player.seekTo(positionMs)
|
||||
}
|
||||
|
||||
if (sendNewPlaybackEvent) {
|
||||
ack?.let { playbackManager.ack(this, it) }
|
||||
}
|
||||
}
|
||||
|
@ -538,6 +540,50 @@ class ExoPlaybackStateHolder(
|
|||
currentSaveJob = saveScope.launch { block() }
|
||||
}
|
||||
|
||||
private fun Song.buildMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build()
|
||||
|
||||
private val MediaItem.song: Song?
|
||||
get() = this.localConfiguration?.tag as? Song?
|
||||
|
||||
private fun Player.unscrambleQueueIndices(): List<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
|
||||
@Inject
|
||||
constructor(
|
||||
|
@ -553,7 +599,7 @@ class ExoPlaybackStateHolder(
|
|||
) {
|
||||
fun create(): ExoPlaybackStateHolder {
|
||||
// Since Auxio is a music player, only specify an audio renderer to save
|
||||
// battery/apk size/cache size
|
||||
// battery/apk size/cache size]
|
||||
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
||||
arrayOf(
|
||||
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -1,285 +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 start(startedBy: Int) {
|
||||
// At minimum we want to ensure an active playback state.
|
||||
// TODO: Possibly also force to go foreground?
|
||||
logD("Handling non-native start.")
|
||||
val action =
|
||||
when (startedBy) {
|
||||
IntegerTable.START_ID_ACTIVITY -> null
|
||||
IntegerTable.START_ID_TASKER ->
|
||||
DeferredPlayback.RestoreState(
|
||||
play = true, fallback = DeferredPlayback.ShuffleAll)
|
||||
// External services using Auxio better know what they are doing.
|
||||
else -> DeferredPlayback.RestoreState(play = false)
|
||||
}
|
||||
if (action != null) {
|
||||
logD("Initing service fragment using action $action")
|
||||
playbackManager.playDeferred(action)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasNotification(): Boolean = exoHolder.sessionOngoing
|
||||
|
||||
fun createNotification(post: (MediaNotification) -> Unit) {
|
||||
val notification =
|
||||
mediaNotificationProvider.createNotification(
|
||||
mediaSession, mediaSession.customLayout, actionFactory) { notification ->
|
||||
post(wrapMediaNotification(notification))
|
||||
}
|
||||
post(wrapMediaNotification(notification))
|
||||
}
|
||||
|
||||
fun release() {
|
||||
waitJob.cancel()
|
||||
mediaItemBrowser.release()
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -1,280 +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.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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -145,7 +145,13 @@ interface PlaybackStateHolder {
|
|||
* @param ack The [StateAck] to return to [PlaybackStateManager]. If null, do not return any
|
||||
* ack.
|
||||
*/
|
||||
fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?)
|
||||
fun applySavedState(
|
||||
parent: MusicParent?,
|
||||
rawQueue: RawQueue,
|
||||
positionMs: Long,
|
||||
repeatMode: RepeatMode,
|
||||
ack: StateAck.NewPlayback?
|
||||
)
|
||||
|
||||
/** End whatever ongoing playback session may be going on */
|
||||
fun endSession()
|
||||
|
|
|
@ -416,9 +416,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
|
||||
this.stateHolder = stateHolder
|
||||
if (isInitialized && currentSong != null) {
|
||||
stateHolder.applySavedState(stateMirror.parent, stateMirror.rawQueue, null)
|
||||
stateHolder.seekTo(stateMirror.progression.calculateElapsedPositionMs())
|
||||
stateHolder.playing(false)
|
||||
stateHolder.applySavedState(
|
||||
stateMirror.parent,
|
||||
stateMirror.rawQueue,
|
||||
stateMirror.progression.calculateElapsedPositionMs(),
|
||||
stateMirror.repeatMode,
|
||||
null)
|
||||
}
|
||||
pendingDeferredPlayback?.let(stateHolder::handleDeferred)
|
||||
}
|
||||
|
@ -795,9 +798,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
index
|
||||
})
|
||||
|
||||
stateHolder.applySavedState(savedState.parent, rawQueue, StateAck.NewPlayback)
|
||||
stateHolder.seekTo(savedState.positionMs)
|
||||
stateHolder.repeatMode(savedState.repeatMode)
|
||||
stateHolder.applySavedState(
|
||||
savedState.parent,
|
||||
rawQueue,
|
||||
savedState.positionMs,
|
||||
savedState.repeatMode,
|
||||
StateAck.NewPlayback)
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
|
|
|
@ -61,7 +61,7 @@ interface SearchEngine {
|
|||
val artists: Collection<Artist>? = null,
|
||||
val genres: Collection<Genre>? = null,
|
||||
val playlists: Collection<Playlist>? = null
|
||||
)
|
||||
) {}
|
||||
}
|
||||
|
||||
class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||
|
|
|
@ -23,7 +23,6 @@ import android.graphics.Bitmap
|
|||
import android.os.Build
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.image.BitmapProvider
|
||||
|
@ -47,17 +46,28 @@ import org.oxycblt.auxio.util.logD
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class WidgetComponent
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private constructor(
|
||||
private val context: Context,
|
||||
private val imageSettings: ImageSettings,
|
||||
private val bitmapProvider: BitmapProvider,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val uiSettings: UISettings
|
||||
) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener {
|
||||
class Factory
|
||||
@Inject
|
||||
constructor(
|
||||
private val imageSettings: ImageSettings,
|
||||
private val bitmapProvider: BitmapProvider,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val uiSettings: UISettings
|
||||
) {
|
||||
fun create(context: Context) =
|
||||
WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings)
|
||||
}
|
||||
|
||||
private val widgetProvider = WidgetProvider()
|
||||
|
||||
init {
|
||||
fun attach() {
|
||||
playbackManager.addListener(this)
|
||||
uiSettings.registerListener(this)
|
||||
imageSettings.registerListener(this)
|
||||
|
@ -90,7 +100,7 @@ constructor(
|
|||
} else if (uiSettings.roundMode) {
|
||||
// < Android 12, but the user still enabled round mode.
|
||||
logD("Using default corner radius")
|
||||
context.getDimenPixels(R.dimen.size_corners_medium)
|
||||
context.getDimenPixels(R.dimen.spacing_medium)
|
||||
} else {
|
||||
// User did not enable round mode.
|
||||
logD("Using no corner radius")
|
||||
|
|
BIN
app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 123 B |
BIN
app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 97 B |
BIN
app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 141 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 179 B |
BIN
app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 217 B |
|
@ -163,6 +163,7 @@
|
|||
<string name="lbl_reset">Reset</string>
|
||||
<!-- As in to add a new folder in the "Music folders" setting -->
|
||||
<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_absolute">Absolute</string>
|
||||
|
|
2
fastlane/metadata/android/en-US/changelogs/50.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/50.txt
Normal 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
|
2
media
2
media
|
@ -1 +1 @@
|
|||
Subproject commit 34b33175c00183dc95cdcb8c735033b6785041e1
|
||||
Subproject commit 9fc2401b8fdc2b23905402462e775c6db4e1527f
|
Loading…
Reference in a new issue