Merge branch 'master' into dev
This commit is contained in:
commit
8d767a0aac
55 changed files with 2913 additions and 2109 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
|
||||
|
|
36
CHANGELOG.md
36
CHANGELOG.md
|
@ -16,6 +16,42 @@
|
|||
- Excessive CPU no longer spent showing music loading process
|
||||
- Fixed playback sheet flickering on warm start
|
||||
|
||||
## 3.6.0
|
||||
|
||||
#### What's New
|
||||
- Added support for playback from google assistant
|
||||
|
||||
#### What's Improved
|
||||
- Home and detail UIs in Android Auto now reflect app sort settings
|
||||
- Album view now shows discs in android auto
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed playback briefly pausing when adding songs to playlist
|
||||
- Fixed media lists in Android Auto being truncated in some cases
|
||||
- Possibly fixed duplicated song items depending on album/all children
|
||||
- Possibly fixed truncated tab lists in android auto
|
||||
|
||||
#### Dev/Meta
|
||||
- Moved to raw media session apis rather than media3 session
|
||||
|
||||
## 3.5.3
|
||||
|
||||
#### What's New
|
||||
- Basic Tasker integration for safely starting Auxio's service
|
||||
|
||||
#### What's Improved
|
||||
- Added support for informal singular-spaced tags like `album artist` in
|
||||
file metadata
|
||||
|
||||
#### What's Fixed
|
||||
- Fix "Foreground not allowed" music loading crash from starting too early
|
||||
- Fixed widget not loading on some devices due to the cover being too large
|
||||
|
||||
## 3.5.2
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed music loading failure from improper sort systems (For real this time)
|
||||
|
||||
## 3.5.1
|
||||
|
||||
#### What's Fixed
|
||||
|
|
|
@ -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.1">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.5.1&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">
|
||||
|
|
|
@ -16,13 +16,13 @@ android {
|
|||
// it here so that binary stripping will work.
|
||||
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
|
||||
// NDK use is unified
|
||||
ndkVersion = "25.2.9519653"
|
||||
ndkVersion "26.3.11579264"
|
||||
namespace "org.oxycblt.auxio"
|
||||
|
||||
defaultConfig {
|
||||
applicationId namespace
|
||||
versionName "3.5.1"
|
||||
versionCode 47
|
||||
versionName "3.6.0"
|
||||
versionCode 50
|
||||
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
|
@ -118,6 +118,9 @@ dependencies {
|
|||
// Media
|
||||
implementation "androidx.media:media:1.7.0"
|
||||
|
||||
// Android Auto
|
||||
implementation "androidx.car.app:app:1.4.0"
|
||||
|
||||
// Preferences
|
||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
|
||||
|
@ -130,7 +133,6 @@ dependencies {
|
|||
// --- THIRD PARTY ---
|
||||
|
||||
// Exoplayer (Vendored)
|
||||
implementation project(":media-lib-session")
|
||||
implementation project(":media-lib-exoplayer")
|
||||
implementation project(":media-lib-decoder-ffmpeg")
|
||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
|
||||
|
@ -155,6 +157,12 @@ dependencies {
|
|||
// Speed dial
|
||||
implementation "com.leinardi.android:speed-dial:3.3.0"
|
||||
|
||||
// Tasker integration
|
||||
implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10'
|
||||
|
||||
// Fuzzy search
|
||||
implementation 'org.apache.commons:commons-text:1.9'
|
||||
|
||||
// Testing
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
|
|
|
@ -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>
|
||||
|
@ -135,5 +134,15 @@
|
|||
android:resource="@xml/widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Tasker 'start service' integration -->
|
||||
<activity
|
||||
android:name=".tasker.ActivityConfigStartAction"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/lbl_start_playback">
|
||||
<intent-filter>
|
||||
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
|
@ -19,92 +19,147 @@
|
|||
package org.oxycblt.auxio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.service.IndexerServiceFragment
|
||||
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment
|
||||
import org.oxycblt.auxio.music.service.MusicServiceFragment
|
||||
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuxioService : MediaLibraryService(), ForegroundListener {
|
||||
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
|
||||
class AuxioService :
|
||||
MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator {
|
||||
@Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory
|
||||
private lateinit var playbackFragment: PlaybackServiceFragment
|
||||
|
||||
@Inject lateinit var indexingFragment: IndexerServiceFragment
|
||||
@Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory
|
||||
private lateinit var musicFragment: MusicServiceFragment
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
mediaSessionFragment.attach(this, this)
|
||||
indexingFragment.attach(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
start(intent)
|
||||
return super.onBind(intent)
|
||||
playbackFragment = playbackFragmentFactory.create(this, this)
|
||||
sessionToken = playbackFragment.attach()
|
||||
musicFragment = musicFragmentFactory.create(this, this, this)
|
||||
musicFragment.attach()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// TODO: Start command occurring from a foreign service basically implies a detached
|
||||
// service, we might need more handling here.
|
||||
start(intent)
|
||||
onHandleForeground(intent)
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
private fun start(intent: Intent?) {
|
||||
val nativeStart = intent?.getBooleanExtra(INTENT_KEY_NATIVE_START, false) ?: false
|
||||
if (!nativeStart) {
|
||||
// Some foreign code started us, no guarantees about foreground stability. Figure
|
||||
// out what to do.
|
||||
mediaSessionFragment.handleNonNativeStart()
|
||||
}
|
||||
indexingFragment.start()
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
onHandleForeground(intent)
|
||||
return super.onBind(intent)
|
||||
}
|
||||
|
||||
private fun onHandleForeground(intent: Intent?) {
|
||||
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
|
||||
musicFragment.start()
|
||||
playbackFragment.start(startId)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
mediaSessionFragment.handleTaskRemoved()
|
||||
playbackFragment.handleTaskRemoved()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
indexingFragment.release()
|
||||
mediaSessionFragment.release()
|
||||
musicFragment.release()
|
||||
playbackFragment.release()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
|
||||
mediaSessionFragment.mediaSession
|
||||
override fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): BrowserRoot {
|
||||
return musicFragment.getRoot()
|
||||
}
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
updateForeground(ForegroundListener.Change.MEDIA_SESSION)
|
||||
override fun onLoadItem(itemId: String, result: Result<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)
|
||||
}
|
||||
startForeground(mediaNotification.code, mediaNotification.build())
|
||||
}
|
||||
// Nothing changed, but don't show anything music related since we can always
|
||||
// index during playback.
|
||||
} else {
|
||||
indexingFragment.createNotification {
|
||||
musicFragment.createNotification {
|
||||
if (it != null) {
|
||||
startForeground(it.code, it.build())
|
||||
isForeground = true
|
||||
} else {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
isForeground = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun invalidateMusic(mediaId: String) {
|
||||
logD(mediaId)
|
||||
notifyChildrenChanged(mediaId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isForeground = false
|
||||
private set
|
||||
|
||||
// This is only meant for Auxio to internally ensure that it's state management will work.
|
||||
const val INTENT_KEY_NATIVE_START = BuildConfig.APPLICATION_ID + ".service.NATIVE_START"
|
||||
const val INTENT_KEY_START_ID = BuildConfig.APPLICATION_ID + ".service.START_ID"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -116,3 +171,42 @@ interface ForegroundListener {
|
|||
INDEXER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
|
||||
* signal a Service's ongoing foreground state.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) :
|
||||
NotificationCompat.Builder(context, info.id) {
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
|
||||
init {
|
||||
// Set up the notification channel. Foreground notifications are non-substantial, and
|
||||
// thus make no sense to have lights, vibration, or lead to a notification badge.
|
||||
val channel =
|
||||
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(context.getString(info.nameRes))
|
||||
.setLightsEnabled(false)
|
||||
.setVibrationEnabled(false)
|
||||
.setShowBadge(false)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* The code used to identify this notification.
|
||||
*
|
||||
* @see NotificationManagerCompat.notify
|
||||
*/
|
||||
abstract val code: Int
|
||||
|
||||
/**
|
||||
* Reduced representation of a [NotificationChannelCompat].
|
||||
*
|
||||
* @param id The ID of the channel.
|
||||
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
|
||||
*/
|
||||
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
|
||||
}
|
||||
|
|
|
@ -59,6 +59,10 @@ object IntegerTable {
|
|||
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
|
||||
/** MainActivity Intent request code */
|
||||
const val REQUEST_CODE = 0xA0C0
|
||||
/** Activity AuxioService Start ID */
|
||||
const val START_ID_ACTIVITY = 0xA050
|
||||
/** Tasker AuxioService Start ID */
|
||||
const val START_ID_TASKER = 0xA051
|
||||
/** RepeatMode.NONE */
|
||||
const val REPEAT_MODE_NONE = 0xA100
|
||||
/** RepeatMode.ALL */
|
||||
|
|
|
@ -71,11 +71,11 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
startService(
|
||||
Intent(this, AuxioService::class.java)
|
||||
.putExtra(AuxioService.INTENT_KEY_NATIVE_START, true))
|
||||
.putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY))
|
||||
|
||||
if (!startIntentAction(intent)) {
|
||||
// No intent action to do, just restore the previously saved state.
|
||||
playbackModel.playDeferred(DeferredPlayback.RestoreState)
|
||||
playbackModel.playDeferred(DeferredPlayback.RestoreState(false))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -501,15 +471,15 @@ constructor(
|
|||
fun movePlaylistSongs(from: Int, to: Int): Boolean {
|
||||
val playlist = _currentPlaylist.value ?: return false
|
||||
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
|
||||
val realFrom = from - 2
|
||||
val realTo = to - 2
|
||||
val realFrom = from - 1
|
||||
val realTo = to - 1
|
||||
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
|
||||
return false
|
||||
}
|
||||
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
|
||||
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
|
||||
_editedPlaylist.value = editedPlaylist
|
||||
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
|
||||
refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -521,20 +491,20 @@ constructor(
|
|||
fun removePlaylistSong(at: Int) {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
|
||||
val realAt = at - 2
|
||||
val realAt = at - 1
|
||||
if (realAt !in editedPlaylist.indices) {
|
||||
return
|
||||
}
|
||||
logD("Removing playlist song at $realAt [$at]")
|
||||
editedPlaylist.removeAt(realAt)
|
||||
_editedPlaylist.value = editedPlaylist
|
||||
refreshPlaylistList(
|
||||
playlist,
|
||||
refreshPlaylist(
|
||||
playlist.uid,
|
||||
if (editedPlaylist.isNotEmpty()) {
|
||||
UpdateInstructions.Remove(at, 1)
|
||||
} else {
|
||||
logD("Playlist will be empty after removal, removing header")
|
||||
UpdateInstructions.Remove(at - 2, 3)
|
||||
UpdateInstructions.Remove(at - 1, 3)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -552,173 +522,72 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
|
||||
logD("Refreshing album list")
|
||||
val list = mutableListOf<Item>()
|
||||
val header = SortHeader(R.string.lbl_songs)
|
||||
list.add(header)
|
||||
val instructions =
|
||||
if (replace) {
|
||||
// Intentional so that the header item isn't replaced with the songs
|
||||
UpdateInstructions.Replace(list.size)
|
||||
} else {
|
||||
UpdateInstructions.Diff
|
||||
}
|
||||
|
||||
// To create a good user experience regarding disc numbers, we group the album's
|
||||
// songs up by disc and then delimit the groups by a disc header.
|
||||
val songs = albumSongSort.songs(album.songs)
|
||||
val byDisc = songs.groupBy { it.disc }
|
||||
if (byDisc.size > 1) {
|
||||
logD("Album has more than one disc, interspersing headers")
|
||||
for (entry in byDisc.entries) {
|
||||
list.add(DiscHeader(entry.key))
|
||||
list.addAll(entry.value)
|
||||
}
|
||||
} else {
|
||||
// Album only has one disc, don't add any redundant headers
|
||||
list.addAll(songs)
|
||||
private fun <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 ((i, entry) in grouping.entries.withIndex()) {
|
||||
val header = BasicHeader(entry.key.headerTitleRes)
|
||||
if (i > 0) {
|
||||
list.add(Divider(header))
|
||||
}
|
||||
list.add(header)
|
||||
list.addAll(ARTIST_ALBUM_SORT.albums(entry.value))
|
||||
}
|
||||
|
||||
// Artists may not be linked to any songs, only include a header entry if we have any.
|
||||
var instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
logD("Songs present in this artist, adding header")
|
||||
val header = SortHeader(R.string.lbl_songs)
|
||||
list.add(Divider(header))
|
||||
list.add(header)
|
||||
if (replace) {
|
||||
// Currently only the final section (songs, which can be sorted) are invalidatable
|
||||
// and thus need to be replaced.
|
||||
if (replace == -1 && i == detail.sections.lastIndex) {
|
||||
// Intentional so that the header item isn't replaced with the songs
|
||||
instructions = UpdateInstructions.Replace(list.size)
|
||||
newInstructions = UpdateInstructions.Replace(newList.size)
|
||||
}
|
||||
list.addAll(artistSongSort.songs(artist.songs))
|
||||
newList.addAll(items)
|
||||
}
|
||||
|
||||
logD("Updating artist list to ${list.size} items with $instructions")
|
||||
_artistSongInstructions.put(instructions)
|
||||
_artistSongList.value = list.toList()
|
||||
parent.value = detail.parent
|
||||
instructions.put(newInstructions)
|
||||
list.value = newList
|
||||
}
|
||||
|
||||
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
|
||||
logD("Refreshing genre list")
|
||||
val list = mutableListOf<Item>()
|
||||
// Genre is guaranteed to always have artists and songs.
|
||||
val artistHeader = BasicHeader(R.string.lbl_artists)
|
||||
list.add(artistHeader)
|
||||
list.addAll(GENRE_ARTIST_SORT.artists(genre.artists))
|
||||
|
||||
val songHeader = SortHeader(R.string.lbl_songs)
|
||||
list.add(Divider(songHeader))
|
||||
list.add(songHeader)
|
||||
val instructions =
|
||||
if (replace) {
|
||||
// Intentional so that the header item isn't replaced alongside the songs
|
||||
UpdateInstructions.Replace(list.size)
|
||||
} else {
|
||||
UpdateInstructions.Diff
|
||||
}
|
||||
list.addAll(genreSongSort.songs(genre.songs))
|
||||
|
||||
logD("Updating genre list to ${list.size} items with $instructions")
|
||||
_genreSongInstructions.put(instructions)
|
||||
_genreSongList.value = list
|
||||
}
|
||||
|
||||
private fun refreshPlaylistList(
|
||||
playlist: Playlist,
|
||||
private fun refreshPlaylist(
|
||||
uid: Music.UID,
|
||||
instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||
) {
|
||||
logD("Refreshing playlist list")
|
||||
val list = mutableListOf<Item>()
|
||||
|
||||
val songs = editedPlaylist.value ?: playlist.songs
|
||||
if (songs.isNotEmpty()) {
|
||||
val header = EditHeader(R.string.lbl_songs)
|
||||
list.add(header)
|
||||
list.addAll(songs)
|
||||
val edited = editedPlaylist.value
|
||||
if (edited == null) {
|
||||
val playlist = detailGenerator.playlist(uid)
|
||||
refreshDetail(
|
||||
playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null)
|
||||
return
|
||||
}
|
||||
val list = mutableListOf<Item>()
|
||||
if (edited.isNotEmpty()) {
|
||||
val header = EditHeader(R.string.lbl_songs)
|
||||
list.add(Divider(header))
|
||||
list.add(header)
|
||||
list.addAll(edited)
|
||||
}
|
||||
|
||||
logD("Updating playlist list to ${list.size} items with $instructions")
|
||||
_playlistSongInstructions.put(instructions)
|
||||
_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])
|
||||
|
@ -161,63 +160,44 @@ constructor(
|
|||
get() = _showOuter
|
||||
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
homeSettings.registerListener(this)
|
||||
homeGenerator.attach()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicRepository.removeUpdateListener(this)
|
||||
homeSettings.unregisterListener(this)
|
||||
homeGenerator.release()
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||
logD("Refreshing library")
|
||||
// Get the each list of items in the library to use as our list data.
|
||||
// Applying the preferred sorting to them.
|
||||
_songInstructions.put(UpdateInstructions.Diff)
|
||||
_songList.value = listSettings.songSort.songs(deviceLibrary.songs)
|
||||
_albumInstructions.put(UpdateInstructions.Diff)
|
||||
_albumList.value = listSettings.albumSort.albums(deviceLibrary.albums)
|
||||
_artistInstructions.put(UpdateInstructions.Diff)
|
||||
_artistList.value =
|
||||
listSettings.artistSort.artists(
|
||||
if (homeSettings.shouldHideCollaborators) {
|
||||
logD("Filtering collaborator artists")
|
||||
// Hide Collaborators is enabled, filter out collaborators.
|
||||
deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() }
|
||||
} else {
|
||||
logD("Using all artists")
|
||||
deviceLibrary.artists
|
||||
})
|
||||
_genreInstructions.put(UpdateInstructions.Diff)
|
||||
_genreList.value = listSettings.genreSort.genres(deviceLibrary.genres)
|
||||
}
|
||||
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (changes.userLibrary && userLibrary != null) {
|
||||
logD("Refreshing playlists")
|
||||
_playlistInstructions.put(UpdateInstructions.Diff)
|
||||
_playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists)
|
||||
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
|
||||
when (type) {
|
||||
MusicType.SONGS -> {
|
||||
_songInstructions.put(instructions)
|
||||
_songList.value = homeGenerator.songs()
|
||||
}
|
||||
MusicType.ALBUMS -> {
|
||||
_albumInstructions.put(instructions)
|
||||
_albumList.value = homeGenerator.albums()
|
||||
}
|
||||
MusicType.ARTISTS -> {
|
||||
_artistInstructions.put(instructions)
|
||||
_artistList.value = homeGenerator.artists()
|
||||
}
|
||||
MusicType.GENRES -> {
|
||||
_genreInstructions.put(instructions)
|
||||
_genreList.value = homeGenerator.genres()
|
||||
}
|
||||
MusicType.PLAYLISTS -> {
|
||||
_playlistInstructions.put(instructions)
|
||||
_playlistList.value = homeGenerator.playlists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabsChanged() {
|
||||
// Tabs changed, update the current tabs and set up a re-create event.
|
||||
currentTabTypes = makeTabTypes()
|
||||
logD("Updating tabs: ${currentTabType.value}")
|
||||
override fun invalidateTabs() {
|
||||
currentTabTypes = homeGenerator.tabs()
|
||||
_shouldRecreate.put(Unit)
|
||||
}
|
||||
|
||||
override fun onHideCollaboratorsChanged() {
|
||||
// Changes in the hide collaborator setting will change the artist contents
|
||||
// of the library, consider it a library update.
|
||||
logD("Collaborator setting changed, forwarding update")
|
||||
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new [Sort] to [songList].
|
||||
*
|
||||
|
@ -225,8 +205,6 @@ constructor(
|
|||
*/
|
||||
fun applySongSort(sort: Sort) {
|
||||
listSettings.songSort = sort
|
||||
_songInstructions.put(UpdateInstructions.Replace(0))
|
||||
_songList.value = listSettings.songSort.songs(_songList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -236,8 +214,6 @@ constructor(
|
|||
*/
|
||||
fun applyAlbumSort(sort: Sort) {
|
||||
listSettings.albumSort = sort
|
||||
_albumInstructions.put(UpdateInstructions.Replace(0))
|
||||
_albumList.value = listSettings.albumSort.albums(_albumList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -247,8 +223,6 @@ constructor(
|
|||
*/
|
||||
fun applyArtistSort(sort: Sort) {
|
||||
listSettings.artistSort = sort
|
||||
_artistInstructions.put(UpdateInstructions.Replace(0))
|
||||
_artistList.value = listSettings.artistSort.artists(_artistList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -258,8 +232,6 @@ constructor(
|
|||
*/
|
||||
fun applyGenreSort(sort: Sort) {
|
||||
listSettings.genreSort = sort
|
||||
_genreInstructions.put(UpdateInstructions.Replace(0))
|
||||
_genreList.value = listSettings.genreSort.genres(_genreList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -269,8 +241,6 @@ constructor(
|
|||
*/
|
||||
fun applyPlaylistSort(sort: Sort) {
|
||||
listSettings.playlistSort = sort
|
||||
_playlistInstructions.put(UpdateInstructions.Replace(0))
|
||||
_playlistList.value = listSettings.playlistSort.playlists(_playlistList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -300,15 +270,6 @@ constructor(
|
|||
fun showAbout() {
|
||||
_showOuter.put(Outer.About)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list of [MusicType]s representing a simpler version of the [Tab] configuration.
|
||||
*
|
||||
* @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in
|
||||
* the same way as the configuration.
|
||||
*/
|
||||
private fun makeTabTypes() =
|
||||
homeSettings.homeTabs.filterIsInstance<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -107,7 +107,10 @@ class RoundedRectTransformation(
|
|||
}
|
||||
|
||||
private fun calculateOutputSize(input: Bitmap, size: Size): Pair<Int, Int> {
|
||||
// MODIFICATION: Remove short-circuiting for original size and input size
|
||||
if (size == Size.ORIGINAL) {
|
||||
// This path only runs w/the widget code, which already normalizes widget sizes
|
||||
return input.width to input.height
|
||||
}
|
||||
val multiplier =
|
||||
DecodeUtils.computeSizeMultiplier(
|
||||
srcWidth = input.width,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date
|
|||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
||||
import org.oxycblt.auxio.music.metadata.splitEscaped
|
||||
|
||||
@Database(entities = [CachedSong::class], version = 46, exportSchema = false)
|
||||
@Database(entities = [CachedSong::class], version = 49, exportSchema = false)
|
||||
abstract class CacheDatabase : RoomDatabase() {
|
||||
abstract fun cachedSongsDao(): CachedSongsDao
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -70,12 +70,12 @@ sealed interface Name : Comparable<Name> {
|
|||
final override fun compareTo(other: Name) =
|
||||
when (other) {
|
||||
is Known -> {
|
||||
// Progressively compare the sort tokens between each known name.
|
||||
sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) ->
|
||||
acc.takeIf { it != 0 } ?: token.compareTo(otherToken)
|
||||
}
|
||||
val result =
|
||||
sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) ->
|
||||
acc.takeIf { it != 0 } ?: token.compareTo(otherToken)
|
||||
}
|
||||
if (result != 0) result else sortTokens.size.compareTo(other.sortTokens.size)
|
||||
}
|
||||
// Unknown names always come before known names.
|
||||
is Unknown -> 1
|
||||
}
|
||||
|
||||
|
|
|
@ -100,6 +100,7 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
|
|||
|
||||
private fun populateWithId3v2(rawSong: RawSong, textFrames: Map<String, List<String>>) {
|
||||
// Song
|
||||
logD(textFrames)
|
||||
(textFrames["TXXX:musicbrainz release track id"]
|
||||
?: textFrames["TXXX:musicbrainz_releasetrackid"])
|
||||
?.let { rawSong.musicBrainzId = it.first() }
|
||||
|
@ -147,10 +148,13 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
|
|||
(textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
|
||||
rawSong.artistMusicBrainzIds = it
|
||||
}
|
||||
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
|
||||
(textFrames["TXXX:artists"] ?: textFrames["TPE1"] ?: textFrames["TXXX:artist"])?.let {
|
||||
rawSong.artistNames = it
|
||||
}
|
||||
(textFrames["TXXX:artistssort"]
|
||||
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
|
||||
?: textFrames["TSOP"])
|
||||
?: textFrames["TSOP"] ?: textFrames["artistsort"]
|
||||
?: textFrames["TXXX:artist sort"])
|
||||
?.let { rawSong.artistSortNames = it }
|
||||
|
||||
// Album artist
|
||||
|
@ -159,13 +163,14 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
|
|||
?.let { rawSong.albumArtistMusicBrainzIds = it }
|
||||
(textFrames["TXXX:albumartists"]
|
||||
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
|
||||
?: textFrames["TPE2"])
|
||||
?: textFrames["TPE2"] ?: textFrames["TXXX:albumartist"]
|
||||
?: textFrames["TXXX:album artist"])
|
||||
?.let { rawSong.albumArtistNames = it }
|
||||
(textFrames["TXXX:albumartistssort"]
|
||||
?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"]
|
||||
?: textFrames["TXXX:albumartistsort"]
|
||||
// This is a non-standard iTunes extension
|
||||
?: textFrames["TSO2"])
|
||||
?: textFrames["TSO2"] ?: textFrames["TXXX:album artist sort"])
|
||||
?.let { rawSong.albumArtistSortNames = it }
|
||||
|
||||
// Genre
|
||||
|
@ -273,7 +278,8 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
|
|||
}
|
||||
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
|
||||
(comments["artistssort"]
|
||||
?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"])
|
||||
?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"]
|
||||
?: comments["artist sort"])
|
||||
?.let { rawSong.artistSortNames = it }
|
||||
|
||||
// Album artist
|
||||
|
@ -281,12 +287,12 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
|
|||
rawSong.albumArtistMusicBrainzIds = it
|
||||
}
|
||||
(comments["albumartists"]
|
||||
?: comments["album_artists"] ?: comments["album artists"]
|
||||
?: comments["albumartist"])
|
||||
?: comments["album_artists"] ?: comments["album artists"] ?: comments["albumartist"]
|
||||
?: comments["album artist"])
|
||||
?.let { rawSong.albumArtistNames = it }
|
||||
(comments["albumartistssort"]
|
||||
?: comments["albumartists_sort"] ?: comments["albumartists sort"]
|
||||
?: comments["albumartistsort"])
|
||||
?: comments["albumartistsort"] ?: comments["album artist sort"])
|
||||
?.let { rawSong.albumArtistSortNames = it }
|
||||
|
||||
// Genre
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* IndexerServiceFragment.kt is part of Auxio.
|
||||
* Indexer.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -21,13 +21,13 @@ package org.oxycblt.auxio.music.service
|
|||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.ForegroundListener
|
||||
import org.oxycblt.auxio.ForegroundServiceNotification
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
|
@ -35,34 +35,52 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
|||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
class IndexerServiceFragment
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext override val workerContext: Context,
|
||||
class Indexer
|
||||
private constructor(
|
||||
override val workerContext: Context,
|
||||
private val foregroundListener: ForegroundListener,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val musicSettings: MusicSettings,
|
||||
private val contentObserver: SystemContentObserver,
|
||||
private val imageLoader: ImageLoader
|
||||
private val imageLoader: ImageLoader,
|
||||
private val contentObserver: SystemContentObserver
|
||||
) :
|
||||
MusicRepository.IndexingWorker,
|
||||
MusicRepository.IndexingListener,
|
||||
MusicRepository.UpdateListener,
|
||||
MusicSettings.Listener {
|
||||
class Factory
|
||||
@Inject
|
||||
constructor(
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val musicSettings: MusicSettings,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val contentObserver: SystemContentObserver
|
||||
) {
|
||||
fun create(context: Context, listener: ForegroundListener) =
|
||||
Indexer(
|
||||
context,
|
||||
listener,
|
||||
playbackManager,
|
||||
musicRepository,
|
||||
musicSettings,
|
||||
imageLoader,
|
||||
contentObserver)
|
||||
}
|
||||
|
||||
private val indexJob = Job()
|
||||
private val indexScope = CoroutineScope(indexJob + Dispatchers.IO)
|
||||
private var currentIndexJob: Job? = null
|
||||
private val indexingNotification = IndexingNotification(workerContext)
|
||||
private val observingNotification = ObservingNotification(workerContext)
|
||||
private var foregroundListener: ForegroundListener? = null
|
||||
private val wakeLock =
|
||||
workerContext
|
||||
.getSystemServiceCompat(PowerManager::class)
|
||||
.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
|
||||
|
||||
fun attach(listener: ForegroundListener) {
|
||||
foregroundListener = listener
|
||||
fun attach() {
|
||||
musicSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
musicRepository.addIndexingListener(this)
|
||||
|
@ -76,7 +94,6 @@ constructor(
|
|||
musicRepository.removeIndexingListener(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
musicSettings.unregisterListener(this)
|
||||
foregroundListener = null
|
||||
}
|
||||
|
||||
fun start() {
|
||||
|
@ -85,7 +102,7 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun createNotification(post: (IndexerNotification?) -> Unit) {
|
||||
fun createNotification(post: (ForegroundServiceNotification?) -> Unit) {
|
||||
val state = musicRepository.indexingState
|
||||
if (state is IndexingState.Indexing) {
|
||||
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
||||
|
@ -118,7 +135,7 @@ constructor(
|
|||
override val scope = indexScope
|
||||
|
||||
override fun onIndexingStateChanged() {
|
||||
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
|
||||
foregroundListener.updateForeground(ForegroundListener.Change.INDEXER)
|
||||
val state = musicRepository.indexingState
|
||||
if (state is IndexingState.Indexing) {
|
||||
wakeLock.acquireSafe()
|
||||
|
@ -157,9 +174,9 @@ constructor(
|
|||
// notification if we were actively loading when the automatic rescanning
|
||||
// setting changed. In such a case, the state will still be updated when
|
||||
// the music loading process ends.
|
||||
if (currentIndexJob == null) {
|
||||
if (musicRepository.indexingState == null) {
|
||||
logD("Not loading, updating idle session")
|
||||
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
|
||||
foregroundListener.updateForeground(ForegroundListener.Change.INDEXER)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,8 +46,6 @@ import org.oxycblt.auxio.image.ImageSettings
|
|||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.service.toMediaItem
|
||||
import org.oxycblt.auxio.music.service.toSong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.msToSecs
|
||||
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||
|
@ -92,7 +90,6 @@ class ExoPlaybackStateHolder(
|
|||
fun attach() {
|
||||
imageSettings.registerListener(this)
|
||||
player.addListener(this)
|
||||
replayGainProcessor.attach()
|
||||
playbackManager.registerStateHolder(this)
|
||||
playbackSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
|
@ -111,10 +108,6 @@ class ExoPlaybackStateHolder(
|
|||
override var parent: MusicParent? = null
|
||||
private set
|
||||
|
||||
val mediaSessionPlayer: Player
|
||||
get() =
|
||||
MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository)
|
||||
|
||||
override val progression: Progression
|
||||
get() {
|
||||
val mediaItem = player.currentMediaItem ?: return Progression.nil()
|
||||
|
@ -147,10 +140,7 @@ class ExoPlaybackStateHolder(
|
|||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return RawQueue(
|
||||
heap.mapNotNull { it.toSong(deviceLibrary) },
|
||||
shuffledMapping,
|
||||
player.currentMediaItemIndex)
|
||||
return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex)
|
||||
}
|
||||
|
||||
override fun handleDeferred(action: DeferredPlayback): Boolean {
|
||||
|
@ -164,10 +154,18 @@ class ExoPlaybackStateHolder(
|
|||
is DeferredPlayback.RestoreState -> {
|
||||
logD("Restoring playback state")
|
||||
restoreScope.launch {
|
||||
persistenceRepository.readState()?.let {
|
||||
// Apply the saved state on the main thread to prevent code expecting
|
||||
// state updates on the main thread from crashing.
|
||||
withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) }
|
||||
val state = persistenceRepository.readState()
|
||||
withContext(Dispatchers.Main) {
|
||||
if (state != null) {
|
||||
// Apply the saved state on the main thread to prevent code expecting
|
||||
// state updates on the main thread from crashing.
|
||||
playbackManager.applySavedState(state, false)
|
||||
if (action.play) {
|
||||
playbackManager.playing(true)
|
||||
}
|
||||
} else if (action.fallback != null) {
|
||||
playbackManager.playDeferred(action.fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -219,7 +217,7 @@ class ExoPlaybackStateHolder(
|
|||
override fun newPlayback(command: PlaybackCommand) {
|
||||
parent = command.parent
|
||||
player.shuffleModeEnabled = command.shuffled
|
||||
player.setMediaItems(command.queue.map { it.toMediaItem(context, null) })
|
||||
player.setMediaItems(command.queue.map { it.buildMediaItem() })
|
||||
val startIndex =
|
||||
command.song
|
||||
?.let { command.queue.indexOf(it) }
|
||||
|
@ -309,16 +307,16 @@ class ExoPlaybackStateHolder(
|
|||
}
|
||||
|
||||
if (nextIndex == C.INDEX_UNSET) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
||||
player.addMediaItems(songs.map { it.buildMediaItem() })
|
||||
} else {
|
||||
player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) })
|
||||
player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() })
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
||||
player.addMediaItems(songs.map { it.buildMediaItem() })
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
@ -370,12 +368,6 @@ class ExoPlaybackStateHolder(
|
|||
repeatMode: RepeatMode,
|
||||
ack: StateAck.NewPlayback?
|
||||
) {
|
||||
val resolve = resolveQueue()
|
||||
logD("${rawQueue.heap == resolve.heap}")
|
||||
logD("${rawQueue.shuffledMapping == resolve.shuffledMapping}")
|
||||
logD("${rawQueue.heapIndex == resolve.heapIndex}")
|
||||
logD("${rawQueue.isShuffled == resolve.isShuffled}")
|
||||
logD("${rawQueue == resolve}")
|
||||
var sendNewPlaybackEvent = false
|
||||
var shouldSeek = false
|
||||
if (this.parent != parent) {
|
||||
|
@ -383,7 +375,7 @@ class ExoPlaybackStateHolder(
|
|||
sendNewPlaybackEvent = true
|
||||
}
|
||||
if (rawQueue != resolveQueue()) {
|
||||
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
|
||||
player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() })
|
||||
if (rawQueue.isShuffled) {
|
||||
player.shuffleModeEnabled = true
|
||||
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
||||
|
@ -548,6 +540,50 @@ class ExoPlaybackStateHolder(
|
|||
currentSaveJob = saveScope.launch { block() }
|
||||
}
|
||||
|
||||
private fun Song.buildMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build()
|
||||
|
||||
private val MediaItem.song: Song?
|
||||
get() = this.localConfiguration?.tag as? Song?
|
||||
|
||||
private fun Player.unscrambleQueueIndices(): List<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(
|
||||
|
@ -563,7 +599,7 @@ class ExoPlaybackStateHolder(
|
|||
) {
|
||||
fun create(): ExoPlaybackStateHolder {
|
||||
// Since Auxio is a music player, only specify an audio renderer to save
|
||||
// battery/apk size/cache size
|
||||
// battery/apk size/cache size]
|
||||
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
||||
arrayOf(
|
||||
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
|
||||
|
|
|
@ -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,273 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* MediaSessionServiceFragment.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.DefaultActionFactory
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.LibraryResult
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
|
||||
import androidx.media3.session.MediaNotification
|
||||
import androidx.media3.session.MediaNotification.ActionFactory
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSession.ConnectionResult
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.guava.asListenableFuture
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.ForegroundListener
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.service.MediaItemBrowser
|
||||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
class MediaSessionServiceFragment
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val actionHandler: PlaybackActionHandler,
|
||||
private val mediaItemBrowser: MediaItemBrowser,
|
||||
exoHolderFactory: ExoPlaybackStateHolder.Factory
|
||||
) :
|
||||
MediaLibrarySession.Callback,
|
||||
PlaybackActionHandler.Callback,
|
||||
MediaItemBrowser.Invalidator,
|
||||
PlaybackStateManager.Listener {
|
||||
private val waitJob = Job()
|
||||
private val waitScope = CoroutineScope(waitJob + Dispatchers.Default)
|
||||
private val exoHolder = exoHolderFactory.create()
|
||||
|
||||
private lateinit var actionFactory: ActionFactory
|
||||
private val mediaNotificationProvider =
|
||||
DefaultMediaNotificationProvider.Builder(context)
|
||||
.setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE)
|
||||
.setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK")
|
||||
.setChannelName(R.string.lbl_playback)
|
||||
.setPlayDrawableResourceId(R.drawable.ic_play_24)
|
||||
.setPauseDrawableResourceId(R.drawable.ic_pause_24)
|
||||
.setSkipNextDrawableResourceId(R.drawable.ic_skip_next_24)
|
||||
.setSkipPrevDrawableResourceId(R.drawable.ic_skip_prev_24)
|
||||
.setContentIntent(context.newMainPendingIntent())
|
||||
.build()
|
||||
.also { it.setSmallIcon(R.drawable.ic_auxio_24) }
|
||||
private var foregroundListener: ForegroundListener? = null
|
||||
|
||||
lateinit var mediaSession: MediaLibrarySession
|
||||
private set
|
||||
|
||||
// --- MEDIASESSION CALLBACKS ---
|
||||
|
||||
fun attach(service: MediaLibraryService, listener: ForegroundListener): MediaLibrarySession {
|
||||
foregroundListener = listener
|
||||
mediaSession = createSession(service)
|
||||
service.addSession(mediaSession)
|
||||
actionFactory = DefaultActionFactory(service)
|
||||
playbackManager.addListener(this)
|
||||
exoHolder.attach()
|
||||
actionHandler.attach(this)
|
||||
mediaItemBrowser.attach(this)
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
fun handleTaskRemoved() {
|
||||
if (!playbackManager.progression.isPlaying) {
|
||||
playbackManager.endSession()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleNonNativeStart() {
|
||||
// At minimum we want to ensure an active playback state.
|
||||
// TODO: Possibly also force to go foreground?
|
||||
logD("Handling non-native start.")
|
||||
playbackManager.playDeferred(DeferredPlayback.RestoreState)
|
||||
}
|
||||
|
||||
fun hasNotification(): Boolean = exoHolder.sessionOngoing
|
||||
|
||||
fun createNotification(post: (MediaNotification) -> Unit) {
|
||||
val notification =
|
||||
mediaNotificationProvider.createNotification(
|
||||
mediaSession, mediaSession.customLayout, actionFactory) { notification ->
|
||||
post(wrapMediaNotification(notification))
|
||||
}
|
||||
post(wrapMediaNotification(notification))
|
||||
}
|
||||
|
||||
fun release() {
|
||||
waitJob.cancel()
|
||||
mediaItemBrowser.release()
|
||||
actionHandler.release()
|
||||
exoHolder.release()
|
||||
playbackManager.removeListener(this)
|
||||
mediaSession.release()
|
||||
foregroundListener = null
|
||||
}
|
||||
|
||||
private fun wrapMediaNotification(notification: MediaNotification): MediaNotification {
|
||||
// Pulled from MediaNotificationManager: Need to specify MediaSession token manually
|
||||
// in notification
|
||||
val fwkToken =
|
||||
mediaSession.sessionCompatToken.token as android.media.session.MediaSession.Token
|
||||
notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken)
|
||||
return notification
|
||||
}
|
||||
|
||||
private fun createSession(service: MediaLibraryService) =
|
||||
MediaLibrarySession.Builder(service, exoHolder.mediaSessionPlayer, this).build()
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): ConnectionResult {
|
||||
val sessionCommands =
|
||||
actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS)
|
||||
return ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(sessionCommands)
|
||||
.setCustomLayout(actionHandler.createCustomLayout())
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> =
|
||||
if (actionHandler.handleCommand(customCommand)) {
|
||||
Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
} else {
|
||||
super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<MediaItem>> =
|
||||
Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params))
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
mediaId: String
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val result =
|
||||
mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) }
|
||||
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
return Futures.immediateFuture(result)
|
||||
}
|
||||
|
||||
override fun onSetMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long
|
||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> =
|
||||
Futures.immediateFuture(
|
||||
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val children =
|
||||
mediaItemBrowser.getChildren(parentId, page, pageSize)?.let {
|
||||
LibraryResult.ofItemList(it, params)
|
||||
}
|
||||
?: LibraryResult.ofError<ImmutableList<MediaItem>>(
|
||||
LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
return Futures.immediateFuture(children)
|
||||
}
|
||||
|
||||
override fun onSearch(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
query: String,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
): ListenableFuture<LibraryResult<Void>> =
|
||||
waitScope
|
||||
.async {
|
||||
mediaItemBrowser.prepareSearch(query, browser)
|
||||
// Invalidator will send the notify result
|
||||
LibraryResult.ofVoid()
|
||||
}
|
||||
.asListenableFuture()
|
||||
|
||||
override fun onGetSearchResult(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
query: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: MediaLibraryService.LibraryParams?
|
||||
) =
|
||||
waitScope
|
||||
.async {
|
||||
mediaItemBrowser.getSearchResult(query, page, pageSize)?.let {
|
||||
LibraryResult.ofItemList(it, params)
|
||||
}
|
||||
?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)
|
||||
}
|
||||
.asListenableFuture()
|
||||
|
||||
override fun onSessionEnded() {
|
||||
foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
|
||||
}
|
||||
|
||||
override fun onCustomLayoutChanged(layout: List<CommandButton>) {
|
||||
mediaSession.setCustomLayout(layout)
|
||||
}
|
||||
|
||||
override fun invalidate(ids: Map<String, Int>) {
|
||||
for (id in ids) {
|
||||
mediaSession.notifyChildrenChanged(id.key, id.value, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun invalidate(
|
||||
controller: MediaSession.ControllerInfo,
|
||||
query: String,
|
||||
itemCount: Int
|
||||
) {
|
||||
mediaSession.notifySearchResultChanged(controller, query, itemCount, null)
|
||||
}
|
||||
}
|
|
@ -1,282 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* PlaybackActionHandler.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionCommands
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.ActionMode
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.Progression
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.widgets.WidgetComponent
|
||||
import org.oxycblt.auxio.widgets.WidgetProvider
|
||||
|
||||
class PlaybackActionHandler
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val widgetComponent: WidgetComponent
|
||||
) : PlaybackStateManager.Listener, PlaybackSettings.Listener {
|
||||
|
||||
interface Callback {
|
||||
fun onCustomLayoutChanged(layout: List<CommandButton>)
|
||||
}
|
||||
|
||||
private val systemReceiver =
|
||||
SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent)
|
||||
private var callback: Callback? = null
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
fun attach(callback: Callback) {
|
||||
this.callback = callback
|
||||
playbackManager.addListener(this)
|
||||
playbackSettings.registerListener(this)
|
||||
ContextCompat.registerReceiver(
|
||||
context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
callback = null
|
||||
playbackManager.removeListener(this)
|
||||
playbackSettings.unregisterListener(this)
|
||||
context.unregisterReceiver(systemReceiver)
|
||||
widgetComponent.release()
|
||||
}
|
||||
|
||||
fun withCommands(commands: SessionCommands) =
|
||||
commands
|
||||
.buildUpon()
|
||||
.add(SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle.EMPTY))
|
||||
.add(SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle.EMPTY))
|
||||
.add(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle.EMPTY))
|
||||
.build()
|
||||
|
||||
fun handleCommand(command: SessionCommand): Boolean {
|
||||
when (command.customAction) {
|
||||
PlaybackActions.ACTION_INC_REPEAT_MODE ->
|
||||
playbackManager.repeatMode(playbackManager.repeatMode.increment())
|
||||
PlaybackActions.ACTION_INVERT_SHUFFLE ->
|
||||
playbackManager.shuffled(!playbackManager.isShuffled)
|
||||
PlaybackActions.ACTION_EXIT -> playbackManager.endSession()
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun createCustomLayout(): List<CommandButton> {
|
||||
val actions = mutableListOf<CommandButton>()
|
||||
|
||||
when (playbackSettings.notificationAction) {
|
||||
ActionMode.REPEAT -> {
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(playbackManager.repeatMode.icon)
|
||||
.setDisplayName(context.getString(R.string.desc_change_repeat))
|
||||
.setSessionCommand(
|
||||
SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle()))
|
||||
.setEnabled(true)
|
||||
.build())
|
||||
}
|
||||
ActionMode.SHUFFLE -> {
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(
|
||||
if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24
|
||||
else R.drawable.ic_shuffle_off_24)
|
||||
.setDisplayName(context.getString(R.string.lbl_shuffle))
|
||||
.setSessionCommand(
|
||||
SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle()))
|
||||
.setEnabled(true)
|
||||
.build())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(R.drawable.ic_skip_prev_24)
|
||||
.setDisplayName(context.getString(R.string.desc_skip_prev))
|
||||
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.setEnabled(true)
|
||||
.build())
|
||||
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(R.drawable.ic_close_24)
|
||||
.setDisplayName(context.getString(R.string.desc_exit))
|
||||
.setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle()))
|
||||
.setEnabled(true)
|
||||
.build())
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
override fun onPauseOnRepeatChanged() {
|
||||
super.onPauseOnRepeatChanged()
|
||||
callback?.onCustomLayoutChanged(createCustomLayout())
|
||||
}
|
||||
|
||||
override fun onProgressionChanged(progression: Progression) {
|
||||
super.onProgressionChanged(progression)
|
||||
callback?.onCustomLayoutChanged(createCustomLayout())
|
||||
}
|
||||
|
||||
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
|
||||
super.onRepeatModeChanged(repeatMode)
|
||||
callback?.onCustomLayoutChanged(createCustomLayout())
|
||||
}
|
||||
|
||||
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
|
||||
super.onQueueReordered(queue, index, isShuffled)
|
||||
callback?.onCustomLayoutChanged(createCustomLayout())
|
||||
}
|
||||
|
||||
override fun onNotificationActionChanged() {
|
||||
super.onNotificationActionChanged()
|
||||
callback?.onCustomLayoutChanged(createCustomLayout())
|
||||
}
|
||||
}
|
||||
|
||||
object PlaybackActions {
|
||||
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
|
||||
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
|
||||
const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
|
||||
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
|
||||
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
|
||||
const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
|
||||
}
|
||||
|
||||
/**
|
||||
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
|
||||
* active [IntentFilter] to be registered.
|
||||
*/
|
||||
class SystemPlaybackReceiver(
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val widgetComponent: WidgetComponent
|
||||
) : BroadcastReceiver() {
|
||||
private var initialHeadsetPlugEventHandled = false
|
||||
|
||||
val intentFilter =
|
||||
IntentFilter().apply {
|
||||
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||
addAction(AudioManager.ACTION_HEADSET_PLUG)
|
||||
addAction(PlaybackActions.ACTION_INC_REPEAT_MODE)
|
||||
addAction(PlaybackActions.ACTION_INVERT_SHUFFLE)
|
||||
addAction(PlaybackActions.ACTION_SKIP_PREV)
|
||||
addAction(PlaybackActions.ACTION_PLAY_PAUSE)
|
||||
addAction(PlaybackActions.ACTION_SKIP_NEXT)
|
||||
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
// --- SYSTEM EVENTS ---
|
||||
|
||||
// Android has three different ways of handling audio plug events for some reason:
|
||||
// 1. ACTION_HEADSET_PLUG, which only works with wired headsets
|
||||
// 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
|
||||
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
|
||||
// a non-starter since both require me to display a permission prompt
|
||||
// 3. Some internal framework thing that also handles bluetooth headsets
|
||||
// Just use ACTION_HEADSET_PLUG.
|
||||
AudioManager.ACTION_HEADSET_PLUG -> {
|
||||
logD("Received headset plug event")
|
||||
when (intent.getIntExtra("state", -1)) {
|
||||
0 -> pauseFromHeadsetPlug()
|
||||
1 -> playFromHeadsetPlug()
|
||||
}
|
||||
|
||||
initialHeadsetPlugEventHandled = true
|
||||
}
|
||||
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> {
|
||||
logD("Received Headset noise event")
|
||||
pauseFromHeadsetPlug()
|
||||
}
|
||||
|
||||
// --- AUXIO EVENTS ---
|
||||
PlaybackActions.ACTION_PLAY_PAUSE -> {
|
||||
logD("Received play event")
|
||||
playbackManager.playing(!playbackManager.progression.isPlaying)
|
||||
}
|
||||
PlaybackActions.ACTION_INC_REPEAT_MODE -> {
|
||||
logD("Received repeat mode event")
|
||||
playbackManager.repeatMode(playbackManager.repeatMode.increment())
|
||||
}
|
||||
PlaybackActions.ACTION_INVERT_SHUFFLE -> {
|
||||
logD("Received shuffle event")
|
||||
playbackManager.shuffled(!playbackManager.isShuffled)
|
||||
}
|
||||
PlaybackActions.ACTION_SKIP_PREV -> {
|
||||
logD("Received skip previous event")
|
||||
playbackManager.prev()
|
||||
}
|
||||
PlaybackActions.ACTION_SKIP_NEXT -> {
|
||||
logD("Received skip next event")
|
||||
playbackManager.next()
|
||||
}
|
||||
PlaybackActions.ACTION_EXIT -> {
|
||||
logD("Received exit event")
|
||||
playbackManager.endSession()
|
||||
}
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> {
|
||||
logD("Received widget update event")
|
||||
widgetComponent.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playFromHeadsetPlug() {
|
||||
// ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached,
|
||||
// which would result in unexpected playback. Work around it by dropping the first
|
||||
// call to this function, which should come from that Intent.
|
||||
if (playbackSettings.headsetAutoplay &&
|
||||
playbackManager.currentSong != null &&
|
||||
initialHeadsetPlugEventHandled) {
|
||||
logD("Device connected, resuming")
|
||||
playbackManager.playing(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pauseFromHeadsetPlug() {
|
||||
if (playbackManager.currentSong != null) {
|
||||
logD("Device disconnected, pausing")
|
||||
playbackManager.playing(false)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -281,7 +281,8 @@ data class QueueChange(val type: Type, val instructions: UpdateInstructions) {
|
|||
/** Possible long-running background tasks handled by the background playback task. */
|
||||
sealed interface DeferredPlayback {
|
||||
/** Restore the previously saved playback state. */
|
||||
data object RestoreState : DeferredPlayback
|
||||
data class RestoreState(val play: Boolean, val fallback: DeferredPlayback? = null) :
|
||||
DeferredPlayback
|
||||
|
||||
/**
|
||||
* Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut.
|
||||
|
|
|
@ -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) :
|
||||
|
|
70
app/src/main/java/org/oxycblt/auxio/tasker/Start.kt
Normal file
70
app/src/main/java/org/oxycblt/auxio/tasker/Start.kt
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* Start.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.tasker
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutputOrInput
|
||||
import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig
|
||||
import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutputOrInput
|
||||
import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigNoInput
|
||||
import com.joaomgcd.taskerpluginlibrary.input.TaskerInput
|
||||
import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult
|
||||
import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess
|
||||
import org.oxycblt.auxio.AuxioService
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
|
||||
class StartActionHelper(config: TaskerPluginConfig<Unit>) :
|
||||
TaskerPluginConfigHelperNoOutputOrInput<StartActionRunner>(config) {
|
||||
override val runnerClass: Class<StartActionRunner>
|
||||
get() = StartActionRunner::class.java
|
||||
|
||||
override fun addToStringBlurb(input: TaskerInput<Unit>, blurbBuilder: StringBuilder) {
|
||||
blurbBuilder.append(context.getString(R.string.lng_tasker_start))
|
||||
}
|
||||
}
|
||||
|
||||
class ActivityConfigStartAction : Activity(), TaskerPluginConfigNoInput {
|
||||
override val context
|
||||
get() = applicationContext
|
||||
|
||||
private val taskerHelper by lazy { StartActionHelper(this) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
taskerHelper.finishForTasker()
|
||||
}
|
||||
}
|
||||
|
||||
class StartActionRunner : TaskerPluginRunnerActionNoOutputOrInput() {
|
||||
override fun run(context: Context, input: TaskerInput<Unit>): TaskerPluginResult<Unit> {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, AuxioService::class.java)
|
||||
.putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_TASKER))
|
||||
while (!AuxioService.isForeground) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
return TaskerPluginResultSucess()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* WidgetBitmapTransformation.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.widgets
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import coil.size.Size
|
||||
import coil.transform.Transformation
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class WidgetBitmapTransformation(private val reduce: Float) : Transformation {
|
||||
private val metrics = Resources.getSystem().displayMetrics
|
||||
private val sw = metrics.widthPixels
|
||||
private val sh = metrics.heightPixels
|
||||
// Cap memory usage at 1.5 times the size of the display
|
||||
// 1.5 * 4 bytes/pixel * w * h ==> 6 * w * h
|
||||
// https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/appwidget/java/com/android/server/appwidget/AppWidgetServiceImpl.java
|
||||
// Of course since OEMs randomly patch this check, we give a lot of slack.
|
||||
private val maxBitmapArea = (1.5 * sw * sh / reduce).toInt()
|
||||
|
||||
override val cacheKey: String
|
||||
get() = "WidgetBitmapTransformation:${maxBitmapArea}"
|
||||
|
||||
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
|
||||
if (size !== Size.ORIGINAL) {
|
||||
// The widget loading stack basically discards the size parameter since there's no
|
||||
// sane value from the get-go, all this transform does is actually dynamically apply
|
||||
// the size cap so this transform must always be zero.
|
||||
throw IllegalArgumentException("WidgetBitmapTransformation requires original size.")
|
||||
}
|
||||
val inputArea = input.width * input.height
|
||||
if (inputArea != maxBitmapArea) {
|
||||
val scale = sqrt(maxBitmapArea / inputArea.toDouble())
|
||||
val newWidth = (input.width * scale).toInt()
|
||||
val newHeight = (input.height * scale).toInt()
|
||||
return Bitmap.createScaledBitmap(input, newWidth, newHeight, true)
|
||||
}
|
||||
return input
|
||||
}
|
||||
}
|
|
@ -22,7 +22,7 @@ import android.content.Context
|
|||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import coil.request.ImageRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import coil.size.Size
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.image.BitmapProvider
|
||||
|
@ -46,17 +46,28 @@ import org.oxycblt.auxio.util.logD
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class WidgetComponent
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private constructor(
|
||||
private val context: Context,
|
||||
private val imageSettings: ImageSettings,
|
||||
private val bitmapProvider: BitmapProvider,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val uiSettings: UISettings
|
||||
) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener {
|
||||
class Factory
|
||||
@Inject
|
||||
constructor(
|
||||
private val imageSettings: ImageSettings,
|
||||
private val bitmapProvider: BitmapProvider,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val uiSettings: UISettings
|
||||
) {
|
||||
fun create(context: Context) =
|
||||
WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings)
|
||||
}
|
||||
|
||||
private val widgetProvider = WidgetProvider()
|
||||
|
||||
init {
|
||||
fun attach() {
|
||||
playbackManager.addListener(this)
|
||||
uiSettings.registerListener(this)
|
||||
imageSettings.registerListener(this)
|
||||
|
@ -96,24 +107,19 @@ constructor(
|
|||
0
|
||||
}
|
||||
|
||||
return if (cornerRadius > 0) {
|
||||
// If rounded, reduce the bitmap size further to obtain more pronounced
|
||||
// rounded corners.
|
||||
builder.size(getSafeRemoteViewsImageSize(context, 10f))
|
||||
val cornersTransformation =
|
||||
RoundedRectTransformation(cornerRadius.toFloat())
|
||||
val transformations = buildList {
|
||||
if (imageSettings.forceSquareCovers) {
|
||||
builder.transformations(
|
||||
SquareCropTransformation.INSTANCE, cornersTransformation)
|
||||
add(SquareCropTransformation.INSTANCE)
|
||||
}
|
||||
if (cornerRadius > 0) {
|
||||
add(WidgetBitmapTransformation(15f))
|
||||
add(RoundedRectTransformation(cornerRadius.toFloat()))
|
||||
} else {
|
||||
builder.transformations(cornersTransformation)
|
||||
add(WidgetBitmapTransformation(3f))
|
||||
}
|
||||
} else {
|
||||
if (imageSettings.forceSquareCovers) {
|
||||
builder.transformations(SquareCropTransformation.INSTANCE)
|
||||
}
|
||||
builder.size(getSafeRemoteViewsImageSize(context))
|
||||
}
|
||||
|
||||
return builder.size(Size.ORIGINAL).transformations(transformations)
|
||||
}
|
||||
|
||||
override fun onCompleted(bitmap: Bitmap?) {
|
||||
|
|
|
@ -27,7 +27,6 @@ import android.widget.RemoteViews
|
|||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.annotation.LayoutRes
|
||||
import kotlin.math.sqrt
|
||||
import org.oxycblt.auxio.util.isLandscape
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
@ -46,24 +45,6 @@ fun newRemoteViews(context: Context, @LayoutRes layoutRes: Int): RemoteViews {
|
|||
return views
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an image size guaranteed to not exceed the [RemoteViews] bitmap memory limit, assuming that
|
||||
* there is only one image.
|
||||
*
|
||||
* @param context [Context] required to perform calculation.
|
||||
* @param reduce Optional multiplier to reduce the image size. Recommended value is 3 to avoid
|
||||
* device-specific variations in memory limit.
|
||||
* @return The dimension of a bitmap that can be safely used in [RemoteViews].
|
||||
*/
|
||||
fun getSafeRemoteViewsImageSize(context: Context, reduce: Float = 3f): Int {
|
||||
val metrics = context.resources.displayMetrics
|
||||
val sw = metrics.widthPixels
|
||||
val sh = metrics.heightPixels
|
||||
// Maximum size is 1/3 total screen area * 4 bytes per pixel. Reverse
|
||||
// that to obtain the image size.
|
||||
return sqrt((6f / 4f / reduce) * sw * sh).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the background resource of a [RemoteViews] View.
|
||||
*
|
||||
|
|
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 |
|
@ -153,6 +153,7 @@
|
|||
<string name="lbl_shuffle_shortcut_short">Shuffle</string>
|
||||
<!-- Limit to 25 characters -->
|
||||
<string name="lbl_shuffle_shortcut_long">Shuffle all</string>
|
||||
<string name="lbl_start_playback">Start playback</string>
|
||||
|
||||
<string name="lbl_ok">OK</string>
|
||||
<string name="lbl_cancel">Cancel</string>
|
||||
|
@ -162,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>
|
||||
|
@ -205,6 +207,10 @@
|
|||
<string name="lng_supporters_promo">Donate to the project to get your name added here!</string>
|
||||
<!-- As in music library -->
|
||||
<string name="lng_search_library">Search your library…</string>
|
||||
<string name="lng_tasker_start">
|
||||
Starts Auxio using the previously saved state. If no saved state is available, all songs will be shuffled. Playback will start immediately.
|
||||
\n\nWARNING: Be careful controlling this service, if you close it and then try to use it again, you will probably crash the app.
|
||||
</string>
|
||||
|
||||
<!-- Settings namespace | Settings-related labels -->
|
||||
<eat-comment />
|
||||
|
|
3
fastlane/metadata/android/en-US/changelogs/48.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/48.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
Auxio 3.5.0 adds support for android auto alongside various playback and music quality of life improvements.
|
||||
This release fixes a critical bug with the music loader.
|
||||
For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.2
|
3
fastlane/metadata/android/en-US/changelogs/49.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/49.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
Auxio 3.5.0 adds support for android auto alongside various playback and music quality of life improvements.
|
||||
This release adds basic Tasker integration while fixing a few issues that affected certain devices.
|
||||
For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.3
|
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
|
Loading…
Reference in a new issue