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
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install ninja-build
|
||||||
|
run: sudo apt-get install -y ninja-build
|
||||||
- name: Clone repository
|
- name: Clone repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Clone submodules
|
- name: Clone submodules
|
||||||
|
|
36
CHANGELOG.md
36
CHANGELOG.md
|
@ -16,6 +16,42 @@
|
||||||
- Excessive CPU no longer spent showing music loading process
|
- Excessive CPU no longer spent showing music loading process
|
||||||
- Fixed playback sheet flickering on warm start
|
- Fixed playback sheet flickering on warm start
|
||||||
|
|
||||||
|
## 3.6.0
|
||||||
|
|
||||||
|
#### What's New
|
||||||
|
- Added support for playback from google assistant
|
||||||
|
|
||||||
|
#### What's Improved
|
||||||
|
- Home and detail UIs in Android Auto now reflect app sort settings
|
||||||
|
- Album view now shows discs in android auto
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed playback briefly pausing when adding songs to playlist
|
||||||
|
- Fixed media lists in Android Auto being truncated in some cases
|
||||||
|
- Possibly fixed duplicated song items depending on album/all children
|
||||||
|
- Possibly fixed truncated tab lists in android auto
|
||||||
|
|
||||||
|
#### Dev/Meta
|
||||||
|
- Moved to raw media session apis rather than media3 session
|
||||||
|
|
||||||
|
## 3.5.3
|
||||||
|
|
||||||
|
#### What's New
|
||||||
|
- Basic Tasker integration for safely starting Auxio's service
|
||||||
|
|
||||||
|
#### What's Improved
|
||||||
|
- Added support for informal singular-spaced tags like `album artist` in
|
||||||
|
file metadata
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fix "Foreground not allowed" music loading crash from starting too early
|
||||||
|
- Fixed widget not loading on some devices due to the cover being too large
|
||||||
|
|
||||||
|
## 3.5.2
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed music loading failure from improper sort systems (For real this time)
|
||||||
|
|
||||||
## 3.5.1
|
## 3.5.1
|
||||||
|
|
||||||
#### What's Fixed
|
#### What's Fixed
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
<h1 align="center"><b>Auxio</b></h1>
|
<h1 align="center"><b>Auxio</b></h1>
|
||||||
<h4 align="center">A simple, rational music player for android.</h4>
|
<h4 align="center">A simple, rational music player for android.</h4>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.5.1">
|
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.6.0">
|
||||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.5.1&color=64B5F6&style=flat">
|
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.6.0&color=64B5F6&style=flat">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
|
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
|
||||||
|
|
|
@ -16,13 +16,13 @@ android {
|
||||||
// it here so that binary stripping will work.
|
// it here so that binary stripping will work.
|
||||||
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
|
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
|
||||||
// NDK use is unified
|
// NDK use is unified
|
||||||
ndkVersion = "25.2.9519653"
|
ndkVersion "26.3.11579264"
|
||||||
namespace "org.oxycblt.auxio"
|
namespace "org.oxycblt.auxio"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId namespace
|
applicationId namespace
|
||||||
versionName "3.5.1"
|
versionName "3.6.0"
|
||||||
versionCode 47
|
versionCode 50
|
||||||
|
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
|
@ -118,6 +118,9 @@ dependencies {
|
||||||
// Media
|
// Media
|
||||||
implementation "androidx.media:media:1.7.0"
|
implementation "androidx.media:media:1.7.0"
|
||||||
|
|
||||||
|
// Android Auto
|
||||||
|
implementation "androidx.car.app:app:1.4.0"
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||||
|
|
||||||
|
@ -130,7 +133,6 @@ dependencies {
|
||||||
// --- THIRD PARTY ---
|
// --- THIRD PARTY ---
|
||||||
|
|
||||||
// Exoplayer (Vendored)
|
// Exoplayer (Vendored)
|
||||||
implementation project(":media-lib-session")
|
|
||||||
implementation project(":media-lib-exoplayer")
|
implementation project(":media-lib-exoplayer")
|
||||||
implementation project(":media-lib-decoder-ffmpeg")
|
implementation project(":media-lib-decoder-ffmpeg")
|
||||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
|
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
|
||||||
|
@ -155,6 +157,12 @@ dependencies {
|
||||||
// Speed dial
|
// Speed dial
|
||||||
implementation "com.leinardi.android:speed-dial:3.3.0"
|
implementation "com.leinardi.android:speed-dial:3.3.0"
|
||||||
|
|
||||||
|
// Tasker integration
|
||||||
|
implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10'
|
||||||
|
|
||||||
|
// Fuzzy search
|
||||||
|
implementation 'org.apache.commons:commons-text:1.9'
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||||
testImplementation "junit:junit:4.13.2"
|
testImplementation "junit:junit:4.13.2"
|
||||||
|
|
|
@ -94,7 +94,6 @@
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher">
|
android:roundIcon="@mipmap/ic_launcher">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
|
||||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
@ -135,5 +134,15 @@
|
||||||
android:resource="@xml/widget_info" />
|
android:resource="@xml/widget_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Tasker 'start service' integration -->
|
||||||
|
<activity
|
||||||
|
android:name=".tasker.ActivityConfigStartAction"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/lbl_start_playback">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
|
@ -19,92 +19,147 @@
|
||||||
package org.oxycblt.auxio
|
package org.oxycblt.auxio
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
|
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import androidx.media3.session.MediaLibraryService
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media.utils.MediaConstants
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.music.service.IndexerServiceFragment
|
import org.oxycblt.auxio.music.service.MusicServiceFragment
|
||||||
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment
|
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AuxioService : MediaLibraryService(), ForegroundListener {
|
class AuxioService :
|
||||||
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
|
MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator {
|
||||||
|
@Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory
|
||||||
|
private lateinit var playbackFragment: PlaybackServiceFragment
|
||||||
|
|
||||||
@Inject lateinit var indexingFragment: IndexerServiceFragment
|
@Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory
|
||||||
|
private lateinit var musicFragment: MusicServiceFragment
|
||||||
|
|
||||||
@SuppressLint("WrongConstant")
|
@SuppressLint("WrongConstant")
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
mediaSessionFragment.attach(this, this)
|
playbackFragment = playbackFragmentFactory.create(this, this)
|
||||||
indexingFragment.attach(this)
|
sessionToken = playbackFragment.attach()
|
||||||
}
|
musicFragment = musicFragmentFactory.create(this, this, this)
|
||||||
|
musicFragment.attach()
|
||||||
override fun onBind(intent: Intent?): IBinder? {
|
|
||||||
start(intent)
|
|
||||||
return super.onBind(intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
// TODO: Start command occurring from a foreign service basically implies a detached
|
// TODO: Start command occurring from a foreign service basically implies a detached
|
||||||
// service, we might need more handling here.
|
// service, we might need more handling here.
|
||||||
start(intent)
|
onHandleForeground(intent)
|
||||||
return super.onStartCommand(intent, flags, startId)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun start(intent: Intent?) {
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
val nativeStart = intent?.getBooleanExtra(INTENT_KEY_NATIVE_START, false) ?: false
|
onHandleForeground(intent)
|
||||||
if (!nativeStart) {
|
return super.onBind(intent)
|
||||||
// Some foreign code started us, no guarantees about foreground stability. Figure
|
}
|
||||||
// out what to do.
|
|
||||||
mediaSessionFragment.handleNonNativeStart()
|
private fun onHandleForeground(intent: Intent?) {
|
||||||
}
|
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
|
||||||
indexingFragment.start()
|
musicFragment.start()
|
||||||
|
playbackFragment.start(startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
super.onTaskRemoved(rootIntent)
|
super.onTaskRemoved(rootIntent)
|
||||||
mediaSessionFragment.handleTaskRemoved()
|
playbackFragment.handleTaskRemoved()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
indexingFragment.release()
|
musicFragment.release()
|
||||||
mediaSessionFragment.release()
|
playbackFragment.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
|
override fun onGetRoot(
|
||||||
mediaSessionFragment.mediaSession
|
clientPackageName: String,
|
||||||
|
clientUid: Int,
|
||||||
|
rootHints: Bundle?
|
||||||
|
): BrowserRoot {
|
||||||
|
return musicFragment.getRoot()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
|
||||||
updateForeground(ForegroundListener.Change.MEDIA_SESSION)
|
musicFragment.getItem(itemId, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaItem>>) {
|
||||||
|
val maximumRootChildLimit = getRootChildrenLimit()
|
||||||
|
musicFragment.getChildren(parentId, maximumRootChildLimit, result, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadChildren(
|
||||||
|
parentId: String,
|
||||||
|
result: Result<MutableList<MediaItem>>,
|
||||||
|
options: Bundle
|
||||||
|
) {
|
||||||
|
val maximumRootChildLimit = getRootChildrenLimit()
|
||||||
|
musicFragment.getChildren(parentId, maximumRootChildLimit, result, options.getPage())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaItem>>) {
|
||||||
|
musicFragment.search(query, result, extras?.getPage())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRootChildrenLimit(): Int {
|
||||||
|
return browserRootHints?.getInt(
|
||||||
|
MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4)
|
||||||
|
?: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bundle.getPage(): MusicServiceFragment.Page? {
|
||||||
|
val page = getInt(MediaBrowserCompat.EXTRA_PAGE, -1).takeIf { it >= 0 } ?: return null
|
||||||
|
val pageSize =
|
||||||
|
getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null
|
||||||
|
return MusicServiceFragment.Page(page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateForeground(change: ForegroundListener.Change) {
|
override fun updateForeground(change: ForegroundListener.Change) {
|
||||||
if (mediaSessionFragment.hasNotification()) {
|
val mediaNotification = playbackFragment.notification
|
||||||
|
if (mediaNotification != null) {
|
||||||
if (change == ForegroundListener.Change.MEDIA_SESSION) {
|
if (change == ForegroundListener.Change.MEDIA_SESSION) {
|
||||||
mediaSessionFragment.createNotification {
|
startForeground(mediaNotification.code, mediaNotification.build())
|
||||||
startForeground(it.notificationId, it.notification)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Nothing changed, but don't show anything music related since we can always
|
// Nothing changed, but don't show anything music related since we can always
|
||||||
// index during playback.
|
// index during playback.
|
||||||
} else {
|
} else {
|
||||||
indexingFragment.createNotification {
|
musicFragment.createNotification {
|
||||||
if (it != null) {
|
if (it != null) {
|
||||||
startForeground(it.code, it.build())
|
startForeground(it.code, it.build())
|
||||||
|
isForeground = true
|
||||||
} else {
|
} else {
|
||||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
isForeground = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun invalidateMusic(mediaId: String) {
|
||||||
|
logD(mediaId)
|
||||||
|
notifyChildrenChanged(mediaId)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
var isForeground = false
|
||||||
|
private set
|
||||||
|
|
||||||
// This is only meant for Auxio to internally ensure that it's state management will work.
|
// This is only meant for Auxio to internally ensure that it's state management will work.
|
||||||
const val INTENT_KEY_NATIVE_START = BuildConfig.APPLICATION_ID + ".service.NATIVE_START"
|
const val INTENT_KEY_START_ID = BuildConfig.APPLICATION_ID + ".service.START_ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,3 +171,42 @@ interface ForegroundListener {
|
||||||
INDEXER
|
INDEXER
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
|
||||||
|
* signal a Service's ongoing foreground state.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) :
|
||||||
|
NotificationCompat.Builder(context, info.id) {
|
||||||
|
private val notificationManager = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Set up the notification channel. Foreground notifications are non-substantial, and
|
||||||
|
// thus make no sense to have lights, vibration, or lead to a notification badge.
|
||||||
|
val channel =
|
||||||
|
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||||
|
.setName(context.getString(info.nameRes))
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setShowBadge(false)
|
||||||
|
.build()
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The code used to identify this notification.
|
||||||
|
*
|
||||||
|
* @see NotificationManagerCompat.notify
|
||||||
|
*/
|
||||||
|
abstract val code: Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduced representation of a [NotificationChannelCompat].
|
||||||
|
*
|
||||||
|
* @param id The ID of the channel.
|
||||||
|
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
|
||||||
|
*/
|
||||||
|
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
|
||||||
|
}
|
||||||
|
|
|
@ -59,6 +59,10 @@ object IntegerTable {
|
||||||
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
|
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
|
||||||
/** MainActivity Intent request code */
|
/** MainActivity Intent request code */
|
||||||
const val REQUEST_CODE = 0xA0C0
|
const val REQUEST_CODE = 0xA0C0
|
||||||
|
/** Activity AuxioService Start ID */
|
||||||
|
const val START_ID_ACTIVITY = 0xA050
|
||||||
|
/** Tasker AuxioService Start ID */
|
||||||
|
const val START_ID_TASKER = 0xA051
|
||||||
/** RepeatMode.NONE */
|
/** RepeatMode.NONE */
|
||||||
const val REPEAT_MODE_NONE = 0xA100
|
const val REPEAT_MODE_NONE = 0xA100
|
||||||
/** RepeatMode.ALL */
|
/** RepeatMode.ALL */
|
||||||
|
|
|
@ -71,11 +71,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
startService(
|
startService(
|
||||||
Intent(this, AuxioService::class.java)
|
Intent(this, AuxioService::class.java)
|
||||||
.putExtra(AuxioService.INTENT_KEY_NATIVE_START, true))
|
.putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY))
|
||||||
|
|
||||||
if (!startIntentAction(intent)) {
|
if (!startIntentAction(intent)) {
|
||||||
// No intent action to do, just restore the previously saved state.
|
// No intent action to do, just restore the previously saved state.
|
||||||
playbackModel.playDeferred(DeferredPlayback.RestoreState)
|
playbackModel.playDeferred(DeferredPlayback.RestoreState(false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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
|
package org.oxycblt.auxio.detail
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
@ -43,10 +42,11 @@ import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
|
import org.oxycblt.auxio.music.MusicType
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.info.ReleaseType
|
|
||||||
import org.oxycblt.auxio.music.metadata.AudioProperties
|
import org.oxycblt.auxio.music.metadata.AudioProperties
|
||||||
import org.oxycblt.auxio.playback.PlaySong
|
import org.oxycblt.auxio.playback.PlaySong
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
|
@ -69,8 +69,9 @@ constructor(
|
||||||
private val listSettings: ListSettings,
|
private val listSettings: ListSettings,
|
||||||
private val musicRepository: MusicRepository,
|
private val musicRepository: MusicRepository,
|
||||||
private val audioPropertiesFactory: AudioProperties.Factory,
|
private val audioPropertiesFactory: AudioProperties.Factory,
|
||||||
private val playbackSettings: PlaybackSettings
|
private val playbackSettings: PlaybackSettings,
|
||||||
) : ViewModel(), MusicRepository.UpdateListener {
|
detailGeneratorFactory: DetailGenerator.Factory
|
||||||
|
) : ViewModel(), DetailGenerator.Invalidator {
|
||||||
private val _toShow = MutableEvent<Show>()
|
private val _toShow = MutableEvent<Show>()
|
||||||
/**
|
/**
|
||||||
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
|
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
|
||||||
|
@ -133,13 +134,8 @@ constructor(
|
||||||
get() = _artistSongInstructions
|
get() = _artistSongInstructions
|
||||||
|
|
||||||
/** The current [Sort] used for [Song]s in [artistSongList]. */
|
/** The current [Sort] used for [Song]s in [artistSongList]. */
|
||||||
var artistSongSort: Sort
|
val artistSongSort: Sort
|
||||||
get() = listSettings.artistSongSort
|
get() = listSettings.artistSongSort
|
||||||
set(value) {
|
|
||||||
listSettings.artistSongSort = value
|
|
||||||
// Refresh the artist list to reflect the new sort.
|
|
||||||
currentArtist.value?.let { refreshArtistList(it, true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */
|
/** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */
|
||||||
val playInArtistWith
|
val playInArtistWith
|
||||||
|
@ -162,13 +158,8 @@ constructor(
|
||||||
get() = _genreSongInstructions
|
get() = _genreSongInstructions
|
||||||
|
|
||||||
/** The current [Sort] used for [Song]s in [genreSongList]. */
|
/** The current [Sort] used for [Song]s in [genreSongList]. */
|
||||||
var genreSongSort: Sort
|
val genreSongSort: Sort
|
||||||
get() = listSettings.genreSongSort
|
get() = listSettings.genreSongSort
|
||||||
set(value) {
|
|
||||||
listSettings.genreSongSort = value
|
|
||||||
// Refresh the genre list to reflect the new sort.
|
|
||||||
currentGenre.value?.let { refreshGenreList(it, true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
|
/** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
|
||||||
val playInGenreWith
|
val playInGenreWith
|
||||||
|
@ -204,54 +195,35 @@ constructor(
|
||||||
playbackSettings.inParentPlaybackMode
|
playbackSettings.inParentPlaybackMode
|
||||||
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
|
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
|
||||||
|
|
||||||
|
private val detailGenerator = detailGeneratorFactory.create(this)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addUpdateListener(this)
|
detailGenerator.attach()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
musicRepository.removeUpdateListener(this)
|
detailGenerator.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun invalidate(type: MusicType, replace: Int?) {
|
||||||
// If we are showing any item right now, we will need to refresh it (and any information
|
when (type) {
|
||||||
// related to it) with the new library in order to prevent stale items from showing up
|
MusicType.ALBUMS -> {
|
||||||
// in the UI.
|
val album = detailGenerator.album(currentAlbum.value?.uid ?: return)
|
||||||
val deviceLibrary = musicRepository.deviceLibrary
|
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace)
|
||||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
|
||||||
val song = currentSong.value
|
|
||||||
if (song != null) {
|
|
||||||
_currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo)
|
|
||||||
logD("Updated song to ${currentSong.value}")
|
|
||||||
}
|
}
|
||||||
|
MusicType.ARTISTS -> {
|
||||||
val album = currentAlbum.value
|
val artist = detailGenerator.artist(currentArtist.value?.uid ?: return)
|
||||||
if (album != null) {
|
refreshDetail(
|
||||||
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
|
artist, _currentArtist, _artistSongList, _artistSongInstructions, replace)
|
||||||
logD("Updated album to ${currentAlbum.value}")
|
|
||||||
}
|
}
|
||||||
|
MusicType.GENRES -> {
|
||||||
val artist = currentArtist.value
|
val genre = detailGenerator.genre(currentGenre.value?.uid ?: return)
|
||||||
if (artist != null) {
|
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace)
|
||||||
_currentArtist.value =
|
|
||||||
deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
|
|
||||||
logD("Updated artist to ${currentArtist.value}")
|
|
||||||
}
|
}
|
||||||
|
MusicType.PLAYLISTS -> {
|
||||||
val genre = currentGenre.value
|
refreshPlaylist(currentPlaylist.value?.uid ?: return)
|
||||||
if (genre != null) {
|
|
||||||
_currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList)
|
|
||||||
logD("Updated genre to ${currentGenre.value}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val userLibrary = musicRepository.userLibrary
|
|
||||||
if (changes.userLibrary && userLibrary != null) {
|
|
||||||
val playlist = currentPlaylist.value
|
|
||||||
if (playlist != null) {
|
|
||||||
_currentPlaylist.value =
|
|
||||||
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
|
|
||||||
logD("Updated playlist to ${currentPlaylist.value}")
|
|
||||||
}
|
}
|
||||||
|
else -> error("Unexpected music type $type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -356,8 +328,11 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun setAlbum(uid: Music.UID) {
|
fun setAlbum(uid: Music.UID) {
|
||||||
logD("Opening album $uid")
|
logD("Opening album $uid")
|
||||||
_currentAlbum.value =
|
if (uid === _currentAlbum.value?.uid) {
|
||||||
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
|
return
|
||||||
|
}
|
||||||
|
val album = detailGenerator.album(uid)
|
||||||
|
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null)
|
||||||
if (_currentAlbum.value == null) {
|
if (_currentAlbum.value == null) {
|
||||||
logW("Given album UID was invalid")
|
logW("Given album UID was invalid")
|
||||||
}
|
}
|
||||||
|
@ -370,7 +345,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applyAlbumSongSort(sort: Sort) {
|
fun applyAlbumSongSort(sort: Sort) {
|
||||||
listSettings.albumSongSort = sort
|
listSettings.albumSongSort = sort
|
||||||
_currentAlbum.value?.let { refreshAlbumList(it, true) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -381,11 +355,11 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun setArtist(uid: Music.UID) {
|
fun setArtist(uid: Music.UID) {
|
||||||
logD("Opening artist $uid")
|
logD("Opening artist $uid")
|
||||||
_currentArtist.value =
|
if (uid === _currentArtist.value?.uid) {
|
||||||
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
|
return
|
||||||
if (_currentArtist.value == null) {
|
|
||||||
logW("Given artist UID was invalid")
|
|
||||||
}
|
}
|
||||||
|
val artist = detailGenerator.artist(uid)
|
||||||
|
refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -395,7 +369,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applyArtistSongSort(sort: Sort) {
|
fun applyArtistSongSort(sort: Sort) {
|
||||||
listSettings.artistSongSort = sort
|
listSettings.artistSongSort = sort
|
||||||
_currentArtist.value?.let { refreshArtistList(it, true) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -406,11 +379,11 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun setGenre(uid: Music.UID) {
|
fun setGenre(uid: Music.UID) {
|
||||||
logD("Opening genre $uid")
|
logD("Opening genre $uid")
|
||||||
_currentGenre.value =
|
if (uid === _currentGenre.value?.uid) {
|
||||||
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
|
return
|
||||||
if (_currentGenre.value == null) {
|
|
||||||
logW("Given genre UID was invalid")
|
|
||||||
}
|
}
|
||||||
|
val genre = detailGenerator.genre(uid)
|
||||||
|
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -420,7 +393,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applyGenreSongSort(sort: Sort) {
|
fun applyGenreSongSort(sort: Sort) {
|
||||||
listSettings.genreSongSort = sort
|
listSettings.genreSongSort = sort
|
||||||
_currentGenre.value?.let { refreshGenreList(it, true) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -431,11 +403,10 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun setPlaylist(uid: Music.UID) {
|
fun setPlaylist(uid: Music.UID) {
|
||||||
logD("Opening playlist $uid")
|
logD("Opening playlist $uid")
|
||||||
_currentPlaylist.value =
|
if (uid === _currentPlaylist.value?.uid) {
|
||||||
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
|
return
|
||||||
if (_currentPlaylist.value == null) {
|
|
||||||
logW("Given playlist UID was invalid")
|
|
||||||
}
|
}
|
||||||
|
refreshPlaylist(uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
|
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
|
||||||
|
@ -443,7 +414,7 @@ constructor(
|
||||||
val playlist = _currentPlaylist.value ?: return
|
val playlist = _currentPlaylist.value ?: return
|
||||||
logD("Starting playlist edit")
|
logD("Starting playlist edit")
|
||||||
_editedPlaylist.value = playlist.songs
|
_editedPlaylist.value = playlist.songs
|
||||||
refreshPlaylistList(playlist)
|
refreshPlaylist(playlist.uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -474,9 +445,8 @@ constructor(
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
logD("Discarding playlist edits")
|
|
||||||
_editedPlaylist.value = null
|
_editedPlaylist.value = null
|
||||||
refreshPlaylistList(playlist)
|
refreshPlaylist(playlist.uid)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -488,7 +458,7 @@ constructor(
|
||||||
fun applyPlaylistSongSort(sort: Sort) {
|
fun applyPlaylistSongSort(sort: Sort) {
|
||||||
val playlist = _currentPlaylist.value ?: return
|
val playlist = _currentPlaylist.value ?: return
|
||||||
_editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return)
|
_editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return)
|
||||||
refreshPlaylistList(playlist, UpdateInstructions.Replace(2))
|
refreshPlaylist(playlist.uid, UpdateInstructions.Replace(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -501,15 +471,15 @@ constructor(
|
||||||
fun movePlaylistSongs(from: Int, to: Int): Boolean {
|
fun movePlaylistSongs(from: Int, to: Int): Boolean {
|
||||||
val playlist = _currentPlaylist.value ?: return false
|
val playlist = _currentPlaylist.value ?: return false
|
||||||
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
|
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
|
||||||
val realFrom = from - 2
|
val realFrom = from - 1
|
||||||
val realTo = to - 2
|
val realTo = to - 1
|
||||||
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
|
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
|
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
|
||||||
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
|
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
|
||||||
_editedPlaylist.value = editedPlaylist
|
_editedPlaylist.value = editedPlaylist
|
||||||
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
|
refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -521,20 +491,20 @@ constructor(
|
||||||
fun removePlaylistSong(at: Int) {
|
fun removePlaylistSong(at: Int) {
|
||||||
val playlist = _currentPlaylist.value ?: return
|
val playlist = _currentPlaylist.value ?: return
|
||||||
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
|
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
|
||||||
val realAt = at - 2
|
val realAt = at - 1
|
||||||
if (realAt !in editedPlaylist.indices) {
|
if (realAt !in editedPlaylist.indices) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logD("Removing playlist song at $realAt [$at]")
|
logD("Removing playlist song at $realAt [$at]")
|
||||||
editedPlaylist.removeAt(realAt)
|
editedPlaylist.removeAt(realAt)
|
||||||
_editedPlaylist.value = editedPlaylist
|
_editedPlaylist.value = editedPlaylist
|
||||||
refreshPlaylistList(
|
refreshPlaylist(
|
||||||
playlist,
|
playlist.uid,
|
||||||
if (editedPlaylist.isNotEmpty()) {
|
if (editedPlaylist.isNotEmpty()) {
|
||||||
UpdateInstructions.Remove(at, 1)
|
UpdateInstructions.Remove(at, 1)
|
||||||
} else {
|
} else {
|
||||||
logD("Playlist will be empty after removal, removing header")
|
logD("Playlist will be empty after removal, removing header")
|
||||||
UpdateInstructions.Remove(at - 2, 3)
|
UpdateInstructions.Remove(at - 1, 3)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -552,173 +522,72 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
|
private fun <T : MusicParent> refreshDetail(
|
||||||
logD("Refreshing album list")
|
detail: Detail<T>?,
|
||||||
val list = mutableListOf<Item>()
|
parent: MutableStateFlow<T?>,
|
||||||
val header = SortHeader(R.string.lbl_songs)
|
list: MutableStateFlow<List<Item>>,
|
||||||
list.add(header)
|
instructions: MutableEvent<UpdateInstructions>,
|
||||||
val instructions =
|
replace: Int?
|
||||||
if (replace) {
|
) {
|
||||||
// Intentional so that the header item isn't replaced with the songs
|
if (detail == null) {
|
||||||
UpdateInstructions.Replace(list.size)
|
parent.value = null
|
||||||
} else {
|
return
|
||||||
UpdateInstructions.Diff
|
|
||||||
}
|
|
||||||
|
|
||||||
// To create a good user experience regarding disc numbers, we group the album's
|
|
||||||
// songs up by disc and then delimit the groups by a disc header.
|
|
||||||
val songs = albumSongSort.songs(album.songs)
|
|
||||||
val byDisc = songs.groupBy { it.disc }
|
|
||||||
if (byDisc.size > 1) {
|
|
||||||
logD("Album has more than one disc, interspersing headers")
|
|
||||||
for (entry in byDisc.entries) {
|
|
||||||
list.add(DiscHeader(entry.key))
|
|
||||||
list.addAll(entry.value)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Album only has one disc, don't add any redundant headers
|
|
||||||
list.addAll(songs)
|
|
||||||
}
|
}
|
||||||
|
val newList = mutableListOf<Item>()
|
||||||
logD("Update album list to ${list.size} items with $instructions")
|
var newInstructions: UpdateInstructions = UpdateInstructions.Diff
|
||||||
_albumSongInstructions.put(instructions)
|
for ((i, section) in detail.sections.withIndex()) {
|
||||||
_albumSongList.value = list
|
val items =
|
||||||
}
|
when (section) {
|
||||||
|
is DetailSection.PlainSection<*> -> {
|
||||||
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
|
val header =
|
||||||
logD("Refreshing artist list")
|
if (section is DetailSection.Songs) SortHeader(section.stringRes)
|
||||||
val list = mutableListOf<Item>()
|
else BasicHeader(section.stringRes)
|
||||||
|
newList.add(Divider(header))
|
||||||
val grouping =
|
newList.add(header)
|
||||||
artist.explicitAlbums.groupByTo(sortedMapOf()) {
|
section.items
|
||||||
// Remap the complicated ReleaseType data structure into an easier
|
}
|
||||||
// "AlbumGrouping" enum that will automatically group and sort
|
is DetailSection.Discs -> {
|
||||||
// the artist's albums.
|
val header = SortHeader(section.stringRes)
|
||||||
when (it.releaseType.refinement) {
|
newList.add(Divider(header))
|
||||||
ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE
|
newList.add(header)
|
||||||
ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES
|
section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value }
|
||||||
null ->
|
}
|
||||||
when (it.releaseType) {
|
|
||||||
is ReleaseType.Album -> AlbumGrouping.ALBUMS
|
|
||||||
is ReleaseType.EP -> AlbumGrouping.EPS
|
|
||||||
is ReleaseType.Single -> AlbumGrouping.SINGLES
|
|
||||||
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
|
|
||||||
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
|
|
||||||
is ReleaseType.Mix -> AlbumGrouping.DJMIXES
|
|
||||||
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
|
|
||||||
is ReleaseType.Demo -> AlbumGrouping.DEMOS
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
// Currently only the final section (songs, which can be sorted) are invalidatable
|
||||||
|
// and thus need to be replaced.
|
||||||
if (artist.implicitAlbums.isNotEmpty()) {
|
if (replace == -1 && i == detail.sections.lastIndex) {
|
||||||
// groupByTo normally returns a mapping to a MutableList mapping. Since MutableList
|
|
||||||
// inherits list, we can cast upwards and save a copy by directly inserting the
|
|
||||||
// implicit album list into the mapping.
|
|
||||||
logD("Implicit albums present, adding to list")
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
(grouping as MutableMap<AlbumGrouping, Collection<Album>>)[AlbumGrouping.APPEARANCES] =
|
|
||||||
artist.implicitAlbums
|
|
||||||
}
|
|
||||||
|
|
||||||
logD("Release groups for this artist: ${grouping.keys}")
|
|
||||||
|
|
||||||
for ((i, entry) in grouping.entries.withIndex()) {
|
|
||||||
val header = BasicHeader(entry.key.headerTitleRes)
|
|
||||||
if (i > 0) {
|
|
||||||
list.add(Divider(header))
|
|
||||||
}
|
|
||||||
list.add(header)
|
|
||||||
list.addAll(ARTIST_ALBUM_SORT.albums(entry.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Artists may not be linked to any songs, only include a header entry if we have any.
|
|
||||||
var instructions: UpdateInstructions = UpdateInstructions.Diff
|
|
||||||
if (artist.songs.isNotEmpty()) {
|
|
||||||
logD("Songs present in this artist, adding header")
|
|
||||||
val header = SortHeader(R.string.lbl_songs)
|
|
||||||
list.add(Divider(header))
|
|
||||||
list.add(header)
|
|
||||||
if (replace) {
|
|
||||||
// Intentional so that the header item isn't replaced with the songs
|
// Intentional so that the header item isn't replaced with the songs
|
||||||
instructions = UpdateInstructions.Replace(list.size)
|
newInstructions = UpdateInstructions.Replace(newList.size)
|
||||||
}
|
}
|
||||||
list.addAll(artistSongSort.songs(artist.songs))
|
newList.addAll(items)
|
||||||
}
|
}
|
||||||
|
parent.value = detail.parent
|
||||||
logD("Updating artist list to ${list.size} items with $instructions")
|
instructions.put(newInstructions)
|
||||||
_artistSongInstructions.put(instructions)
|
list.value = newList
|
||||||
_artistSongList.value = list.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
|
private fun refreshPlaylist(
|
||||||
logD("Refreshing genre list")
|
uid: Music.UID,
|
||||||
val list = mutableListOf<Item>()
|
|
||||||
// Genre is guaranteed to always have artists and songs.
|
|
||||||
val artistHeader = BasicHeader(R.string.lbl_artists)
|
|
||||||
list.add(artistHeader)
|
|
||||||
list.addAll(GENRE_ARTIST_SORT.artists(genre.artists))
|
|
||||||
|
|
||||||
val songHeader = SortHeader(R.string.lbl_songs)
|
|
||||||
list.add(Divider(songHeader))
|
|
||||||
list.add(songHeader)
|
|
||||||
val instructions =
|
|
||||||
if (replace) {
|
|
||||||
// Intentional so that the header item isn't replaced alongside the songs
|
|
||||||
UpdateInstructions.Replace(list.size)
|
|
||||||
} else {
|
|
||||||
UpdateInstructions.Diff
|
|
||||||
}
|
|
||||||
list.addAll(genreSongSort.songs(genre.songs))
|
|
||||||
|
|
||||||
logD("Updating genre list to ${list.size} items with $instructions")
|
|
||||||
_genreSongInstructions.put(instructions)
|
|
||||||
_genreSongList.value = list
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshPlaylistList(
|
|
||||||
playlist: Playlist,
|
|
||||||
instructions: UpdateInstructions = UpdateInstructions.Diff
|
instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||||
) {
|
) {
|
||||||
logD("Refreshing playlist list")
|
logD("Refreshing playlist list")
|
||||||
val list = mutableListOf<Item>()
|
val edited = editedPlaylist.value
|
||||||
|
if (edited == null) {
|
||||||
val songs = editedPlaylist.value ?: playlist.songs
|
val playlist = detailGenerator.playlist(uid)
|
||||||
if (songs.isNotEmpty()) {
|
refreshDetail(
|
||||||
val header = EditHeader(R.string.lbl_songs)
|
playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null)
|
||||||
list.add(header)
|
return
|
||||||
list.addAll(songs)
|
}
|
||||||
|
val list = mutableListOf<Item>()
|
||||||
|
if (edited.isNotEmpty()) {
|
||||||
|
val header = EditHeader(R.string.lbl_songs)
|
||||||
|
list.add(Divider(header))
|
||||||
|
list.add(header)
|
||||||
|
list.addAll(edited)
|
||||||
}
|
}
|
||||||
|
|
||||||
logD("Updating playlist list to ${list.size} items with $instructions")
|
|
||||||
_playlistSongInstructions.put(instructions)
|
_playlistSongInstructions.put(instructions)
|
||||||
_playlistSongList.value = list
|
_playlistSongList.value = list
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A simpler mapping of [ReleaseType] used for grouping and sorting songs.
|
|
||||||
*
|
|
||||||
* @param headerTitleRes The title string resource to use for a header created out of an
|
|
||||||
* instance of this enum.
|
|
||||||
*/
|
|
||||||
private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
|
|
||||||
ALBUMS(R.string.lbl_albums),
|
|
||||||
EPS(R.string.lbl_eps),
|
|
||||||
SINGLES(R.string.lbl_singles),
|
|
||||||
COMPILATIONS(R.string.lbl_compilations),
|
|
||||||
SOUNDTRACKS(R.string.lbl_soundtracks),
|
|
||||||
DJMIXES(R.string.lbl_mixes),
|
|
||||||
MIXTAPES(R.string.lbl_mixtapes),
|
|
||||||
DEMOS(R.string.lbl_demos),
|
|
||||||
APPEARANCES(R.string.lbl_appears_on),
|
|
||||||
LIVE(R.string.lbl_live_group),
|
|
||||||
REMIXES(R.string.lbl_remix_group),
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
|
|
||||||
val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -35,10 +35,10 @@ import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.info.Disc
|
import org.oxycblt.auxio.music.info.Disc
|
||||||
|
import org.oxycblt.auxio.music.info.resolveNumber
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
|
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
|
||||||
|
@ -111,16 +111,10 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
||||||
*/
|
*/
|
||||||
fun bind(discHeader: DiscHeader) {
|
fun bind(discHeader: DiscHeader) {
|
||||||
val disc = discHeader.inner
|
val disc = discHeader.inner
|
||||||
if (disc != null) {
|
binding.discNumber.text = disc.resolveNumber(binding.context)
|
||||||
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
|
binding.discName.apply {
|
||||||
binding.discName.apply {
|
text = disc?.name
|
||||||
text = disc.name
|
isGone = disc?.name == null
|
||||||
isGone = disc.name == null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logD("Disc is null, defaulting to no disc")
|
|
||||||
binding.discNumber.text = binding.context.getString(R.string.def_disc)
|
|
||||||
binding.discName.isGone = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface HomeModule {
|
interface HomeModule {
|
||||||
@Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
|
@Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
|
||||||
|
|
||||||
|
@Binds fun homeGeneratorFactory(factory: HomeGeneratorFactoryImpl): HomeGenerator.Factory
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,9 +42,9 @@ interface HomeSettings : Settings<HomeSettings.Listener> {
|
||||||
|
|
||||||
interface Listener {
|
interface Listener {
|
||||||
/** Called when the [homeTabs] configuration changes. */
|
/** Called when the [homeTabs] configuration changes. */
|
||||||
fun onTabsChanged()
|
fun onTabsChanged() {}
|
||||||
/** Called when the [shouldHideCollaborators] configuration changes. */
|
/** Called when the [shouldHideCollaborators] configuration changes. */
|
||||||
fun onHideCollaboratorsChanged()
|
fun onHideCollaboratorsChanged() {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,6 @@ import org.oxycblt.auxio.list.sort.Sort
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
|
||||||
import org.oxycblt.auxio.music.MusicType
|
import org.oxycblt.auxio.music.MusicType
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
|
@ -49,12 +48,10 @@ import org.oxycblt.auxio.util.logD
|
||||||
class HomeViewModel
|
class HomeViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val homeSettings: HomeSettings,
|
|
||||||
private val listSettings: ListSettings,
|
private val listSettings: ListSettings,
|
||||||
private val playbackSettings: PlaybackSettings,
|
private val playbackSettings: PlaybackSettings,
|
||||||
private val musicRepository: MusicRepository,
|
homeGeneratorFactory: HomeGenerator.Factory
|
||||||
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
|
) : ViewModel(), HomeGenerator.Invalidator {
|
||||||
|
|
||||||
private val _songList = MutableStateFlow(listOf<Song>())
|
private val _songList = MutableStateFlow(listOf<Song>())
|
||||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||||
val songList: StateFlow<List<Song>>
|
val songList: StateFlow<List<Song>>
|
||||||
|
@ -132,11 +129,13 @@ constructor(
|
||||||
val playlistSort: Sort
|
val playlistSort: Sort
|
||||||
get() = listSettings.playlistSort
|
get() = listSettings.playlistSort
|
||||||
|
|
||||||
|
private val homeGenerator = homeGeneratorFactory.create(this)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
|
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
|
||||||
* [Tab]s.
|
* [Tab]s.
|
||||||
*/
|
*/
|
||||||
var currentTabTypes = makeTabTypes()
|
var currentTabTypes = homeGenerator.tabs()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private val _currentTabType = MutableStateFlow(currentTabTypes[0])
|
private val _currentTabType = MutableStateFlow(currentTabTypes[0])
|
||||||
|
@ -161,63 +160,44 @@ constructor(
|
||||||
get() = _showOuter
|
get() = _showOuter
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addUpdateListener(this)
|
homeGenerator.attach()
|
||||||
homeSettings.registerListener(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
musicRepository.removeUpdateListener(this)
|
homeGenerator.release()
|
||||||
homeSettings.unregisterListener(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary
|
when (type) {
|
||||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
MusicType.SONGS -> {
|
||||||
logD("Refreshing library")
|
_songInstructions.put(instructions)
|
||||||
// Get the each list of items in the library to use as our list data.
|
_songList.value = homeGenerator.songs()
|
||||||
// Applying the preferred sorting to them.
|
}
|
||||||
_songInstructions.put(UpdateInstructions.Diff)
|
MusicType.ALBUMS -> {
|
||||||
_songList.value = listSettings.songSort.songs(deviceLibrary.songs)
|
_albumInstructions.put(instructions)
|
||||||
_albumInstructions.put(UpdateInstructions.Diff)
|
_albumList.value = homeGenerator.albums()
|
||||||
_albumList.value = listSettings.albumSort.albums(deviceLibrary.albums)
|
}
|
||||||
_artistInstructions.put(UpdateInstructions.Diff)
|
MusicType.ARTISTS -> {
|
||||||
_artistList.value =
|
_artistInstructions.put(instructions)
|
||||||
listSettings.artistSort.artists(
|
_artistList.value = homeGenerator.artists()
|
||||||
if (homeSettings.shouldHideCollaborators) {
|
}
|
||||||
logD("Filtering collaborator artists")
|
MusicType.GENRES -> {
|
||||||
// Hide Collaborators is enabled, filter out collaborators.
|
_genreInstructions.put(instructions)
|
||||||
deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() }
|
_genreList.value = homeGenerator.genres()
|
||||||
} else {
|
}
|
||||||
logD("Using all artists")
|
MusicType.PLAYLISTS -> {
|
||||||
deviceLibrary.artists
|
_playlistInstructions.put(instructions)
|
||||||
})
|
_playlistList.value = homeGenerator.playlists()
|
||||||
_genreInstructions.put(UpdateInstructions.Diff)
|
}
|
||||||
_genreList.value = listSettings.genreSort.genres(deviceLibrary.genres)
|
|
||||||
}
|
|
||||||
|
|
||||||
val userLibrary = musicRepository.userLibrary
|
|
||||||
if (changes.userLibrary && userLibrary != null) {
|
|
||||||
logD("Refreshing playlists")
|
|
||||||
_playlistInstructions.put(UpdateInstructions.Diff)
|
|
||||||
_playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTabsChanged() {
|
override fun invalidateTabs() {
|
||||||
// Tabs changed, update the current tabs and set up a re-create event.
|
currentTabTypes = homeGenerator.tabs()
|
||||||
currentTabTypes = makeTabTypes()
|
|
||||||
logD("Updating tabs: ${currentTabType.value}")
|
|
||||||
_shouldRecreate.put(Unit)
|
_shouldRecreate.put(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onHideCollaboratorsChanged() {
|
|
||||||
// Changes in the hide collaborator setting will change the artist contents
|
|
||||||
// of the library, consider it a library update.
|
|
||||||
logD("Collaborator setting changed, forwarding update")
|
|
||||||
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a new [Sort] to [songList].
|
* Apply a new [Sort] to [songList].
|
||||||
*
|
*
|
||||||
|
@ -225,8 +205,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applySongSort(sort: Sort) {
|
fun applySongSort(sort: Sort) {
|
||||||
listSettings.songSort = sort
|
listSettings.songSort = sort
|
||||||
_songInstructions.put(UpdateInstructions.Replace(0))
|
|
||||||
_songList.value = listSettings.songSort.songs(_songList.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -236,8 +214,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applyAlbumSort(sort: Sort) {
|
fun applyAlbumSort(sort: Sort) {
|
||||||
listSettings.albumSort = sort
|
listSettings.albumSort = sort
|
||||||
_albumInstructions.put(UpdateInstructions.Replace(0))
|
|
||||||
_albumList.value = listSettings.albumSort.albums(_albumList.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -247,8 +223,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applyArtistSort(sort: Sort) {
|
fun applyArtistSort(sort: Sort) {
|
||||||
listSettings.artistSort = sort
|
listSettings.artistSort = sort
|
||||||
_artistInstructions.put(UpdateInstructions.Replace(0))
|
|
||||||
_artistList.value = listSettings.artistSort.artists(_artistList.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -258,8 +232,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applyGenreSort(sort: Sort) {
|
fun applyGenreSort(sort: Sort) {
|
||||||
listSettings.genreSort = sort
|
listSettings.genreSort = sort
|
||||||
_genreInstructions.put(UpdateInstructions.Replace(0))
|
|
||||||
_genreList.value = listSettings.genreSort.genres(_genreList.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -269,8 +241,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applyPlaylistSort(sort: Sort) {
|
fun applyPlaylistSort(sort: Sort) {
|
||||||
listSettings.playlistSort = sort
|
listSettings.playlistSort = sort
|
||||||
_playlistInstructions.put(UpdateInstructions.Replace(0))
|
|
||||||
_playlistList.value = listSettings.playlistSort.playlists(_playlistList.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -300,15 +270,6 @@ constructor(
|
||||||
fun showAbout() {
|
fun showAbout() {
|
||||||
_showOuter.put(Outer.About)
|
_showOuter.put(Outer.About)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a list of [MusicType]s representing a simpler version of the [Tab] configuration.
|
|
||||||
*
|
|
||||||
* @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in
|
|
||||||
* the same way as the configuration.
|
|
||||||
*/
|
|
||||||
private fun makeTabTypes() =
|
|
||||||
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface Outer {
|
sealed interface Outer {
|
||||||
|
|
|
@ -37,40 +37,24 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
|
||||||
private val width = context.resources.configuration.smallestScreenWidthDp
|
private val width = context.resources.configuration.smallestScreenWidthDp
|
||||||
|
|
||||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||||
val icon: Int
|
val homeTab = tabs[position]
|
||||||
val string: Int
|
val icon =
|
||||||
|
when (homeTab) {
|
||||||
when (tabs[position]) {
|
MusicType.SONGS -> R.drawable.ic_song_24
|
||||||
MusicType.SONGS -> {
|
MusicType.ALBUMS -> R.drawable.ic_album_24
|
||||||
icon = R.drawable.ic_song_24
|
MusicType.ARTISTS -> R.drawable.ic_artist_24
|
||||||
string = R.string.lbl_songs
|
MusicType.GENRES -> R.drawable.ic_genre_24
|
||||||
|
MusicType.PLAYLISTS -> R.drawable.ic_playlist_24
|
||||||
}
|
}
|
||||||
MusicType.ALBUMS -> {
|
|
||||||
icon = R.drawable.ic_album_24
|
|
||||||
string = R.string.lbl_albums
|
|
||||||
}
|
|
||||||
MusicType.ARTISTS -> {
|
|
||||||
icon = R.drawable.ic_artist_24
|
|
||||||
string = R.string.lbl_artists
|
|
||||||
}
|
|
||||||
MusicType.GENRES -> {
|
|
||||||
icon = R.drawable.ic_genre_24
|
|
||||||
string = R.string.lbl_genres
|
|
||||||
}
|
|
||||||
MusicType.PLAYLISTS -> {
|
|
||||||
icon = R.drawable.ic_playlist_24
|
|
||||||
string = R.string.lbl_playlists
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use expected sw* size thresholds when choosing a configuration.
|
// Use expected sw* size thresholds when choosing a configuration.
|
||||||
when {
|
when {
|
||||||
// On small screens, only display an icon.
|
// On small screens, only display an icon.
|
||||||
width < 370 -> tab.setIcon(icon).setContentDescription(string)
|
width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes)
|
||||||
// On large screens, display an icon and text.
|
// On large screens, display an icon and text.
|
||||||
width < 600 -> tab.setText(string)
|
width < 600 -> tab.setText(homeTab.nameRes)
|
||||||
// On medium-size screens, display text.
|
// On medium-size screens, display text.
|
||||||
else -> tab.setIcon(icon).setText(string)
|
else -> tab.setIcon(icon).setText(homeTab.nameRes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,7 +107,10 @@ class RoundedRectTransformation(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateOutputSize(input: Bitmap, size: Size): Pair<Int, Int> {
|
private fun calculateOutputSize(input: Bitmap, size: Size): Pair<Int, Int> {
|
||||||
// MODIFICATION: Remove short-circuiting for original size and input size
|
if (size == Size.ORIGINAL) {
|
||||||
|
// This path only runs w/the widget code, which already normalizes widget sizes
|
||||||
|
return input.width to input.height
|
||||||
|
}
|
||||||
val multiplier =
|
val multiplier =
|
||||||
DecodeUtils.computeSizeMultiplier(
|
DecodeUtils.computeSizeMultiplier(
|
||||||
srcWidth = input.width,
|
srcWidth = input.width,
|
||||||
|
|
|
@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
|
|
||||||
interface ListSettings : Settings<Unit> {
|
interface ListSettings : Settings<ListSettings.Listener> {
|
||||||
/** The [Sort] mode used in Song lists. */
|
/** The [Sort] mode used in Song lists. */
|
||||||
var songSort: Sort
|
var songSort: Sort
|
||||||
/** The [Sort] mode used in Album lists. */
|
/** The [Sort] mode used in Album lists. */
|
||||||
|
@ -43,10 +43,28 @@ interface ListSettings : Settings<Unit> {
|
||||||
var artistSongSort: Sort
|
var artistSongSort: Sort
|
||||||
/** The [Sort] mode used in a Genre's Song list. */
|
/** The [Sort] mode used in a Genre's Song list. */
|
||||||
var genreSongSort: Sort
|
var genreSongSort: Sort
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
fun onSongSortChanged() {}
|
||||||
|
|
||||||
|
fun onAlbumSortChanged() {}
|
||||||
|
|
||||||
|
fun onAlbumSongSortChanged() {}
|
||||||
|
|
||||||
|
fun onArtistSortChanged() {}
|
||||||
|
|
||||||
|
fun onArtistSongSortChanged() {}
|
||||||
|
|
||||||
|
fun onGenreSortChanged() {}
|
||||||
|
|
||||||
|
fun onGenreSongSortChanged() {}
|
||||||
|
|
||||||
|
fun onPlaylistSortChanged() {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) :
|
class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) :
|
||||||
Settings.Impl<Unit>(context), ListSettings {
|
Settings.Impl<ListSettings.Listener>(context), ListSettings {
|
||||||
override var songSort: Sort
|
override var songSort: Sort
|
||||||
get() =
|
get() =
|
||||||
Sort.fromIntCode(
|
Sort.fromIntCode(
|
||||||
|
@ -145,4 +163,17 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSettingChanged(key: String, listener: ListSettings.Listener) {
|
||||||
|
when (key) {
|
||||||
|
getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged()
|
||||||
|
getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged()
|
||||||
|
getString(R.string.set_key_album_songs_sort) -> listener.onAlbumSongSortChanged()
|
||||||
|
getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged()
|
||||||
|
getString(R.string.set_key_artist_songs_sort) -> listener.onArtistSongSortChanged()
|
||||||
|
getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged()
|
||||||
|
getString(R.string.set_key_genre_songs_sort) -> listener.onGenreSongSortChanged()
|
||||||
|
getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
|
import org.oxycblt.auxio.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* General configuration enum to control what kind of music is being worked with.
|
* General configuration enum to control what kind of music is being worked with.
|
||||||
|
@ -52,6 +53,16 @@ enum class MusicType {
|
||||||
PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS
|
PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val nameRes: Int
|
||||||
|
get() =
|
||||||
|
when (this) {
|
||||||
|
SONGS -> R.string.lbl_songs
|
||||||
|
ALBUMS -> R.string.lbl_albums
|
||||||
|
ARTISTS -> R.string.lbl_artists
|
||||||
|
GENRES -> R.string.lbl_genres
|
||||||
|
PLAYLISTS -> R.string.lbl_playlists
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Convert a [MusicType] integer representation into an instance.
|
* Convert a [MusicType] integer representation into an instance.
|
||||||
|
|
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date
|
||||||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
||||||
import org.oxycblt.auxio.music.metadata.splitEscaped
|
import org.oxycblt.auxio.music.metadata.splitEscaped
|
||||||
|
|
||||||
@Database(entities = [CachedSong::class], version = 46, exportSchema = false)
|
@Database(entities = [CachedSong::class], version = 49, exportSchema = false)
|
||||||
abstract class CacheDatabase : RoomDatabase() {
|
abstract class CacheDatabase : RoomDatabase() {
|
||||||
abstract fun cachedSongsDao(): CachedSongsDao
|
abstract fun cachedSongsDao(): CachedSongsDao
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,8 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.info
|
package org.oxycblt.auxio.music.info
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -34,3 +36,7 @@ class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
|
||||||
|
|
||||||
override fun compareTo(other: Disc) = number.compareTo(other.number)
|
override fun compareTo(other: Disc) = number.compareTo(other.number)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Disc?.resolveNumber(context: Context) =
|
||||||
|
this?.run { context.getString(R.string.fmt_disc_no, number) }
|
||||||
|
?: context.getString(R.string.def_disc)
|
||||||
|
|
|
@ -70,12 +70,12 @@ sealed interface Name : Comparable<Name> {
|
||||||
final override fun compareTo(other: Name) =
|
final override fun compareTo(other: Name) =
|
||||||
when (other) {
|
when (other) {
|
||||||
is Known -> {
|
is Known -> {
|
||||||
// Progressively compare the sort tokens between each known name.
|
val result =
|
||||||
sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) ->
|
sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) ->
|
||||||
acc.takeIf { it != 0 } ?: token.compareTo(otherToken)
|
acc.takeIf { it != 0 } ?: token.compareTo(otherToken)
|
||||||
}
|
}
|
||||||
|
if (result != 0) result else sortTokens.size.compareTo(other.sortTokens.size)
|
||||||
}
|
}
|
||||||
// Unknown names always come before known names.
|
|
||||||
is Unknown -> 1
|
is Unknown -> 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -100,6 +100,7 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
|
||||||
|
|
||||||
private fun populateWithId3v2(rawSong: RawSong, textFrames: Map<String, List<String>>) {
|
private fun populateWithId3v2(rawSong: RawSong, textFrames: Map<String, List<String>>) {
|
||||||
// Song
|
// Song
|
||||||
|
logD(textFrames)
|
||||||
(textFrames["TXXX:musicbrainz release track id"]
|
(textFrames["TXXX:musicbrainz release track id"]
|
||||||
?: textFrames["TXXX:musicbrainz_releasetrackid"])
|
?: textFrames["TXXX:musicbrainz_releasetrackid"])
|
||||||
?.let { rawSong.musicBrainzId = it.first() }
|
?.let { rawSong.musicBrainzId = it.first() }
|
||||||
|
@ -147,10 +148,13 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
|
||||||
(textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
|
(textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
|
||||||
rawSong.artistMusicBrainzIds = it
|
rawSong.artistMusicBrainzIds = it
|
||||||
}
|
}
|
||||||
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
|
(textFrames["TXXX:artists"] ?: textFrames["TPE1"] ?: textFrames["TXXX:artist"])?.let {
|
||||||
|
rawSong.artistNames = it
|
||||||
|
}
|
||||||
(textFrames["TXXX:artistssort"]
|
(textFrames["TXXX:artistssort"]
|
||||||
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
|
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
|
||||||
?: textFrames["TSOP"])
|
?: textFrames["TSOP"] ?: textFrames["artistsort"]
|
||||||
|
?: textFrames["TXXX:artist sort"])
|
||||||
?.let { rawSong.artistSortNames = it }
|
?.let { rawSong.artistSortNames = it }
|
||||||
|
|
||||||
// Album artist
|
// Album artist
|
||||||
|
@ -159,13 +163,14 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
|
||||||
?.let { rawSong.albumArtistMusicBrainzIds = it }
|
?.let { rawSong.albumArtistMusicBrainzIds = it }
|
||||||
(textFrames["TXXX:albumartists"]
|
(textFrames["TXXX:albumartists"]
|
||||||
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
|
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
|
||||||
?: textFrames["TPE2"])
|
?: textFrames["TPE2"] ?: textFrames["TXXX:albumartist"]
|
||||||
|
?: textFrames["TXXX:album artist"])
|
||||||
?.let { rawSong.albumArtistNames = it }
|
?.let { rawSong.albumArtistNames = it }
|
||||||
(textFrames["TXXX:albumartistssort"]
|
(textFrames["TXXX:albumartistssort"]
|
||||||
?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"]
|
?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"]
|
||||||
?: textFrames["TXXX:albumartistsort"]
|
?: textFrames["TXXX:albumartistsort"]
|
||||||
// This is a non-standard iTunes extension
|
// This is a non-standard iTunes extension
|
||||||
?: textFrames["TSO2"])
|
?: textFrames["TSO2"] ?: textFrames["TXXX:album artist sort"])
|
||||||
?.let { rawSong.albumArtistSortNames = it }
|
?.let { rawSong.albumArtistSortNames = it }
|
||||||
|
|
||||||
// Genre
|
// Genre
|
||||||
|
@ -273,7 +278,8 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
|
||||||
}
|
}
|
||||||
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
|
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
|
||||||
(comments["artistssort"]
|
(comments["artistssort"]
|
||||||
?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"])
|
?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"]
|
||||||
|
?: comments["artist sort"])
|
||||||
?.let { rawSong.artistSortNames = it }
|
?.let { rawSong.artistSortNames = it }
|
||||||
|
|
||||||
// Album artist
|
// Album artist
|
||||||
|
@ -281,12 +287,12 @@ class TagInterpreterImpl @Inject constructor(private val coverExtractor: CoverEx
|
||||||
rawSong.albumArtistMusicBrainzIds = it
|
rawSong.albumArtistMusicBrainzIds = it
|
||||||
}
|
}
|
||||||
(comments["albumartists"]
|
(comments["albumartists"]
|
||||||
?: comments["album_artists"] ?: comments["album artists"]
|
?: comments["album_artists"] ?: comments["album artists"] ?: comments["albumartist"]
|
||||||
?: comments["albumartist"])
|
?: comments["album artist"])
|
||||||
?.let { rawSong.albumArtistNames = it }
|
?.let { rawSong.albumArtistNames = it }
|
||||||
(comments["albumartistssort"]
|
(comments["albumartistssort"]
|
||||||
?: comments["albumartists_sort"] ?: comments["albumartists sort"]
|
?: comments["albumartists_sort"] ?: comments["albumartists sort"]
|
||||||
?: comments["albumartistsort"])
|
?: comments["albumartistsort"] ?: comments["album artist sort"])
|
||||||
?.let { rawSong.albumArtistSortNames = it }
|
?.let { rawSong.albumArtistSortNames = it }
|
||||||
|
|
||||||
// Genre
|
// Genre
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2024 Auxio Project
|
* Copyright (c) 2024 Auxio Project
|
||||||
* IndexerServiceFragment.kt is part of Auxio.
|
* Indexer.kt is part of Auxio.
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -21,13 +21,13 @@ package org.oxycblt.auxio.music.service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.ForegroundListener
|
import org.oxycblt.auxio.ForegroundListener
|
||||||
|
import org.oxycblt.auxio.ForegroundServiceNotification
|
||||||
import org.oxycblt.auxio.music.IndexingState
|
import org.oxycblt.auxio.music.IndexingState
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
import org.oxycblt.auxio.music.MusicSettings
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
@ -35,34 +35,52 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
class IndexerServiceFragment
|
class Indexer
|
||||||
@Inject
|
private constructor(
|
||||||
constructor(
|
override val workerContext: Context,
|
||||||
@ApplicationContext override val workerContext: Context,
|
private val foregroundListener: ForegroundListener,
|
||||||
private val playbackManager: PlaybackStateManager,
|
private val playbackManager: PlaybackStateManager,
|
||||||
private val musicRepository: MusicRepository,
|
private val musicRepository: MusicRepository,
|
||||||
private val musicSettings: MusicSettings,
|
private val musicSettings: MusicSettings,
|
||||||
private val contentObserver: SystemContentObserver,
|
private val imageLoader: ImageLoader,
|
||||||
private val imageLoader: ImageLoader
|
private val contentObserver: SystemContentObserver
|
||||||
) :
|
) :
|
||||||
MusicRepository.IndexingWorker,
|
MusicRepository.IndexingWorker,
|
||||||
MusicRepository.IndexingListener,
|
MusicRepository.IndexingListener,
|
||||||
MusicRepository.UpdateListener,
|
MusicRepository.UpdateListener,
|
||||||
MusicSettings.Listener {
|
MusicSettings.Listener {
|
||||||
|
class Factory
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val playbackManager: PlaybackStateManager,
|
||||||
|
private val musicRepository: MusicRepository,
|
||||||
|
private val musicSettings: MusicSettings,
|
||||||
|
private val imageLoader: ImageLoader,
|
||||||
|
private val contentObserver: SystemContentObserver
|
||||||
|
) {
|
||||||
|
fun create(context: Context, listener: ForegroundListener) =
|
||||||
|
Indexer(
|
||||||
|
context,
|
||||||
|
listener,
|
||||||
|
playbackManager,
|
||||||
|
musicRepository,
|
||||||
|
musicSettings,
|
||||||
|
imageLoader,
|
||||||
|
contentObserver)
|
||||||
|
}
|
||||||
|
|
||||||
private val indexJob = Job()
|
private val indexJob = Job()
|
||||||
private val indexScope = CoroutineScope(indexJob + Dispatchers.IO)
|
private val indexScope = CoroutineScope(indexJob + Dispatchers.IO)
|
||||||
private var currentIndexJob: Job? = null
|
private var currentIndexJob: Job? = null
|
||||||
private val indexingNotification = IndexingNotification(workerContext)
|
private val indexingNotification = IndexingNotification(workerContext)
|
||||||
private val observingNotification = ObservingNotification(workerContext)
|
private val observingNotification = ObservingNotification(workerContext)
|
||||||
private var foregroundListener: ForegroundListener? = null
|
|
||||||
private val wakeLock =
|
private val wakeLock =
|
||||||
workerContext
|
workerContext
|
||||||
.getSystemServiceCompat(PowerManager::class)
|
.getSystemServiceCompat(PowerManager::class)
|
||||||
.newWakeLock(
|
.newWakeLock(
|
||||||
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
|
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
|
||||||
|
|
||||||
fun attach(listener: ForegroundListener) {
|
fun attach() {
|
||||||
foregroundListener = listener
|
|
||||||
musicSettings.registerListener(this)
|
musicSettings.registerListener(this)
|
||||||
musicRepository.addUpdateListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
musicRepository.addIndexingListener(this)
|
musicRepository.addIndexingListener(this)
|
||||||
|
@ -76,7 +94,6 @@ constructor(
|
||||||
musicRepository.removeIndexingListener(this)
|
musicRepository.removeIndexingListener(this)
|
||||||
musicRepository.removeUpdateListener(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
musicSettings.unregisterListener(this)
|
musicSettings.unregisterListener(this)
|
||||||
foregroundListener = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
|
@ -85,7 +102,7 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createNotification(post: (IndexerNotification?) -> Unit) {
|
fun createNotification(post: (ForegroundServiceNotification?) -> Unit) {
|
||||||
val state = musicRepository.indexingState
|
val state = musicRepository.indexingState
|
||||||
if (state is IndexingState.Indexing) {
|
if (state is IndexingState.Indexing) {
|
||||||
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
||||||
|
@ -118,7 +135,7 @@ constructor(
|
||||||
override val scope = indexScope
|
override val scope = indexScope
|
||||||
|
|
||||||
override fun onIndexingStateChanged() {
|
override fun onIndexingStateChanged() {
|
||||||
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
|
foregroundListener.updateForeground(ForegroundListener.Change.INDEXER)
|
||||||
val state = musicRepository.indexingState
|
val state = musicRepository.indexingState
|
||||||
if (state is IndexingState.Indexing) {
|
if (state is IndexingState.Indexing) {
|
||||||
wakeLock.acquireSafe()
|
wakeLock.acquireSafe()
|
||||||
|
@ -157,9 +174,9 @@ constructor(
|
||||||
// notification if we were actively loading when the automatic rescanning
|
// notification if we were actively loading when the automatic rescanning
|
||||||
// setting changed. In such a case, the state will still be updated when
|
// setting changed. In such a case, the state will still be updated when
|
||||||
// the music loading process ends.
|
// the music loading process ends.
|
||||||
if (currentIndexJob == null) {
|
if (musicRepository.indexingState == null) {
|
||||||
logD("Not loading, updating idle session")
|
logD("Not loading, updating idle session")
|
||||||
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
|
foregroundListener.updateForeground(ForegroundListener.Change.INDEXER)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,11 +20,9 @@ package org.oxycblt.auxio.music.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.core.app.NotificationChannelCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
|
import org.oxycblt.auxio.ForegroundServiceNotification
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.IndexingProgress
|
import org.oxycblt.auxio.music.IndexingProgress
|
||||||
|
@ -32,52 +30,13 @@ import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
|
* A dynamic [ForegroundServiceNotification] that shows the current music loading state.
|
||||||
* signal a Service's ongoing foreground state.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
abstract class IndexerNotification(context: Context, info: ChannelInfo) :
|
|
||||||
NotificationCompat.Builder(context, info.id) {
|
|
||||||
private val notificationManager = NotificationManagerCompat.from(context)
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Set up the notification channel. Foreground notifications are non-substantial, and
|
|
||||||
// thus make no sense to have lights, vibration, or lead to a notification badge.
|
|
||||||
val channel =
|
|
||||||
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
|
|
||||||
.setName(context.getString(info.nameRes))
|
|
||||||
.setLightsEnabled(false)
|
|
||||||
.setVibrationEnabled(false)
|
|
||||||
.setShowBadge(false)
|
|
||||||
.build()
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The code used to identify this notification.
|
|
||||||
*
|
|
||||||
* @see NotificationManagerCompat.notify
|
|
||||||
*/
|
|
||||||
abstract val code: Int
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduced representation of a [NotificationChannelCompat].
|
|
||||||
*
|
|
||||||
* @param id The ID of the channel.
|
|
||||||
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
|
|
||||||
*/
|
|
||||||
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A dynamic [IndexerNotification] that shows the current music loading state.
|
|
||||||
*
|
*
|
||||||
* @param context [Context] required to create the notification.
|
* @param context [Context] required to create the notification.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class IndexingNotification(private val context: Context) :
|
class IndexingNotification(private val context: Context) :
|
||||||
IndexerNotification(context, indexerChannel) {
|
ForegroundServiceNotification(context, indexerChannel) {
|
||||||
private var lastUpdateTime = -1L
|
private var lastUpdateTime = -1L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -133,12 +92,13 @@ class IndexingNotification(private val context: Context) :
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A static [IndexerNotification] that signals to the user that the app is currently monitoring the
|
* A static [ForegroundServiceNotification] that signals to the user that the app is currently
|
||||||
* music library for changes.
|
* monitoring the music library for changes.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) {
|
class ObservingNotification(context: Context) :
|
||||||
|
ForegroundServiceNotification(context, indexerChannel) {
|
||||||
init {
|
init {
|
||||||
setSmallIcon(R.drawable.ic_indexer_24)
|
setSmallIcon(R.drawable.ic_indexer_24)
|
||||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
@ -156,5 +116,5 @@ class ObservingNotification(context: Context) : IndexerNotification(context, ind
|
||||||
|
|
||||||
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
|
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
|
||||||
private val indexerChannel =
|
private val indexerChannel =
|
||||||
IndexerNotification.ChannelInfo(
|
ForegroundServiceNotification.ChannelInfo(
|
||||||
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)
|
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)
|
||||||
|
|
|
@ -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
|
package org.oxycblt.auxio.music.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.annotation.DrawableRes
|
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.MediaMetadata
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
|
@ -37,242 +34,19 @@ import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
|
import org.oxycblt.auxio.playback.formatDurationDs
|
||||||
import org.oxycblt.auxio.util.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
|
|
||||||
fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem {
|
|
||||||
// TODO: Make custom overflow menu for compat
|
|
||||||
val style =
|
|
||||||
Bundle().apply {
|
|
||||||
putInt(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)
|
|
||||||
}
|
|
||||||
val metadata =
|
|
||||||
MediaMetadata.Builder()
|
|
||||||
.setTitle(context.getString(nameRes))
|
|
||||||
.setIsPlayable(false)
|
|
||||||
.setIsBrowsable(true)
|
|
||||||
.setMediaType(mediaType)
|
|
||||||
.setExtras(style)
|
|
||||||
if (bitmapRes != null) {
|
|
||||||
val data = ByteArrayOutputStream()
|
|
||||||
BitmapFactory.decodeResource(context.resources, bitmapRes)
|
|
||||||
.compress(Bitmap.CompressFormat.PNG, 100, data)
|
|
||||||
metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON)
|
|
||||||
}
|
|
||||||
return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
|
|
||||||
val mediaSessionUID =
|
|
||||||
if (parent == null) {
|
|
||||||
MediaSessionUID.Single(uid)
|
|
||||||
} else {
|
|
||||||
MediaSessionUID.Joined(parent.uid, uid)
|
|
||||||
}
|
|
||||||
val metadata =
|
|
||||||
MediaMetadata.Builder()
|
|
||||||
.setTitle(name.resolve(context))
|
|
||||||
.setArtist(artists.resolveNames(context))
|
|
||||||
.setAlbumTitle(album.name.resolve(context))
|
|
||||||
.setAlbumArtist(album.artists.resolveNames(context))
|
|
||||||
.setTrackNumber(track)
|
|
||||||
.setDiscNumber(disc?.number)
|
|
||||||
.setGenre(genres.resolveNames(context))
|
|
||||||
.setDisplayTitle(name.resolve(context))
|
|
||||||
.setSubtitle(artists.resolveNames(context))
|
|
||||||
.setRecordingYear(album.dates?.min?.year)
|
|
||||||
.setRecordingMonth(album.dates?.min?.month)
|
|
||||||
.setRecordingDay(album.dates?.min?.day)
|
|
||||||
.setReleaseYear(album.dates?.min?.year)
|
|
||||||
.setReleaseMonth(album.dates?.min?.month)
|
|
||||||
.setReleaseDay(album.dates?.min?.day)
|
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
|
||||||
.setIsPlayable(true)
|
|
||||||
.setIsBrowsable(false)
|
|
||||||
.setArtworkUri(cover.mediaStoreCoverUri)
|
|
||||||
.setExtras(
|
|
||||||
Bundle().apply {
|
|
||||||
putString("uid", mediaSessionUID.toString())
|
|
||||||
putLong("durationMs", durationMs)
|
|
||||||
})
|
|
||||||
.build()
|
|
||||||
return MediaItem.Builder()
|
|
||||||
.setUri(uri)
|
|
||||||
.setMediaId(mediaSessionUID.toString())
|
|
||||||
.setMediaMetadata(metadata)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Album.toMediaItem(context: Context): MediaItem {
|
|
||||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
|
||||||
val metadata =
|
|
||||||
MediaMetadata.Builder()
|
|
||||||
.setTitle(name.resolve(context))
|
|
||||||
.setArtist(artists.resolveNames(context))
|
|
||||||
.setAlbumTitle(name.resolve(context))
|
|
||||||
.setAlbumArtist(artists.resolveNames(context))
|
|
||||||
.setRecordingYear(dates?.min?.year)
|
|
||||||
.setRecordingMonth(dates?.min?.month)
|
|
||||||
.setRecordingDay(dates?.min?.day)
|
|
||||||
.setReleaseYear(dates?.min?.year)
|
|
||||||
.setReleaseMonth(dates?.min?.month)
|
|
||||||
.setReleaseDay(dates?.min?.day)
|
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
|
|
||||||
.setIsPlayable(false)
|
|
||||||
.setIsBrowsable(true)
|
|
||||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
|
||||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
|
||||||
.build()
|
|
||||||
return MediaItem.Builder()
|
|
||||||
.setMediaId(mediaSessionUID.toString())
|
|
||||||
.setMediaMetadata(metadata)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Artist.toMediaItem(context: Context): MediaItem {
|
|
||||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
|
||||||
val metadata =
|
|
||||||
MediaMetadata.Builder()
|
|
||||||
.setTitle(name.resolve(context))
|
|
||||||
.setSubtitle(
|
|
||||||
context.getString(
|
|
||||||
R.string.fmt_two,
|
|
||||||
if (explicitAlbums.isNotEmpty()) {
|
|
||||||
context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size)
|
|
||||||
} else {
|
|
||||||
context.getString(R.string.def_album_count)
|
|
||||||
},
|
|
||||||
if (songs.isNotEmpty()) {
|
|
||||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
|
||||||
} else {
|
|
||||||
context.getString(R.string.def_song_count)
|
|
||||||
}))
|
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
|
|
||||||
.setIsPlayable(false)
|
|
||||||
.setIsBrowsable(true)
|
|
||||||
.setGenre(genres.resolveNames(context))
|
|
||||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
|
||||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
|
||||||
.build()
|
|
||||||
return MediaItem.Builder()
|
|
||||||
.setMediaId(mediaSessionUID.toString())
|
|
||||||
.setMediaMetadata(metadata)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Genre.toMediaItem(context: Context): MediaItem {
|
|
||||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
|
||||||
val metadata =
|
|
||||||
MediaMetadata.Builder()
|
|
||||||
.setTitle(name.resolve(context))
|
|
||||||
.setSubtitle(
|
|
||||||
if (songs.isNotEmpty()) {
|
|
||||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
|
||||||
} else {
|
|
||||||
context.getString(R.string.def_song_count)
|
|
||||||
})
|
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
|
|
||||||
.setIsPlayable(false)
|
|
||||||
.setIsBrowsable(true)
|
|
||||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
|
||||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
|
||||||
.build()
|
|
||||||
return MediaItem.Builder()
|
|
||||||
.setMediaId(mediaSessionUID.toString())
|
|
||||||
.setMediaMetadata(metadata)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Playlist.toMediaItem(context: Context): MediaItem {
|
|
||||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
|
||||||
val metadata =
|
|
||||||
MediaMetadata.Builder()
|
|
||||||
.setTitle(name.resolve(context))
|
|
||||||
.setSubtitle(
|
|
||||||
if (songs.isNotEmpty()) {
|
|
||||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
|
||||||
} else {
|
|
||||||
context.getString(R.string.def_song_count)
|
|
||||||
})
|
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
|
||||||
.setIsPlayable(false)
|
|
||||||
.setIsBrowsable(true)
|
|
||||||
.setArtworkUri(cover?.single?.mediaStoreCoverUri)
|
|
||||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
|
||||||
.build()
|
|
||||||
return MediaItem.Builder()
|
|
||||||
.setMediaId(mediaSessionUID.toString())
|
|
||||||
.setMediaMetadata(metadata)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? {
|
|
||||||
val uid = MediaSessionUID.fromString(mediaId) ?: return null
|
|
||||||
return when (uid) {
|
|
||||||
is MediaSessionUID.Single -> {
|
|
||||||
deviceLibrary.findSong(uid.uid)
|
|
||||||
}
|
|
||||||
is MediaSessionUID.Joined -> {
|
|
||||||
deviceLibrary.findSong(uid.childUid)
|
|
||||||
}
|
|
||||||
is MediaSessionUID.Category -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface MediaSessionUID {
|
sealed interface MediaSessionUID {
|
||||||
enum class Category(
|
data class Tab(val node: TabNode) : MediaSessionUID {
|
||||||
val id: String,
|
override fun toString() = "$ID_CATEGORY:${node.id}"
|
||||||
@StringRes val nameRes: Int,
|
|
||||||
@DrawableRes val bitmapRes: Int?,
|
|
||||||
val mediaType: Int?
|
|
||||||
) : MediaSessionUID {
|
|
||||||
ROOT("root", R.string.info_app_name, null, null),
|
|
||||||
SONGS(
|
|
||||||
"songs",
|
|
||||||
R.string.lbl_songs,
|
|
||||||
R.drawable.ic_song_bitmap_24,
|
|
||||||
MediaMetadata.MEDIA_TYPE_MUSIC),
|
|
||||||
ALBUMS(
|
|
||||||
"albums",
|
|
||||||
R.string.lbl_albums,
|
|
||||||
R.drawable.ic_album_bitmap_24,
|
|
||||||
MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
|
||||||
ARTISTS(
|
|
||||||
"artists",
|
|
||||||
R.string.lbl_artists,
|
|
||||||
R.drawable.ic_artist_bitmap_24,
|
|
||||||
MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
|
|
||||||
GENRES(
|
|
||||||
"genres",
|
|
||||||
R.string.lbl_genres,
|
|
||||||
R.drawable.ic_genre_bitmap_24,
|
|
||||||
MediaMetadata.MEDIA_TYPE_FOLDER_GENRES),
|
|
||||||
PLAYLISTS(
|
|
||||||
"playlists",
|
|
||||||
R.string.lbl_playlists,
|
|
||||||
R.drawable.ic_playlist_bitmap_24,
|
|
||||||
MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
|
|
||||||
|
|
||||||
override fun toString() = "$ID_CATEGORY:$id"
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES)
|
|
||||||
val USER_MUSIC = listOf(ROOT, PLAYLISTS)
|
|
||||||
val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Single(val uid: Music.UID) : MediaSessionUID {
|
data class SingleItem(val uid: Music.UID) : MediaSessionUID {
|
||||||
override fun toString() = "$ID_ITEM:$uid"
|
override fun toString() = "$ID_ITEM:$uid"
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID {
|
|
||||||
override fun toString() = "$ID_ITEM:$parentUid>$childUid"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category"
|
const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category"
|
||||||
const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item"
|
const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item"
|
||||||
|
@ -283,28 +57,154 @@ sealed interface MediaSessionUID {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return when (parts[0]) {
|
return when (parts[0]) {
|
||||||
ID_CATEGORY ->
|
ID_CATEGORY -> Tab(TabNode.fromString(parts[1]) ?: return null)
|
||||||
when (parts[1]) {
|
ID_ITEM -> SingleItem(Music.UID.fromString(parts[1]) ?: return null)
|
||||||
Category.ROOT.id -> Category.ROOT
|
|
||||||
Category.SONGS.id -> Category.SONGS
|
|
||||||
Category.ALBUMS.id -> Category.ALBUMS
|
|
||||||
Category.ARTISTS.id -> Category.ARTISTS
|
|
||||||
Category.GENRES.id -> Category.GENRES
|
|
||||||
Category.PLAYLISTS.id -> Category.PLAYLISTS
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
ID_ITEM -> {
|
|
||||||
val uids = parts[1].split(">", limit = 2)
|
|
||||||
if (uids.size == 1) {
|
|
||||||
Music.UID.fromString(uids[0])?.let { Single(it) }
|
|
||||||
} else {
|
|
||||||
Music.UID.fromString(uids[0])?.let { parent ->
|
|
||||||
Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> return null
|
else -> return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typealias Sugar = Bundle.(Context) -> Unit
|
||||||
|
|
||||||
|
fun header(@StringRes nameRes: Int): Sugar = {
|
||||||
|
putString(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun header(name: String): Sugar = {
|
||||||
|
putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun child(of: MusicParent): Sugar = {
|
||||||
|
putString(MusicBrowser.KEY_CHILD_OF, MediaSessionUID.SingleItem(of.uid).toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun style(style: Int): Sugar = {
|
||||||
|
putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle {
|
||||||
|
return Bundle().apply { sugars.forEach { this.it(context) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun TabNode.toMediaItem(context: Context): MediaItem {
|
||||||
|
val extras =
|
||||||
|
makeExtras(
|
||||||
|
context,
|
||||||
|
style(MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM))
|
||||||
|
val mediaSessionUID = MediaSessionUID.Tab(this)
|
||||||
|
val description =
|
||||||
|
MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaSessionUID.toString())
|
||||||
|
.setTitle(context.getString(nameRes))
|
||||||
|
.setExtras(extras)
|
||||||
|
bitmapRes?.let { res ->
|
||||||
|
val bitmap = BitmapFactory.decodeResource(context.resources, res)
|
||||||
|
description.setIconBitmap(bitmap)
|
||||||
|
}
|
||||||
|
return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Song.toMediaDescription(context: Context, vararg sugar: Sugar): MediaDescriptionCompat {
|
||||||
|
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||||
|
val extras = makeExtras(context, *sugar)
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaSessionUID.toString())
|
||||||
|
.setTitle(name.resolve(context))
|
||||||
|
.setSubtitle(artists.resolveNames(context))
|
||||||
|
.setDescription(album.name.resolve(context))
|
||||||
|
.setIconUri(cover.mediaStoreCoverUri)
|
||||||
|
.setMediaUri(uri)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Song.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
||||||
|
return MediaItem(toMediaDescription(context, *sugar), MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Album.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
||||||
|
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||||
|
val extras = makeExtras(context, *sugar)
|
||||||
|
val counts = context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||||
|
val description =
|
||||||
|
MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaSessionUID.toString())
|
||||||
|
.setTitle(name.resolve(context))
|
||||||
|
.setSubtitle(artists.resolveNames(context))
|
||||||
|
.setDescription(counts)
|
||||||
|
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
||||||
|
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||||
|
val counts =
|
||||||
|
context.getString(
|
||||||
|
R.string.fmt_two,
|
||||||
|
if (explicitAlbums.isNotEmpty()) {
|
||||||
|
context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.def_album_count)
|
||||||
|
},
|
||||||
|
if (songs.isNotEmpty()) {
|
||||||
|
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.def_song_count)
|
||||||
|
})
|
||||||
|
val extras = makeExtras(context, *sugar)
|
||||||
|
val description =
|
||||||
|
MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaSessionUID.toString())
|
||||||
|
.setTitle(name.resolve(context))
|
||||||
|
.setSubtitle(counts)
|
||||||
|
.setDescription(genres.resolveNames(context))
|
||||||
|
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
||||||
|
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||||
|
val counts =
|
||||||
|
if (songs.isNotEmpty()) {
|
||||||
|
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.def_song_count)
|
||||||
|
}
|
||||||
|
val extras = makeExtras(context, *sugar)
|
||||||
|
val description =
|
||||||
|
MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaSessionUID.toString())
|
||||||
|
.setTitle(name.resolve(context))
|
||||||
|
.setSubtitle(counts)
|
||||||
|
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
||||||
|
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||||
|
val counts =
|
||||||
|
if (songs.isNotEmpty()) {
|
||||||
|
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.def_song_count)
|
||||||
|
}
|
||||||
|
val extras = makeExtras(context, *sugar)
|
||||||
|
val description =
|
||||||
|
MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaSessionUID.toString())
|
||||||
|
.setTitle(name.resolve(context))
|
||||||
|
.setSubtitle(counts)
|
||||||
|
.setDescription(durationMs.formatDurationDs(true))
|
||||||
|
.setIconUri(cover?.single?.mediaStoreCoverUri)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||||
|
}
|
||||||
|
|
|
@ -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.MusicParent
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.service.toMediaItem
|
|
||||||
import org.oxycblt.auxio.music.service.toSong
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.playback.msToSecs
|
import org.oxycblt.auxio.playback.msToSecs
|
||||||
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||||
|
@ -92,7 +90,6 @@ class ExoPlaybackStateHolder(
|
||||||
fun attach() {
|
fun attach() {
|
||||||
imageSettings.registerListener(this)
|
imageSettings.registerListener(this)
|
||||||
player.addListener(this)
|
player.addListener(this)
|
||||||
replayGainProcessor.attach()
|
|
||||||
playbackManager.registerStateHolder(this)
|
playbackManager.registerStateHolder(this)
|
||||||
playbackSettings.registerListener(this)
|
playbackSettings.registerListener(this)
|
||||||
musicRepository.addUpdateListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
|
@ -111,10 +108,6 @@ class ExoPlaybackStateHolder(
|
||||||
override var parent: MusicParent? = null
|
override var parent: MusicParent? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val mediaSessionPlayer: Player
|
|
||||||
get() =
|
|
||||||
MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository)
|
|
||||||
|
|
||||||
override val progression: Progression
|
override val progression: Progression
|
||||||
get() {
|
get() {
|
||||||
val mediaItem = player.currentMediaItem ?: return Progression.nil()
|
val mediaItem = player.currentMediaItem ?: return Progression.nil()
|
||||||
|
@ -147,10 +140,7 @@ class ExoPlaybackStateHolder(
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
return RawQueue(
|
return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex)
|
||||||
heap.mapNotNull { it.toSong(deviceLibrary) },
|
|
||||||
shuffledMapping,
|
|
||||||
player.currentMediaItemIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleDeferred(action: DeferredPlayback): Boolean {
|
override fun handleDeferred(action: DeferredPlayback): Boolean {
|
||||||
|
@ -164,10 +154,18 @@ class ExoPlaybackStateHolder(
|
||||||
is DeferredPlayback.RestoreState -> {
|
is DeferredPlayback.RestoreState -> {
|
||||||
logD("Restoring playback state")
|
logD("Restoring playback state")
|
||||||
restoreScope.launch {
|
restoreScope.launch {
|
||||||
persistenceRepository.readState()?.let {
|
val state = persistenceRepository.readState()
|
||||||
// Apply the saved state on the main thread to prevent code expecting
|
withContext(Dispatchers.Main) {
|
||||||
// state updates on the main thread from crashing.
|
if (state != null) {
|
||||||
withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) }
|
// Apply the saved state on the main thread to prevent code expecting
|
||||||
|
// state updates on the main thread from crashing.
|
||||||
|
playbackManager.applySavedState(state, false)
|
||||||
|
if (action.play) {
|
||||||
|
playbackManager.playing(true)
|
||||||
|
}
|
||||||
|
} else if (action.fallback != null) {
|
||||||
|
playbackManager.playDeferred(action.fallback)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -219,7 +217,7 @@ class ExoPlaybackStateHolder(
|
||||||
override fun newPlayback(command: PlaybackCommand) {
|
override fun newPlayback(command: PlaybackCommand) {
|
||||||
parent = command.parent
|
parent = command.parent
|
||||||
player.shuffleModeEnabled = command.shuffled
|
player.shuffleModeEnabled = command.shuffled
|
||||||
player.setMediaItems(command.queue.map { it.toMediaItem(context, null) })
|
player.setMediaItems(command.queue.map { it.buildMediaItem() })
|
||||||
val startIndex =
|
val startIndex =
|
||||||
command.song
|
command.song
|
||||||
?.let { command.queue.indexOf(it) }
|
?.let { command.queue.indexOf(it) }
|
||||||
|
@ -309,16 +307,16 @@ class ExoPlaybackStateHolder(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextIndex == C.INDEX_UNSET) {
|
if (nextIndex == C.INDEX_UNSET) {
|
||||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
player.addMediaItems(songs.map { it.buildMediaItem() })
|
||||||
} else {
|
} else {
|
||||||
player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) })
|
player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() })
|
||||||
}
|
}
|
||||||
playbackManager.ack(this, ack)
|
playbackManager.ack(this, ack)
|
||||||
deferSave()
|
deferSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
|
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
|
||||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
player.addMediaItems(songs.map { it.buildMediaItem() })
|
||||||
playbackManager.ack(this, ack)
|
playbackManager.ack(this, ack)
|
||||||
deferSave()
|
deferSave()
|
||||||
}
|
}
|
||||||
|
@ -370,12 +368,6 @@ class ExoPlaybackStateHolder(
|
||||||
repeatMode: RepeatMode,
|
repeatMode: RepeatMode,
|
||||||
ack: StateAck.NewPlayback?
|
ack: StateAck.NewPlayback?
|
||||||
) {
|
) {
|
||||||
val resolve = resolveQueue()
|
|
||||||
logD("${rawQueue.heap == resolve.heap}")
|
|
||||||
logD("${rawQueue.shuffledMapping == resolve.shuffledMapping}")
|
|
||||||
logD("${rawQueue.heapIndex == resolve.heapIndex}")
|
|
||||||
logD("${rawQueue.isShuffled == resolve.isShuffled}")
|
|
||||||
logD("${rawQueue == resolve}")
|
|
||||||
var sendNewPlaybackEvent = false
|
var sendNewPlaybackEvent = false
|
||||||
var shouldSeek = false
|
var shouldSeek = false
|
||||||
if (this.parent != parent) {
|
if (this.parent != parent) {
|
||||||
|
@ -383,7 +375,7 @@ class ExoPlaybackStateHolder(
|
||||||
sendNewPlaybackEvent = true
|
sendNewPlaybackEvent = true
|
||||||
}
|
}
|
||||||
if (rawQueue != resolveQueue()) {
|
if (rawQueue != resolveQueue()) {
|
||||||
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
|
player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() })
|
||||||
if (rawQueue.isShuffled) {
|
if (rawQueue.isShuffled) {
|
||||||
player.shuffleModeEnabled = true
|
player.shuffleModeEnabled = true
|
||||||
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
||||||
|
@ -548,6 +540,50 @@ class ExoPlaybackStateHolder(
|
||||||
currentSaveJob = saveScope.launch { block() }
|
currentSaveJob = saveScope.launch { block() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Song.buildMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build()
|
||||||
|
|
||||||
|
private val MediaItem.song: Song?
|
||||||
|
get() = this.localConfiguration?.tag as? Song?
|
||||||
|
|
||||||
|
private fun Player.unscrambleQueueIndices(): List<Int> {
|
||||||
|
val timeline = currentTimeline
|
||||||
|
if (timeline.isEmpty) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val queue = mutableListOf<Int>()
|
||||||
|
|
||||||
|
// Add the active queue item.
|
||||||
|
val currentMediaItemIndex = currentMediaItemIndex
|
||||||
|
queue.add(currentMediaItemIndex)
|
||||||
|
|
||||||
|
// Fill queue alternating with next and/or previous queue items.
|
||||||
|
var firstMediaItemIndex = currentMediaItemIndex
|
||||||
|
var lastMediaItemIndex = currentMediaItemIndex
|
||||||
|
val shuffleModeEnabled = shuffleModeEnabled
|
||||||
|
while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) {
|
||||||
|
// Begin with next to have a longer tail than head if an even sized queue needs to be
|
||||||
|
// trimmed.
|
||||||
|
if (lastMediaItemIndex != C.INDEX_UNSET) {
|
||||||
|
lastMediaItemIndex =
|
||||||
|
timeline.getNextWindowIndex(
|
||||||
|
lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
|
||||||
|
if (lastMediaItemIndex != C.INDEX_UNSET) {
|
||||||
|
queue.add(lastMediaItemIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstMediaItemIndex != C.INDEX_UNSET) {
|
||||||
|
firstMediaItemIndex =
|
||||||
|
timeline.getPreviousWindowIndex(
|
||||||
|
firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
|
||||||
|
if (firstMediaItemIndex != C.INDEX_UNSET) {
|
||||||
|
queue.add(0, firstMediaItemIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue
|
||||||
|
}
|
||||||
|
|
||||||
class Factory
|
class Factory
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -563,7 +599,7 @@ class ExoPlaybackStateHolder(
|
||||||
) {
|
) {
|
||||||
fun create(): ExoPlaybackStateHolder {
|
fun create(): ExoPlaybackStateHolder {
|
||||||
// Since Auxio is a music player, only specify an audio renderer to save
|
// Since Auxio is a music player, only specify an audio renderer to save
|
||||||
// battery/apk size/cache size
|
// battery/apk size/cache size]
|
||||||
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
||||||
arrayOf(
|
arrayOf(
|
||||||
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
|
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
|
||||||
|
|
|
@ -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. */
|
/** Possible long-running background tasks handled by the background playback task. */
|
||||||
sealed interface DeferredPlayback {
|
sealed interface DeferredPlayback {
|
||||||
/** Restore the previously saved playback state. */
|
/** Restore the previously saved playback state. */
|
||||||
data object RestoreState : DeferredPlayback
|
data class RestoreState(val play: Boolean, val fallback: DeferredPlayback? = null) :
|
||||||
|
DeferredPlayback
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut.
|
* Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut.
|
||||||
|
|
|
@ -61,7 +61,7 @@ interface SearchEngine {
|
||||||
val artists: Collection<Artist>? = null,
|
val artists: Collection<Artist>? = null,
|
||||||
val genres: Collection<Genre>? = null,
|
val genres: Collection<Genre>? = null,
|
||||||
val playlists: Collection<Playlist>? = null
|
val playlists: Collection<Playlist>? = null
|
||||||
)
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||||
|
|
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.graphics.Bitmap
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import coil.size.Size
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.image.BitmapProvider
|
import org.oxycblt.auxio.image.BitmapProvider
|
||||||
|
@ -46,17 +46,28 @@ import org.oxycblt.auxio.util.logD
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class WidgetComponent
|
class WidgetComponent
|
||||||
@Inject
|
private constructor(
|
||||||
constructor(
|
private val context: Context,
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val imageSettings: ImageSettings,
|
private val imageSettings: ImageSettings,
|
||||||
private val bitmapProvider: BitmapProvider,
|
private val bitmapProvider: BitmapProvider,
|
||||||
private val playbackManager: PlaybackStateManager,
|
private val playbackManager: PlaybackStateManager,
|
||||||
private val uiSettings: UISettings
|
private val uiSettings: UISettings
|
||||||
) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener {
|
) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener {
|
||||||
|
class Factory
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val imageSettings: ImageSettings,
|
||||||
|
private val bitmapProvider: BitmapProvider,
|
||||||
|
private val playbackManager: PlaybackStateManager,
|
||||||
|
private val uiSettings: UISettings
|
||||||
|
) {
|
||||||
|
fun create(context: Context) =
|
||||||
|
WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings)
|
||||||
|
}
|
||||||
|
|
||||||
private val widgetProvider = WidgetProvider()
|
private val widgetProvider = WidgetProvider()
|
||||||
|
|
||||||
init {
|
fun attach() {
|
||||||
playbackManager.addListener(this)
|
playbackManager.addListener(this)
|
||||||
uiSettings.registerListener(this)
|
uiSettings.registerListener(this)
|
||||||
imageSettings.registerListener(this)
|
imageSettings.registerListener(this)
|
||||||
|
@ -96,24 +107,19 @@ constructor(
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (cornerRadius > 0) {
|
val transformations = buildList {
|
||||||
// If rounded, reduce the bitmap size further to obtain more pronounced
|
|
||||||
// rounded corners.
|
|
||||||
builder.size(getSafeRemoteViewsImageSize(context, 10f))
|
|
||||||
val cornersTransformation =
|
|
||||||
RoundedRectTransformation(cornerRadius.toFloat())
|
|
||||||
if (imageSettings.forceSquareCovers) {
|
if (imageSettings.forceSquareCovers) {
|
||||||
builder.transformations(
|
add(SquareCropTransformation.INSTANCE)
|
||||||
SquareCropTransformation.INSTANCE, cornersTransformation)
|
}
|
||||||
|
if (cornerRadius > 0) {
|
||||||
|
add(WidgetBitmapTransformation(15f))
|
||||||
|
add(RoundedRectTransformation(cornerRadius.toFloat()))
|
||||||
} else {
|
} else {
|
||||||
builder.transformations(cornersTransformation)
|
add(WidgetBitmapTransformation(3f))
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if (imageSettings.forceSquareCovers) {
|
|
||||||
builder.transformations(SquareCropTransformation.INSTANCE)
|
|
||||||
}
|
|
||||||
builder.size(getSafeRemoteViewsImageSize(context))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return builder.size(Size.ORIGINAL).transformations(transformations)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCompleted(bitmap: Bitmap?) {
|
override fun onCompleted(bitmap: Bitmap?) {
|
||||||
|
|
|
@ -27,7 +27,6 @@ import android.widget.RemoteViews
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.IdRes
|
import androidx.annotation.IdRes
|
||||||
import androidx.annotation.LayoutRes
|
import androidx.annotation.LayoutRes
|
||||||
import kotlin.math.sqrt
|
|
||||||
import org.oxycblt.auxio.util.isLandscape
|
import org.oxycblt.auxio.util.isLandscape
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||||
|
@ -46,24 +45,6 @@ fun newRemoteViews(context: Context, @LayoutRes layoutRes: Int): RemoteViews {
|
||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an image size guaranteed to not exceed the [RemoteViews] bitmap memory limit, assuming that
|
|
||||||
* there is only one image.
|
|
||||||
*
|
|
||||||
* @param context [Context] required to perform calculation.
|
|
||||||
* @param reduce Optional multiplier to reduce the image size. Recommended value is 3 to avoid
|
|
||||||
* device-specific variations in memory limit.
|
|
||||||
* @return The dimension of a bitmap that can be safely used in [RemoteViews].
|
|
||||||
*/
|
|
||||||
fun getSafeRemoteViewsImageSize(context: Context, reduce: Float = 3f): Int {
|
|
||||||
val metrics = context.resources.displayMetrics
|
|
||||||
val sw = metrics.widthPixels
|
|
||||||
val sh = metrics.heightPixels
|
|
||||||
// Maximum size is 1/3 total screen area * 4 bytes per pixel. Reverse
|
|
||||||
// that to obtain the image size.
|
|
||||||
return sqrt((6f / 4f / reduce) * sw * sh).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the background resource of a [RemoteViews] View.
|
* Set the background resource of a [RemoteViews] View.
|
||||||
*
|
*
|
||||||
|
|
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>
|
<string name="lbl_shuffle_shortcut_short">Shuffle</string>
|
||||||
<!-- Limit to 25 characters -->
|
<!-- Limit to 25 characters -->
|
||||||
<string name="lbl_shuffle_shortcut_long">Shuffle all</string>
|
<string name="lbl_shuffle_shortcut_long">Shuffle all</string>
|
||||||
|
<string name="lbl_start_playback">Start playback</string>
|
||||||
|
|
||||||
<string name="lbl_ok">OK</string>
|
<string name="lbl_ok">OK</string>
|
||||||
<string name="lbl_cancel">Cancel</string>
|
<string name="lbl_cancel">Cancel</string>
|
||||||
|
@ -162,6 +163,7 @@
|
||||||
<string name="lbl_reset">Reset</string>
|
<string name="lbl_reset">Reset</string>
|
||||||
<!-- As in to add a new folder in the "Music folders" setting -->
|
<!-- As in to add a new folder in the "Music folders" setting -->
|
||||||
<string name="lbl_add">Add</string>
|
<string name="lbl_add">Add</string>
|
||||||
|
<string name="lbl_more">More</string>
|
||||||
|
|
||||||
<string name="lbl_path_style">Path style</string>
|
<string name="lbl_path_style">Path style</string>
|
||||||
<string name="lbl_path_style_absolute">Absolute</string>
|
<string name="lbl_path_style_absolute">Absolute</string>
|
||||||
|
@ -205,6 +207,10 @@
|
||||||
<string name="lng_supporters_promo">Donate to the project to get your name added here!</string>
|
<string name="lng_supporters_promo">Donate to the project to get your name added here!</string>
|
||||||
<!-- As in music library -->
|
<!-- As in music library -->
|
||||||
<string name="lng_search_library">Search your library…</string>
|
<string name="lng_search_library">Search your library…</string>
|
||||||
|
<string name="lng_tasker_start">
|
||||||
|
Starts Auxio using the previously saved state. If no saved state is available, all songs will be shuffled. Playback will start immediately.
|
||||||
|
\n\nWARNING: Be careful controlling this service, if you close it and then try to use it again, you will probably crash the app.
|
||||||
|
</string>
|
||||||
|
|
||||||
<!-- Settings namespace | Settings-related labels -->
|
<!-- Settings namespace | Settings-related labels -->
|
||||||
<eat-comment />
|
<eat-comment />
|
||||||
|
|
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