1
.github/FUNDING.yml
vendored
|
@ -1 +1,2 @@
|
|||
github: [OxygenCobalt]
|
||||
custom: ["https://paypal.me/oxycblt"]
|
||||
|
|
7
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
@ -57,6 +57,13 @@ body:
|
|||
placeholder: OnePlus 7T (LineageOS)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: sample-file
|
||||
attributes:
|
||||
label: Provide a sample file
|
||||
description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors may indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. *Upload a ZIP file containing the files or share a link to a file hosted on the cloud.*
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
|
|
6
.github/workflows/android.yml
vendored
|
@ -2,9 +2,9 @@ name: Android CI
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [ "dev" ]
|
||||
branches: []
|
||||
pull_request:
|
||||
branches: [ "dev" ]
|
||||
branches: []
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
@ -14,7 +14,7 @@ jobs:
|
|||
- name: Clone repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive
|
||||
run: git submodule update --init --recursive --remote
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
|
|
39
CHANGELOG.md
|
@ -1,6 +1,43 @@
|
|||
# Changelog
|
||||
|
||||
## dev
|
||||
## 3.5.0
|
||||
|
||||
#### What's New
|
||||
- Android Auto support
|
||||
- Full media browser implementation
|
||||
|
||||
#### What's Improved
|
||||
- Album covers are now loaded on a per-song basis
|
||||
- MP4 sort tags are now correctly interpreted
|
||||
- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
|
||||
- M3U paths are now interpreted both as relative and absolute regardless of the format
|
||||
- Added support for M3U paths starting with /storage/
|
||||
- Queue no longer scrolls as quickly when dragging items
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed repeat mode not restoring on startup
|
||||
- Fixed rewinding not occuring when skipping back at the beginning of the queue if
|
||||
rewind before skipping was turned off
|
||||
- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
|
||||
|
||||
#### What's Changed
|
||||
- For the time being, the media notification will not follow Album Covers or 1:1 Covers settings
|
||||
- Playback will close automatically after some time left idle
|
||||
|
||||
#### Dev/Meta
|
||||
- Use WEBP instead of PNG icons
|
||||
|
||||
#### dev -> release changes
|
||||
- Re-added ability to open app from clicking on notification
|
||||
- Removed tasker plugin
|
||||
- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
|
||||
- M3U paths are now interpreted both as relative and absolute regardless of the format
|
||||
- Added support for M3U paths starting with /storage/
|
||||
- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
|
||||
- Made album cover keying more efficient at the cost of resillience
|
||||
- Fixed android auto queue not respecting shuffle
|
||||
|
||||
## 3.4.3
|
||||
|
||||
#### What's Improved
|
||||
- Added back option disable ReplayGain for poorly tagged libraries
|
||||
|
|
|
@ -21,8 +21,8 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId namespace
|
||||
versionName "3.4.3"
|
||||
versionCode 44
|
||||
versionName "3.5.0"
|
||||
versionCode 46
|
||||
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
|
@ -101,7 +101,7 @@ dependencies {
|
|||
implementation "androidx.viewpager2:viewpager2:1.0.0"
|
||||
|
||||
// Lifecycle
|
||||
def lifecycle_version = "2.6.2"
|
||||
def lifecycle_version = "2.7.0"
|
||||
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
|
@ -126,6 +126,7 @@ dependencies {
|
|||
// --- THIRD PARTY ---
|
||||
|
||||
// Exoplayer (Vendored)
|
||||
implementation project(":media-lib-session")
|
||||
implementation project(":media-lib-exoplayer")
|
||||
implementation project(":media-lib-decoder-ffmpeg")
|
||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
|
||||
|
@ -154,7 +155,7 @@ dependencies {
|
|||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
testImplementation "io.mockk:mockk:1.13.7"
|
||||
testImplementation "org.robolectric:robolectric:4.9"
|
||||
testImplementation "org.robolectric:robolectric:4.11"
|
||||
testImplementation 'androidx.test:core-ktx:1.5.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
|
|
|
@ -37,6 +37,12 @@
|
|||
android:enableOnBackInvokedCallback="true"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<meta-data android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
<meta-data
|
||||
android:name="androidx.car.app.TintableAttributionIcon"
|
||||
android:resource="@drawable/ic_auxio_24" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
@ -77,33 +83,28 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
Service handling querying the media database, extracting metadata, and constructing
|
||||
the music library.
|
||||
-->
|
||||
<service
|
||||
android:name=".music.system.IndexerService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="false"
|
||||
android:roundIcon="@mipmap/ic_launcher" />
|
||||
|
||||
<!--
|
||||
Service handling music playback, system components, and state saving.
|
||||
-->
|
||||
<service
|
||||
android:name=".playback.system.PlaybackService"
|
||||
android:name=".AuxioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="false"
|
||||
android:roundIcon="@mipmap/ic_launcher" />
|
||||
android:exported="true"
|
||||
android:roundIcon="@mipmap/ic_launcher">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!--
|
||||
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
|
||||
See the class for more info.
|
||||
-->
|
||||
<receiver
|
||||
android:name=".playback.system.MediaButtonReceiver"
|
||||
android:name=".playback.service.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
|
@ -133,5 +134,6 @@
|
|||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info" />
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
</manifest>
|
117
app/src/main/java/org/oxycblt/auxio/AuxioService.kt
Normal file
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* AuxioService.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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.service.IndexerServiceFragment
|
||||
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuxioService : MediaLibraryService(), ForegroundListener {
|
||||
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
|
||||
|
||||
@Inject lateinit var indexingFragment: IndexerServiceFragment
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
mediaSessionFragment.attach(this, this)
|
||||
indexingFragment.attach(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
handleIntent(intent)
|
||||
return super.onBind(intent)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// TODO: Start command occurring from a foreign service basically implies a detached
|
||||
// service, we might need more handling here.
|
||||
handleIntent(intent)
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
val nativeStart = intent?.getBooleanExtra(INTENT_KEY_NATIVE_START, false) ?: false
|
||||
if (!nativeStart) {
|
||||
// Some foreign code started us, no guarantees about foreground stability. Figure
|
||||
// out what to do.
|
||||
mediaSessionFragment.handleNonNativeStart()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
mediaSessionFragment.handleTaskRemoved()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
indexingFragment.release()
|
||||
mediaSessionFragment.release()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
|
||||
mediaSessionFragment.mediaSession
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
updateForeground(ForegroundListener.Change.MEDIA_SESSION)
|
||||
}
|
||||
|
||||
override fun updateForeground(change: ForegroundListener.Change) {
|
||||
if (mediaSessionFragment.hasNotification()) {
|
||||
if (change == ForegroundListener.Change.MEDIA_SESSION) {
|
||||
mediaSessionFragment.createNotification {
|
||||
startForeground(it.notificationId, it.notification)
|
||||
}
|
||||
}
|
||||
// Nothing changed, but don't show anything music related since we can always
|
||||
// index during playback.
|
||||
} else {
|
||||
indexingFragment.createNotification {
|
||||
if (it != null) {
|
||||
startForeground(it.code, it.build())
|
||||
} else {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
|
||||
interface ForegroundListener {
|
||||
fun updateForeground(change: Change)
|
||||
|
||||
enum class Change {
|
||||
MEDIA_SESSION,
|
||||
INDEXER
|
||||
}
|
||||
}
|
|
@ -133,4 +133,7 @@ object IntegerTable {
|
|||
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
|
||||
/** PlaySong.ByItself */
|
||||
const val PLAY_SONG_BY_ITSELF = 0xA124
|
||||
const val PLAYER_COMMAND_INC_REPEAT_MODE = 0xA125
|
||||
const val PLAYER_COMMAND_TOGGLE_SHUFFLE = 0xA126
|
||||
const val PLAYER_COMMAND_EXIT = 0xA127
|
||||
}
|
||||
|
|
|
@ -29,10 +29,8 @@ import androidx.core.view.updatePadding
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.databinding.ActivityMainBinding
|
||||
import org.oxycblt.auxio.music.system.IndexerService
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
import org.oxycblt.auxio.playback.system.PlaybackService
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.isNight
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
@ -71,8 +69,9 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
startService(Intent(this, IndexerService::class.java))
|
||||
startService(Intent(this, PlaybackService::class.java))
|
||||
startService(
|
||||
Intent(this, AuxioService::class.java)
|
||||
.putExtra(AuxioService.INTENT_KEY_NATIVE_START, true))
|
||||
|
||||
if (!startIntentAction(intent)) {
|
||||
// No intent action to do, just restore the previously saved state.
|
||||
|
|
|
@ -94,7 +94,7 @@ constructor(
|
|||
target
|
||||
.onConfigRequest(
|
||||
ImageRequest.Builder(context)
|
||||
.data(listOf(song))
|
||||
.data(listOf(song.cover))
|
||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||
.size(Size.ORIGINAL))
|
||||
.target(
|
||||
|
|
|
@ -48,6 +48,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.image.extractor.Cover
|
||||
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
|
||||
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
|
||||
import org.oxycblt.auxio.music.Album
|
||||
|
@ -101,14 +102,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
private val indicatorMatrixSrc = RectF()
|
||||
private val indicatorMatrixDst = RectF()
|
||||
|
||||
private data class Cover(
|
||||
val songs: Collection<Song>,
|
||||
val desc: String,
|
||||
@DrawableRes val errorRes: Int
|
||||
)
|
||||
|
||||
private var currentCover: Cover? = null
|
||||
|
||||
init {
|
||||
// Obtain some StyledImageView attributes to use later when theming the custom view.
|
||||
@SuppressLint("CustomViewStyleable")
|
||||
|
@ -342,8 +335,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param song The [Song] to bind to the view.
|
||||
*/
|
||||
fun bind(song: Song) =
|
||||
bind(
|
||||
listOf(song),
|
||||
bindImpl(
|
||||
listOf(song.cover),
|
||||
context.getString(R.string.desc_album_cover, song.album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
@ -353,8 +346,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param album The [Album] to bind to the view.
|
||||
*/
|
||||
fun bind(album: Album) =
|
||||
bind(
|
||||
album.songs,
|
||||
bindImpl(
|
||||
album.cover.all,
|
||||
context.getString(R.string.desc_album_cover, album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
@ -364,8 +357,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param artist The [Artist] to bind to the view.
|
||||
*/
|
||||
fun bind(artist: Artist) =
|
||||
bind(
|
||||
artist.songs,
|
||||
bindImpl(
|
||||
artist.cover.all,
|
||||
context.getString(R.string.desc_artist_image, artist.name),
|
||||
R.drawable.ic_artist_24)
|
||||
|
||||
|
@ -375,8 +368,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param genre The [Genre] to bind to the view.
|
||||
*/
|
||||
fun bind(genre: Genre) =
|
||||
bind(
|
||||
genre.songs,
|
||||
bindImpl(
|
||||
genre.cover.all,
|
||||
context.getString(R.string.desc_genre_image, genre.name),
|
||||
R.drawable.ic_genre_24)
|
||||
|
||||
|
@ -386,8 +379,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param playlist the [Playlist] to bind.
|
||||
*/
|
||||
fun bind(playlist: Playlist) =
|
||||
bind(
|
||||
playlist.songs,
|
||||
bindImpl(
|
||||
playlist.cover?.all ?: emptyList(),
|
||||
context.getString(R.string.desc_playlist_image, playlist.name),
|
||||
R.drawable.ic_playlist_24)
|
||||
|
||||
|
@ -398,10 +391,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param desc The content description to describe the bound data.
|
||||
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
|
||||
*/
|
||||
fun bind(songs: Collection<Song>, desc: String, @DrawableRes errorRes: Int) {
|
||||
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
|
||||
bindImpl(Cover.order(songs), desc, errorRes)
|
||||
|
||||
private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) {
|
||||
val request =
|
||||
ImageRequest.Builder(context)
|
||||
.data(songs)
|
||||
.data(covers)
|
||||
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
|
||||
.target(image)
|
||||
|
||||
|
@ -417,7 +413,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
CoilUtils.dispose(image)
|
||||
imageLoader.enqueue(request.build())
|
||||
contentDescription = desc
|
||||
currentCover = Cover(songs, desc, errorRes)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -24,25 +24,23 @@ import coil.key.Keyer
|
|||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||
Keyer<Collection<Song>> {
|
||||
override fun key(data: Collection<Song>, options: Options) =
|
||||
"${coverExtractor.computeCoverOrdering(data).hashCode()}"
|
||||
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
|
||||
override fun key(data: Collection<Cover>, options: Options) =
|
||||
"${data.map { it.key }.hashCode()}"
|
||||
}
|
||||
|
||||
class SongCoverFetcher
|
||||
class CoverFetcher
|
||||
private constructor(
|
||||
private val songs: Collection<Song>,
|
||||
private val covers: Collection<Cover>,
|
||||
private val size: Size,
|
||||
private val coverExtractor: CoverExtractor,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch() = coverExtractor.extract(songs, size)
|
||||
override suspend fun fetch() = coverExtractor.extract(covers, size)
|
||||
|
||||
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||
Fetcher.Factory<Collection<Song>> {
|
||||
override fun create(data: Collection<Song>, options: Options, imageLoader: ImageLoader) =
|
||||
SongCoverFetcher(data, options.size, coverExtractor)
|
||||
Fetcher.Factory<Collection<Cover>> {
|
||||
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
|
||||
CoverFetcher(data, options.size, coverExtractor)
|
||||
}
|
||||
}
|
||||
|
|
66
app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt
Normal file
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* Cover.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.image.extractor
|
||||
|
||||
import android.net.Uri
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
sealed interface Cover {
|
||||
val key: String
|
||||
val mediaStoreCoverUri: Uri
|
||||
|
||||
/**
|
||||
* The song has an embedded cover art we support, so we can operate with it on a per-song basis.
|
||||
*/
|
||||
data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
|
||||
Cover {
|
||||
override val mediaStoreCoverUri = songCoverUri
|
||||
override val key = perceptualHash
|
||||
}
|
||||
|
||||
/**
|
||||
* We couldn't find any embedded cover art ourselves, but the android system might have some
|
||||
* through a cover.jpg file or something similar.
|
||||
*/
|
||||
data class External(val albumCoverUri: Uri) : Cover {
|
||||
override val mediaStoreCoverUri = albumCoverUri
|
||||
override val key = albumCoverUri.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
|
||||
|
||||
fun order(songs: Collection<Song>) =
|
||||
FALLBACK_SORT.songs(songs)
|
||||
.map { it.cover }
|
||||
.groupBy { it.key }
|
||||
.entries
|
||||
.sortedByDescending { it.value.size }
|
||||
.map { it.value.first() }
|
||||
}
|
||||
}
|
||||
|
||||
data class ParentCover(val single: Cover, val all: List<Cover>) {
|
||||
companion object {
|
||||
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
|
||||
|
||||
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ import android.util.Size as AndroidSize
|
|||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Metadata
|
||||
import androidx.media3.exoplayer.MetadataRetriever
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.extractor.metadata.flac.PictureFrame
|
||||
|
@ -50,8 +51,6 @@ import okio.buffer
|
|||
import okio.source
|
||||
import org.oxycblt.auxio.image.CoverMode
|
||||
import org.oxycblt.auxio.image.ImageSettings
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
|
@ -70,17 +69,16 @@ constructor(
|
|||
/**
|
||||
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
|
||||
*
|
||||
* @param songs The [Song]s to load.
|
||||
* @param covers The [Cover]s to load.
|
||||
* @param size The [Size] of the image to load.
|
||||
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
|
||||
* will be returned of a mosaic composed of four album covers ordered by
|
||||
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
|
||||
*/
|
||||
suspend fun extract(songs: Collection<Song>, size: Size): FetchResult? {
|
||||
val albums = computeCoverOrdering(songs)
|
||||
suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
|
||||
val streams = mutableListOf<InputStream>()
|
||||
for (album in albums) {
|
||||
openCoverInputStream(album)?.let(streams::add)
|
||||
for (cover in covers) {
|
||||
openCoverInputStream(cover)?.let(streams::add)
|
||||
// We don't immediately check for mosaic feasibility from album count alone, as that
|
||||
// does not factor in InputStreams failing to load. Instead, only check once we
|
||||
// definitely have image data to use.
|
||||
|
@ -108,71 +106,7 @@ constructor(
|
|||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an [Album] list representing the order that album covers would be used in [extract].
|
||||
*
|
||||
* @param songs A hypothetical list of [Song]s that would be used in [extract].
|
||||
* @return A list of [Album]s first ordered by the "representation" within the [Song]s, and then
|
||||
* by their names. "Representation" is defined by how many [Song]s were found to be linked to
|
||||
* the given [Album] in the given [Song] list.
|
||||
*/
|
||||
fun computeCoverOrdering(songs: Collection<Song>): List<Album> {
|
||||
// TODO: Start short-circuiting in more places
|
||||
if (songs.isEmpty()) return listOf()
|
||||
if (songs.size == 1) return listOf(songs.first().album)
|
||||
|
||||
val sortedMap =
|
||||
sortedMapOf<Album, Int>(Sort.Mode.ByName.getAlbumComparator(Sort.Direction.ASCENDING))
|
||||
for (song in songs) {
|
||||
sortedMap[song.album] = (sortedMap[song.album] ?: 0) + 1
|
||||
}
|
||||
return sortedMap.keys.sortedByDescending { sortedMap[it] }
|
||||
}
|
||||
|
||||
private suspend fun openCoverInputStream(album: Album) =
|
||||
try {
|
||||
when (imageSettings.coverMode) {
|
||||
CoverMode.OFF -> null
|
||||
CoverMode.MEDIA_STORE -> extractMediaStoreCover(album)
|
||||
CoverMode.QUALITY -> extractQualityCover(album)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to extract album cover due to an error: $e")
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun extractQualityCover(album: Album) =
|
||||
extractAospMetadataCover(album)
|
||||
?: extractExoplayerCover(album) ?: extractMediaStoreCover(album)
|
||||
|
||||
private fun extractAospMetadataCover(album: Album): InputStream? =
|
||||
MediaMetadataRetriever().run {
|
||||
// This call is time-consuming but it also doesn't seem to hold up the main thread,
|
||||
// so it's probably fine not to wrap it.rmt
|
||||
setDataSource(context, album.coverUri.song)
|
||||
|
||||
// Get the embedded picture from MediaMetadataRetriever, which will return a full
|
||||
// ByteArray of the cover without any compression artifacts.
|
||||
// If its null [i.e there is no embedded cover], than just ignore it and move on
|
||||
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
|
||||
}
|
||||
|
||||
private suspend fun extractExoplayerCover(album: Album): InputStream? {
|
||||
val tracks =
|
||||
MetadataRetriever.retrieveMetadata(
|
||||
mediaSourceFactory, MediaItem.fromUri(album.coverUri.song))
|
||||
.asDeferred()
|
||||
.await()
|
||||
|
||||
// The metadata extraction process of ExoPlayer results in a dump of all metadata
|
||||
// it found, which must be iterated through.
|
||||
val metadata = tracks[0].getFormat(0).metadata
|
||||
|
||||
if (metadata == null || metadata.length() == 0) {
|
||||
// No (parsable) metadata. This is also expected.
|
||||
return null
|
||||
}
|
||||
|
||||
fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
|
||||
var stream: ByteArrayInputStream? = null
|
||||
|
||||
for (i in 0 until metadata.length()) {
|
||||
|
@ -204,10 +138,62 @@ constructor(
|
|||
return stream
|
||||
}
|
||||
|
||||
private suspend fun extractMediaStoreCover(album: Album) =
|
||||
private suspend fun openCoverInputStream(cover: Cover) =
|
||||
try {
|
||||
when (cover) {
|
||||
is Cover.Embedded ->
|
||||
when (imageSettings.coverMode) {
|
||||
CoverMode.OFF -> null
|
||||
CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
|
||||
CoverMode.QUALITY -> extractQualityCover(cover)
|
||||
}
|
||||
is Cover.External -> {
|
||||
extractMediaStoreCover(cover)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to extract album cover due to an error: $e")
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun extractQualityCover(cover: Cover.Embedded) =
|
||||
extractExoplayerCover(cover)
|
||||
?: extractAospMetadataCover(cover) ?: extractMediaStoreCover(cover)
|
||||
|
||||
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
|
||||
MediaMetadataRetriever().run {
|
||||
// This call is time-consuming but it also doesn't seem to hold up the main thread,
|
||||
// so it's probably fine not to wrap it.rmt
|
||||
setDataSource(context, cover.songUri)
|
||||
|
||||
// Get the embedded picture from MediaMetadataRetriever, which will return a full
|
||||
// ByteArray of the cover without any compression artifacts.
|
||||
// If its null [i.e there is no embedded cover], than just ignore it and move on
|
||||
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
|
||||
}
|
||||
|
||||
private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? {
|
||||
val tracks =
|
||||
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
|
||||
.asDeferred()
|
||||
.await()
|
||||
|
||||
// The metadata extraction process of ExoPlayer results in a dump of all metadata
|
||||
// it found, which must be iterated through.
|
||||
val metadata = tracks[0].getFormat(0).metadata
|
||||
|
||||
if (metadata == null || metadata.length() == 0) {
|
||||
// No (parsable) metadata. This is also expected.
|
||||
return null
|
||||
}
|
||||
|
||||
return findCoverDataInMetadata(metadata)
|
||||
}
|
||||
|
||||
private suspend fun extractMediaStoreCover(cover: Cover) =
|
||||
// Eliminate any chance that this blocking call might mess up the loading process
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(album.coverUri.mediaStore)
|
||||
context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
|
||||
}
|
||||
|
||||
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
|
||||
|
|
60
app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* DHash.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.image.extractor
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.Paint
|
||||
import java.math.BigInteger
|
||||
|
||||
@Suppress("UNUSED")
|
||||
fun Bitmap.dHash(hashSize: Int = 16): String {
|
||||
// Step 1: Resize the bitmap to a fixed size
|
||||
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
|
||||
|
||||
// Step 2: Convert the bitmap to grayscale
|
||||
val grayBitmap =
|
||||
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(grayBitmap)
|
||||
val paint = Paint()
|
||||
val colorMatrix = ColorMatrix()
|
||||
colorMatrix.setSaturation(0f)
|
||||
val filter = ColorMatrixColorFilter(colorMatrix)
|
||||
paint.colorFilter = filter
|
||||
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
|
||||
|
||||
// Step 3: Compute the difference between adjacent pixels
|
||||
var hash = BigInteger.valueOf(0)
|
||||
val one = BigInteger.valueOf(1)
|
||||
for (y in 0 until hashSize) {
|
||||
for (x in 0 until hashSize) {
|
||||
val pixel1 = grayBitmap.getPixel(x, y)
|
||||
val pixel2 = grayBitmap.getPixel(x + 1, y)
|
||||
val diff = Color.red(pixel1) - Color.red(pixel2)
|
||||
if (diff > 0) {
|
||||
hash = hash.or(one.shl(y * hashSize + x))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hash.toString(16)
|
||||
}
|
|
@ -35,14 +35,14 @@ class ExtractorModule {
|
|||
@Provides
|
||||
fun imageLoader(
|
||||
@ApplicationContext context: Context,
|
||||
songKeyer: SongKeyer,
|
||||
songFactory: SongCoverFetcher.Factory
|
||||
keyer: CoverKeyer,
|
||||
factory: CoverFetcher.Factory
|
||||
) =
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
// Add fetchers for Music components to make them usable with ImageRequest
|
||||
add(songKeyer)
|
||||
add(songFactory)
|
||||
add(keyer)
|
||||
add(factory)
|
||||
}
|
||||
// Use our own crossfade with error drawable support
|
||||
.transitionFactory(ErrorCrossfadeTransitionFactory())
|
||||
|
|
|
@ -25,6 +25,10 @@ import android.view.animation.AccelerateDecelerateInterpolator
|
|||
import androidx.core.view.isInvisible
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sign
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
|
@ -53,6 +57,27 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
0
|
||||
}
|
||||
|
||||
override fun interpolateOutOfBoundsScroll(
|
||||
recyclerView: RecyclerView,
|
||||
viewSize: Int,
|
||||
viewSizeOutOfBounds: Int,
|
||||
totalSize: Int,
|
||||
msSinceStartScroll: Long
|
||||
): Int {
|
||||
// Clamp the scroll speed to prevent the lists from freaking out
|
||||
// Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe
|
||||
val standardSpeed =
|
||||
super.interpolateOutOfBoundsScroll(
|
||||
recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll)
|
||||
|
||||
val clampedAbsVelocity =
|
||||
max(
|
||||
MINIMUM_INITIAL_DRAG_VELOCITY,
|
||||
min(abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY))
|
||||
|
||||
return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt()
|
||||
}
|
||||
|
||||
final override fun onChildDraw(
|
||||
c: Canvas,
|
||||
recyclerView: RecyclerView,
|
||||
|
@ -150,4 +175,9 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
/** The drawable of the [body] background that can be elevated. */
|
||||
val background: Drawable
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MINIMUM_INITIAL_DRAG_VELOCITY = 10
|
||||
const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,8 @@ import java.util.UUID
|
|||
import kotlin.math.max
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.oxycblt.auxio.image.extractor.CoverUri
|
||||
import org.oxycblt.auxio.image.extractor.Cover
|
||||
import org.oxycblt.auxio.image.extractor.ParentCover
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.fs.MimeType
|
||||
import org.oxycblt.auxio.music.fs.Path
|
||||
|
@ -246,6 +247,8 @@ interface Song : Music {
|
|||
* audio file in a way that is scoped-storage-safe.
|
||||
*/
|
||||
val uri: Uri
|
||||
/** Useful information to quickly obtain the album cover. */
|
||||
val cover: Cover
|
||||
/**
|
||||
* The [Path] to this audio file. This is only intended for display, [uri] should be favored
|
||||
* instead for accessing the audio file.
|
||||
|
@ -293,11 +296,8 @@ interface Album : MusicParent {
|
|||
* [ReleaseType.Album].
|
||||
*/
|
||||
val releaseType: ReleaseType
|
||||
/**
|
||||
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the
|
||||
* cost of image quality.
|
||||
*/
|
||||
val coverUri: CoverUri
|
||||
/** Cover information from the template song used for the album. */
|
||||
val cover: ParentCover
|
||||
/** The duration of all songs in the album, in milliseconds. */
|
||||
val durationMs: Long
|
||||
/** The earliest date a song in this album was added, as a unix epoch timestamp. */
|
||||
|
@ -326,6 +326,8 @@ interface Artist : MusicParent {
|
|||
* songs.
|
||||
*/
|
||||
val durationMs: Long?
|
||||
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
||||
val cover: ParentCover
|
||||
/** The [Genre]s of this artist. */
|
||||
val genres: List<Genre>
|
||||
}
|
||||
|
@ -340,6 +342,8 @@ interface Genre : MusicParent {
|
|||
val artists: Collection<Artist>
|
||||
/** The total duration of the songs in this genre, in milliseconds. */
|
||||
val durationMs: Long
|
||||
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
||||
val cover: ParentCover
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -352,6 +356,8 @@ interface Playlist : MusicParent {
|
|||
override val songs: List<Song>
|
||||
/** The total duration of the songs in this genre, in milliseconds. */
|
||||
val durationMs: Long
|
||||
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
||||
val cover: ParentCover?
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -206,7 +206,7 @@ interface MusicRepository {
|
|||
/** A persistent worker that can load music in the background. */
|
||||
interface IndexingWorker {
|
||||
/** A [Context] required to read device storage */
|
||||
val context: Context
|
||||
val workerContext: Context
|
||||
|
||||
/** The [CoroutineScope] to perform coroutine music loading work on. */
|
||||
val scope: CoroutineScope
|
||||
|
@ -343,7 +343,7 @@ constructor(
|
|||
}
|
||||
|
||||
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
|
||||
worker.scope.launch { indexWrapper(worker.context, this, withCache) }
|
||||
worker.scope.launch { indexWrapper(worker.workerContext, this, withCache) }
|
||||
|
||||
private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
|
||||
try {
|
||||
|
|
|
@ -164,7 +164,10 @@ constructor(
|
|||
}
|
||||
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
|
||||
val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath)
|
||||
val songs =
|
||||
importedPlaylist.paths.mapNotNull {
|
||||
it.firstNotNullOfOrNull(deviceLibrary::findSongByPath)
|
||||
}
|
||||
|
||||
if (songs.isEmpty()) {
|
||||
logE("No songs found")
|
||||
|
|
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date
|
|||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
||||
import org.oxycblt.auxio.music.metadata.splitEscaped
|
||||
|
||||
@Database(entities = [CachedSong::class], version = 42, exportSchema = false)
|
||||
@Database(entities = [CachedSong::class], version = 46, exportSchema = false)
|
||||
abstract class CacheDatabase : RoomDatabase() {
|
||||
abstract fun cachedSongsDao(): CachedSongsDao
|
||||
}
|
||||
|
@ -80,6 +80,8 @@ data class CachedSong(
|
|||
var subtitle: String? = null,
|
||||
/** @see RawSong.date */
|
||||
var date: Date? = null,
|
||||
/** @see RawSong.coverPerceptualHash */
|
||||
var coverPerceptualHash: String? = null,
|
||||
/** @see RawSong.albumMusicBrainzId */
|
||||
var albumMusicBrainzId: String? = null,
|
||||
/** @see RawSong.albumName */
|
||||
|
@ -119,6 +121,8 @@ data class CachedSong(
|
|||
rawSong.subtitle = subtitle
|
||||
rawSong.date = date
|
||||
|
||||
rawSong.coverPerceptualHash = coverPerceptualHash
|
||||
|
||||
rawSong.albumMusicBrainzId = albumMusicBrainzId
|
||||
rawSong.albumName = albumName
|
||||
rawSong.albumSortName = albumSortName
|
||||
|
@ -167,6 +171,7 @@ data class CachedSong(
|
|||
disc = rawSong.disc,
|
||||
subtitle = rawSong.subtitle,
|
||||
date = rawSong.date,
|
||||
coverPerceptualHash = rawSong.coverPerceptualHash,
|
||||
albumMusicBrainzId = rawSong.albumMusicBrainzId,
|
||||
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
|
||||
albumSortName = rawSong.albumSortName,
|
||||
|
|
|
@ -19,7 +19,8 @@
|
|||
package org.oxycblt.auxio.music.device
|
||||
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.image.extractor.CoverUri
|
||||
import org.oxycblt.auxio.image.extractor.Cover
|
||||
import org.oxycblt.auxio.image.extractor.ParentCover
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
|
@ -28,8 +29,9 @@ import org.oxycblt.auxio.music.Music
|
|||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.fs.MimeType
|
||||
import org.oxycblt.auxio.music.fs.toAlbumCoverUri
|
||||
import org.oxycblt.auxio.music.fs.toAudioUri
|
||||
import org.oxycblt.auxio.music.fs.toCoverUri
|
||||
import org.oxycblt.auxio.music.fs.toSongCoverUri
|
||||
import org.oxycblt.auxio.music.info.Date
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.music.info.Name
|
||||
|
@ -112,6 +114,20 @@ class SongImpl(
|
|||
override val genres: List<Genre>
|
||||
get() = _genres
|
||||
|
||||
override val cover =
|
||||
rawSong.coverPerceptualHash?.let {
|
||||
// We were able to confirm that the song had a parsable cover and can be used on
|
||||
// a per-song basis. Otherwise, just fall back to a per-album cover instead, as
|
||||
// it implies either a cover.jpg pattern is used (likely) or ExoPlayer does not
|
||||
// support the cover metadata of a given spec (unlikely).
|
||||
Cover.Embedded(
|
||||
requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }
|
||||
.toSongCoverUri(),
|
||||
uri,
|
||||
it)
|
||||
}
|
||||
?: Cover.External(requireNotNull(rawSong.albumMediaStoreId).toAlbumCoverUri())
|
||||
|
||||
/**
|
||||
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
|
||||
* [Album].
|
||||
|
@ -291,9 +307,9 @@ class AlbumImpl(
|
|||
override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
|
||||
override val dates: Date.Range?
|
||||
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
|
||||
override val coverUri = CoverUri(rawAlbum.mediaStoreId.toCoverUri(), grouping.raw.src.uri)
|
||||
override val durationMs: Long
|
||||
override val dateAdded: Long
|
||||
override val cover: ParentCover
|
||||
|
||||
private val _artists = mutableListOf<ArtistImpl>()
|
||||
override val artists: List<Artist>
|
||||
|
@ -337,6 +353,8 @@ class AlbumImpl(
|
|||
durationMs = totalDuration
|
||||
dateAdded = earliestDateAdded
|
||||
|
||||
cover = ParentCover.from(grouping.raw.src.cover, songs)
|
||||
|
||||
hashCode = 31 * hashCode + rawAlbum.hashCode()
|
||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
||||
hashCode = 31 * hashCode + songs.hashCode()
|
||||
|
@ -419,6 +437,7 @@ class ArtistImpl(
|
|||
override val explicitAlbums: Set<Album>
|
||||
override val implicitAlbums: Set<Album>
|
||||
override val durationMs: Long?
|
||||
override val cover: ParentCover
|
||||
|
||||
override lateinit var genres: List<Genre>
|
||||
|
||||
|
@ -451,6 +470,14 @@ class ArtistImpl(
|
|||
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
|
||||
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
|
||||
|
||||
val singleCover =
|
||||
when (val src = grouping.raw.src) {
|
||||
is SongImpl -> src.cover
|
||||
is AlbumImpl -> src.cover.single
|
||||
else -> error("Unexpected input source $src in $name ${src::class.simpleName}")
|
||||
}
|
||||
cover = ParentCover.from(singleCover, songs)
|
||||
|
||||
hashCode = 31 * hashCode + rawArtist.hashCode()
|
||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
||||
hashCode = 31 * hashCode + songs.hashCode()
|
||||
|
@ -528,6 +555,7 @@ class GenreImpl(
|
|||
override val songs: Set<Song>
|
||||
override val artists: Set<Artist>
|
||||
override val durationMs: Long
|
||||
override val cover: ParentCover
|
||||
|
||||
private var hashCode = uid.hashCode()
|
||||
|
||||
|
@ -545,6 +573,8 @@ class GenreImpl(
|
|||
artists = distinctArtists
|
||||
durationMs = totalDuration
|
||||
|
||||
cover = ParentCover.from(grouping.raw.src.cover, songs)
|
||||
|
||||
hashCode = 31 * hashCode + rawGenre.hashCode()
|
||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
||||
hashCode = 31 * hashCode + songs.hashCode()
|
||||
|
|
|
@ -67,6 +67,8 @@ data class RawSong(
|
|||
var subtitle: String? = null,
|
||||
/** @see Song.date */
|
||||
var date: Date? = null,
|
||||
/** @see Song.cover */
|
||||
var coverPerceptualHash: String? = null,
|
||||
/** @see RawAlbum.mediaStoreId */
|
||||
var albumMediaStoreId: Long? = null,
|
||||
/** @see RawAlbum.musicBrainzId */
|
||||
|
|
|
@ -76,7 +76,9 @@ data class ExportConfig(val absolute: Boolean, val windowsPaths: Boolean)
|
|||
* @see ExternalPlaylistManager
|
||||
* @see M3U
|
||||
*/
|
||||
data class ImportedPlaylist(val name: String?, val paths: List<Path>)
|
||||
data class ImportedPlaylist(val name: String?, val paths: List<PossiblePaths>)
|
||||
|
||||
typealias PossiblePaths = List<Path>
|
||||
|
||||
class ExternalPlaylistManagerImpl
|
||||
@Inject
|
||||
|
|
|
@ -29,9 +29,12 @@ import javax.inject.Inject
|
|||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.fs.Components
|
||||
import org.oxycblt.auxio.music.fs.Path
|
||||
import org.oxycblt.auxio.music.fs.Volume
|
||||
import org.oxycblt.auxio.music.fs.VolumeManager
|
||||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* Minimal M3U file format implementation.
|
||||
|
@ -72,10 +75,16 @@ interface M3U {
|
|||
}
|
||||
}
|
||||
|
||||
class M3UImpl @Inject constructor(@ApplicationContext private val context: Context) : M3U {
|
||||
class M3UImpl
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val volumeManager: VolumeManager
|
||||
) : M3U {
|
||||
override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
|
||||
val volumes = volumeManager.getVolumes()
|
||||
val reader = BufferedReader(InputStreamReader(stream))
|
||||
val paths = mutableListOf<Path>()
|
||||
val paths = mutableListOf<PossiblePaths>()
|
||||
var name: String? = null
|
||||
|
||||
consumeFile@ while (true) {
|
||||
|
@ -112,39 +121,13 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte
|
|||
}
|
||||
|
||||
// There is basically no formal specification of file paths in M3U, and it differs
|
||||
// based on the US that generated it. These are the paths though that I assume most
|
||||
// programs will generate.
|
||||
val components =
|
||||
when {
|
||||
path.startsWith('/') -> {
|
||||
// Unix absolute path. Note that we still assume this absolute path is in
|
||||
// the same volume as the M3U file. There's no sane way to map the volume
|
||||
// to the phone's volumes, so this is the only thing we can do.
|
||||
Components.parseUnix(path)
|
||||
}
|
||||
path.startsWith("./") -> {
|
||||
// Unix relative path, resolve it
|
||||
Components.parseUnix(path).absoluteTo(workingDirectory.components)
|
||||
}
|
||||
path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> {
|
||||
// Windows absolute path, we should get rid of the volume prefix, but
|
||||
// otherwise
|
||||
// the rest should be fine. Again, we have to disregard what the volume
|
||||
// actually
|
||||
// is since there's no sane way to map it to the phone's volumes.
|
||||
Components.parseWindows(path.substring(2))
|
||||
}
|
||||
path.startsWith(".\\") -> {
|
||||
// Windows relative path, we need to remove the .\\ prefix
|
||||
Components.parseWindows(path).absoluteTo(workingDirectory.components)
|
||||
}
|
||||
else -> {
|
||||
// No clue, parse by all separators and assume it's relative.
|
||||
Components.parseAny(path).absoluteTo(workingDirectory.components)
|
||||
}
|
||||
}
|
||||
// based on the programs that generated it. I more or less have to consider any possible
|
||||
// interpretation as valid.
|
||||
val interpretations = interpretPath(path)
|
||||
val possibilities =
|
||||
interpretations.flatMap { expandInterpretation(it, workingDirectory, volumes) }
|
||||
|
||||
paths.add(Path(workingDirectory.volume, components))
|
||||
paths.add(possibilities)
|
||||
}
|
||||
|
||||
return if (paths.isNotEmpty()) {
|
||||
|
@ -155,6 +138,44 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte
|
|||
}
|
||||
}
|
||||
|
||||
private data class InterpretedPath(val components: Components, val likelyAbsolute: Boolean)
|
||||
|
||||
private fun interpretPath(path: String): List<InterpretedPath> =
|
||||
when {
|
||||
path.startsWith('/') -> listOf(InterpretedPath(Components.parseUnix(path), true))
|
||||
path.startsWith("./") -> listOf(InterpretedPath(Components.parseUnix(path), false))
|
||||
path.matches(WINDOWS_VOLUME_PREFIX_REGEX) ->
|
||||
listOf(InterpretedPath(Components.parseWindows(path.substring(2)), true))
|
||||
path.startsWith("\\") -> listOf(InterpretedPath(Components.parseWindows(path), true))
|
||||
path.startsWith(".\\") -> listOf(InterpretedPath(Components.parseWindows(path), false))
|
||||
else ->
|
||||
listOf(
|
||||
InterpretedPath(Components.parseUnix(path), false),
|
||||
InterpretedPath(Components.parseWindows(path), true))
|
||||
}
|
||||
|
||||
private fun expandInterpretation(
|
||||
path: InterpretedPath,
|
||||
workingDirectory: Path,
|
||||
volumes: List<Volume>
|
||||
): List<Path> {
|
||||
val absoluteInterpretation = Path(workingDirectory.volume, path.components)
|
||||
val relativeInterpretation =
|
||||
Path(workingDirectory.volume, path.components.absoluteTo(workingDirectory.components))
|
||||
val volumeExactMatch = volumes.find { it.components?.contains(path.components) == true }
|
||||
val volumeInterpretation =
|
||||
volumeExactMatch?.let {
|
||||
val components =
|
||||
unlikelyToBeNull(volumeExactMatch.components).containing(path.components)
|
||||
Path(volumeExactMatch, components)
|
||||
}
|
||||
return if (path.likelyAbsolute) {
|
||||
listOfNotNull(volumeInterpretation, absoluteInterpretation, relativeInterpretation)
|
||||
} else {
|
||||
listOfNotNull(relativeInterpretation, volumeInterpretation, absoluteInterpretation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(
|
||||
playlist: Playlist,
|
||||
outputStream: OutputStream,
|
||||
|
|
|
@ -158,6 +158,8 @@ value class Components private constructor(val components: List<String>) {
|
|||
return components == other.components.take(components.size)
|
||||
}
|
||||
|
||||
fun containing(other: Components) = Components(other.components.drop(components.size))
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parses a path string into a [Components] instance by the unix path separator (/).
|
||||
|
|
|
@ -102,7 +102,14 @@ fun Long.toAudioUri() =
|
|||
* @return An external storage image [Uri]. May not exist.
|
||||
* @see ContentUris.withAppendedId
|
||||
*/
|
||||
fun Long.toCoverUri() = ContentUris.withAppendedId(externalCoversUri, this)
|
||||
fun Long.toSongCoverUri(): Uri =
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().run {
|
||||
appendPath(this@toSongCoverUri.toString())
|
||||
appendPath("albumart")
|
||||
build()
|
||||
}
|
||||
|
||||
fun Long.toAlbumCoverUri(): Uri = ContentUris.withAppendedId(externalCoversUri, this)
|
||||
|
||||
// --- STORAGEMANAGER UTILITIES ---
|
||||
// Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles
|
||||
|
|
|
@ -37,9 +37,9 @@ import org.oxycblt.auxio.util.positiveOrNull
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Date private constructor(private val tokens: List<Int>) : Comparable<Date> {
|
||||
private val year = tokens[0]
|
||||
private val month = tokens.getOrNull(1)
|
||||
private val day = tokens.getOrNull(2)
|
||||
val year = tokens[0]
|
||||
val month = tokens.getOrNull(1)
|
||||
val day = tokens.getOrNull(2)
|
||||
private val hour = tokens.getOrNull(3)
|
||||
private val minute = tokens.getOrNull(4)
|
||||
private val second = tokens.getOrNull(5)
|
||||
|
|
|
@ -25,6 +25,8 @@ import androidx.media3.exoplayer.source.MediaSource
|
|||
import androidx.media3.exoplayer.source.TrackGroupArray
|
||||
import java.util.concurrent.Future
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.image.extractor.CoverExtractor
|
||||
import org.oxycblt.auxio.music.device.RawSong
|
||||
import org.oxycblt.auxio.music.fs.toAudioUri
|
||||
import org.oxycblt.auxio.music.info.Date
|
||||
|
@ -60,7 +62,10 @@ interface TagWorker {
|
|||
|
||||
class TagWorkerFactoryImpl
|
||||
@Inject
|
||||
constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory {
|
||||
constructor(
|
||||
private val mediaSourceFactory: MediaSource.Factory,
|
||||
private val coverExtractor: CoverExtractor
|
||||
) : TagWorker.Factory {
|
||||
override fun create(rawSong: RawSong): TagWorker =
|
||||
// Note that we do not leverage future callbacks. This is because errors in the
|
||||
// (highly fallible) extraction process will not bubble up to Indexer when a
|
||||
|
@ -70,12 +75,14 @@ constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Fac
|
|||
MetadataRetriever.retrieveMetadata(
|
||||
mediaSourceFactory,
|
||||
MediaItem.fromUri(
|
||||
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())))
|
||||
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())),
|
||||
coverExtractor)
|
||||
}
|
||||
|
||||
private class TagWorkerImpl(
|
||||
private val rawSong: RawSong,
|
||||
private val future: Future<TrackGroupArray>
|
||||
private val future: Future<TrackGroupArray>,
|
||||
private val coverExtractor: CoverExtractor
|
||||
) : TagWorker {
|
||||
override fun poll(): RawSong? {
|
||||
if (!future.isDone) {
|
||||
|
@ -98,6 +105,25 @@ private class TagWorkerImpl(
|
|||
populateWithId3v2(textTags.id3v2)
|
||||
populateWithVorbis(textTags.vorbis)
|
||||
|
||||
coverExtractor.findCoverDataInMetadata(metadata)?.use {
|
||||
val available = it.available()
|
||||
val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong())
|
||||
it.skip(skip)
|
||||
val bytes = ByteArray(COVER_KEY_SAMPLE)
|
||||
it.read(bytes)
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class) val byteString = bytes.toHexString()
|
||||
|
||||
rawSong.coverPerceptualHash = byteString
|
||||
}
|
||||
|
||||
// OPTIONAL: Nicer cover art keying using an actual perceptual hash
|
||||
// Really bad idea if you have big cover arts. Okay idea if you have different
|
||||
// formats for the same cover art.
|
||||
// val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) }
|
||||
// rawSong.coverPerceptualHash = bitmap?.dHash()
|
||||
// bitmap?.recycle()
|
||||
|
||||
// OPUS base gain interpretation code: This is likely not needed, as the media player
|
||||
// should be using the base gain already. Uncomment if that's not the case.
|
||||
// if (format.sampleMimeType == MimeTypes.AUDIO_OPUS
|
||||
|
@ -127,7 +153,9 @@ private class TagWorkerImpl(
|
|||
|
||||
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
|
||||
// Song
|
||||
textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
|
||||
(textFrames["TXXX:musicbrainz release track id"]
|
||||
?: textFrames["TXXX:musicbrainz_releasetrackid"])
|
||||
?.let { rawSong.musicBrainzId = it.first() }
|
||||
textFrames["TIT2"]?.let { rawSong.name = it.first() }
|
||||
textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
|
||||
|
||||
|
@ -157,7 +185,9 @@ private class TagWorkerImpl(
|
|||
?.let { rawSong.date = it }
|
||||
|
||||
// Album
|
||||
textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() }
|
||||
(textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let {
|
||||
rawSong.albumMusicBrainzId = it.first()
|
||||
}
|
||||
textFrames["TALB"]?.let { rawSong.albumName = it.first() }
|
||||
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
|
||||
(textFrames["TXXX:musicbrainz album type"]
|
||||
|
@ -167,7 +197,9 @@ private class TagWorkerImpl(
|
|||
?.let { rawSong.releaseTypes = it }
|
||||
|
||||
// Artist
|
||||
textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it }
|
||||
(textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
|
||||
rawSong.artistMusicBrainzIds = it
|
||||
}
|
||||
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
|
||||
(textFrames["TXXX:artistssort"]
|
||||
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
|
||||
|
@ -175,9 +207,9 @@ private class TagWorkerImpl(
|
|||
?.let { rawSong.artistSortNames = it }
|
||||
|
||||
// Album artist
|
||||
textFrames["TXXX:musicbrainz album artist id"]?.let {
|
||||
rawSong.albumArtistMusicBrainzIds = it
|
||||
}
|
||||
(textFrames["TXXX:musicbrainz album artist id"]
|
||||
?: textFrames["TXXX:musicbrainz_albumartistid"])
|
||||
?.let { rawSong.albumArtistMusicBrainzIds = it }
|
||||
(textFrames["TXXX:albumartists"]
|
||||
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
|
||||
?: textFrames["TPE2"])
|
||||
|
@ -248,7 +280,9 @@ private class TagWorkerImpl(
|
|||
|
||||
private fun populateWithVorbis(comments: Map<String, List<String>>) {
|
||||
// Song
|
||||
comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
|
||||
(comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let {
|
||||
rawSong.musicBrainzId = it.first()
|
||||
}
|
||||
comments["title"]?.let { rawSong.name = it.first() }
|
||||
comments["titlesort"]?.let { rawSong.sortName = it.first() }
|
||||
|
||||
|
@ -277,20 +311,28 @@ private class TagWorkerImpl(
|
|||
?.let { rawSong.date = it }
|
||||
|
||||
// Album
|
||||
comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() }
|
||||
(comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let {
|
||||
rawSong.albumMusicBrainzId = it.first()
|
||||
}
|
||||
comments["album"]?.let { rawSong.albumName = it.first() }
|
||||
comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
|
||||
comments["releasetype"]?.let { rawSong.releaseTypes = it }
|
||||
(comments["releasetype"] ?: comments["musicbrainz album type"])?.let {
|
||||
rawSong.releaseTypes = it
|
||||
}
|
||||
|
||||
// Artist
|
||||
comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it }
|
||||
(comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let {
|
||||
rawSong.artistMusicBrainzIds = it
|
||||
}
|
||||
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
|
||||
(comments["artistssort"]
|
||||
?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"])
|
||||
?.let { rawSong.artistSortNames = it }
|
||||
|
||||
// Album artist
|
||||
comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it }
|
||||
(comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let {
|
||||
rawSong.albumArtistMusicBrainzIds = it
|
||||
}
|
||||
(comments["albumartists"]
|
||||
?: comments["album_artists"] ?: comments["album artists"]
|
||||
?: comments["albumartist"])
|
||||
|
@ -347,6 +389,8 @@ private class TagWorkerImpl(
|
|||
first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull()
|
||||
|
||||
private companion object {
|
||||
val COVER_KEY_SAMPLE = 32
|
||||
|
||||
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
|
||||
val COMPILATION_RELEASE_TYPES = listOf("compilation")
|
||||
|
||||
|
|
|
@ -16,27 +16,68 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.system
|
||||
package org.oxycblt.auxio.music.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.IndexingProgress
|
||||
import org.oxycblt.auxio.service.ForegroundServiceNotification
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
/**
|
||||
* A dynamic [ForegroundServiceNotification] that shows the current music loading state.
|
||||
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
|
||||
* signal a Service's ongoing foreground state.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class IndexerNotification(context: Context, info: ChannelInfo) :
|
||||
NotificationCompat.Builder(context, info.id) {
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
|
||||
init {
|
||||
// Set up the notification channel. Foreground notifications are non-substantial, and
|
||||
// thus make no sense to have lights, vibration, or lead to a notification badge.
|
||||
val channel =
|
||||
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(context.getString(info.nameRes))
|
||||
.setLightsEnabled(false)
|
||||
.setVibrationEnabled(false)
|
||||
.setShowBadge(false)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* The code used to identify this notification.
|
||||
*
|
||||
* @see NotificationManagerCompat.notify
|
||||
*/
|
||||
abstract val code: Int
|
||||
|
||||
/**
|
||||
* Reduced representation of a [NotificationChannelCompat].
|
||||
*
|
||||
* @param id The ID of the channel.
|
||||
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
|
||||
*/
|
||||
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
|
||||
}
|
||||
|
||||
/**
|
||||
* A dynamic [IndexerNotification] that shows the current music loading state.
|
||||
*
|
||||
* @param context [Context] required to create the notification.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class IndexingNotification(private val context: Context) :
|
||||
ForegroundServiceNotification(context, indexerChannel) {
|
||||
IndexerNotification(context, indexerChannel) {
|
||||
private var lastUpdateTime = -1L
|
||||
|
||||
init {
|
||||
|
@ -92,13 +133,12 @@ class IndexingNotification(private val context: Context) :
|
|||
}
|
||||
|
||||
/**
|
||||
* A static [ForegroundServiceNotification] that signals to the user that the app is currently
|
||||
* monitoring the music library for changes.
|
||||
* A static [IndexerNotification] that signals to the user that the app is currently monitoring the
|
||||
* music library for changes.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ObservingNotification(context: Context) :
|
||||
ForegroundServiceNotification(context, indexerChannel) {
|
||||
class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) {
|
||||
init {
|
||||
setSmallIcon(R.drawable.ic_indexer_24)
|
||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
|
@ -116,5 +156,5 @@ class ObservingNotification(context: Context) :
|
|||
|
||||
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
|
||||
private val indexerChannel =
|
||||
ForegroundServiceNotification.ChannelInfo(
|
||||
IndexerNotification.ChannelInfo(
|
||||
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)
|
|
@ -0,0 +1,184 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* IndexerServiceFragment.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.PowerManager
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.ForegroundListener
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
class IndexerServiceFragment
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext override val workerContext: Context,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val musicSettings: MusicSettings,
|
||||
private val contentObserver: SystemContentObserver,
|
||||
private val imageLoader: ImageLoader
|
||||
) :
|
||||
MusicRepository.IndexingWorker,
|
||||
MusicRepository.IndexingListener,
|
||||
MusicRepository.UpdateListener,
|
||||
MusicSettings.Listener {
|
||||
private val indexJob = Job()
|
||||
private val indexScope = CoroutineScope(indexJob + Dispatchers.IO)
|
||||
private var currentIndexJob: Job? = null
|
||||
private val indexingNotification = IndexingNotification(workerContext)
|
||||
private val observingNotification = ObservingNotification(workerContext)
|
||||
private var foregroundListener: ForegroundListener? = null
|
||||
private val wakeLock =
|
||||
workerContext
|
||||
.getSystemServiceCompat(PowerManager::class)
|
||||
.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent")
|
||||
|
||||
fun attach(listener: ForegroundListener) {
|
||||
foregroundListener = listener
|
||||
musicSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
musicRepository.addIndexingListener(this)
|
||||
musicRepository.registerWorker(this)
|
||||
contentObserver.attach()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
contentObserver.release()
|
||||
musicSettings.registerListener(this)
|
||||
musicRepository.addIndexingListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
musicRepository.removeIndexingListener(this)
|
||||
foregroundListener = null
|
||||
}
|
||||
|
||||
fun createNotification(post: (IndexerNotification?) -> Unit) {
|
||||
val state = musicRepository.indexingState
|
||||
if (state is IndexingState.Indexing) {
|
||||
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
||||
// 1. Newer versions of Android have become more and more restrictive regarding
|
||||
// how a foreground service starts. Thus, it's best to go foreground now so that
|
||||
// we can go foreground later.
|
||||
// 2. If a non-foreground service is killed, the app will probably still be alive,
|
||||
// and thus the music library will not be updated at all.
|
||||
val changed = indexingNotification.updateIndexingState(state.progress)
|
||||
if (changed) {
|
||||
post(indexingNotification)
|
||||
}
|
||||
} else if (musicSettings.shouldBeObserving) {
|
||||
// Not observing and done loading, exit foreground.
|
||||
logD("Exiting foreground")
|
||||
post(observingNotification)
|
||||
} else {
|
||||
post(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})")
|
||||
// Cancel the previous music loading job.
|
||||
currentIndexJob?.cancel()
|
||||
// Start a new music loading job on a co-routine.
|
||||
currentIndexJob = musicRepository.index(this, withCache)
|
||||
}
|
||||
|
||||
override val scope = indexScope
|
||||
|
||||
override fun onIndexingStateChanged() {
|
||||
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
|
||||
val state = musicRepository.indexingState
|
||||
if (state is IndexingState.Indexing) {
|
||||
wakeLock.acquireSafe()
|
||||
} else {
|
||||
wakeLock.releaseSafe()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
logD("Music changed, updating shared objects")
|
||||
// Wipe possibly-invalidated outdated covers
|
||||
imageLoader.memoryCache?.clear()
|
||||
// Clear invalid models from PlaybackStateManager. This is not connected
|
||||
// to a listener as it is bad practice for a shared object to attach to
|
||||
// the listener system of another.
|
||||
playbackManager.toSavedState()?.let { savedState ->
|
||||
playbackManager.applySavedState(
|
||||
savedState.copy(
|
||||
heap =
|
||||
savedState.heap.map { song ->
|
||||
song?.let { deviceLibrary.findSong(it.uid) }
|
||||
}),
|
||||
true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIndexingSettingChanged() {
|
||||
super.onIndexingSettingChanged()
|
||||
musicRepository.requestIndex(true)
|
||||
}
|
||||
|
||||
override fun onObservingChanged() {
|
||||
super.onObservingChanged()
|
||||
// Make sure we don't override the service state with the observing
|
||||
// notification if we were actively loading when the automatic rescanning
|
||||
// setting changed. In such a case, the state will still be updated when
|
||||
// the music loading process ends.
|
||||
if (currentIndexJob == null) {
|
||||
logD("Not loading, updating idle session")
|
||||
foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER)
|
||||
}
|
||||
}
|
||||
|
||||
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
|
||||
private fun PowerManager.WakeLock.acquireSafe() {
|
||||
// Avoid unnecessary acquire calls.
|
||||
if (!wakeLock.isHeld) {
|
||||
logD("Acquiring wake lock")
|
||||
// Time out after a minute, which is the average music loading time for a medium-sized
|
||||
// library. If this runs out, we will re-request the lock, and if music loading is
|
||||
// shorter than the timeout, it will be released early.
|
||||
acquire(WAKELOCK_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */
|
||||
private fun PowerManager.WakeLock.releaseSafe() {
|
||||
// Avoid unnecessary release calls.
|
||||
if (wakeLock.isHeld) {
|
||||
logD("Releasing wake lock")
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
|
||||
}
|
||||
}
|
|
@ -0,0 +1,374 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,310 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* MediaItemTranslation.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.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.media.utils.MediaConstants
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import java.io.ByteArrayOutputStream
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Album
|
||||
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.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
|
||||
fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem {
|
||||
// TODO: Make custom overflow menu for compat
|
||||
val style =
|
||||
Bundle().apply {
|
||||
putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)
|
||||
}
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(context.getString(nameRes))
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(mediaType)
|
||||
.setExtras(style)
|
||||
if (bitmapRes != null) {
|
||||
val data = ByteArrayOutputStream()
|
||||
BitmapFactory.decodeResource(context.resources, bitmapRes)
|
||||
.compress(Bitmap.CompressFormat.PNG, 100, data)
|
||||
metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON)
|
||||
}
|
||||
return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build()
|
||||
}
|
||||
|
||||
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
|
||||
val mediaSessionUID =
|
||||
if (parent == null) {
|
||||
MediaSessionUID.Single(uid)
|
||||
} else {
|
||||
MediaSessionUID.Joined(parent.uid, uid)
|
||||
}
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setArtist(artists.resolveNames(context))
|
||||
.setAlbumTitle(album.name.resolve(context))
|
||||
.setAlbumArtist(album.artists.resolveNames(context))
|
||||
.setTrackNumber(track)
|
||||
.setDiscNumber(disc?.number)
|
||||
.setGenre(genres.resolveNames(context))
|
||||
.setDisplayTitle(name.resolve(context))
|
||||
.setSubtitle(artists.resolveNames(context))
|
||||
.setRecordingYear(album.dates?.min?.year)
|
||||
.setRecordingMonth(album.dates?.min?.month)
|
||||
.setRecordingDay(album.dates?.min?.day)
|
||||
.setReleaseYear(album.dates?.min?.year)
|
||||
.setReleaseMonth(album.dates?.min?.month)
|
||||
.setReleaseDay(album.dates?.min?.day)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(false)
|
||||
.setArtworkUri(cover.mediaStoreCoverUri)
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
putString("uid", mediaSessionUID.toString())
|
||||
putLong("durationMs", durationMs)
|
||||
})
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Album.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setArtist(artists.resolveNames(context))
|
||||
.setAlbumTitle(name.resolve(context))
|
||||
.setAlbumArtist(artists.resolveNames(context))
|
||||
.setRecordingYear(dates?.min?.year)
|
||||
.setRecordingMonth(dates?.min?.month)
|
||||
.setRecordingDay(dates?.min?.day)
|
||||
.setReleaseYear(dates?.min?.year)
|
||||
.setReleaseMonth(dates?.min?.month)
|
||||
.setReleaseDay(dates?.min?.day)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Artist.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
context.getString(
|
||||
R.string.fmt_two,
|
||||
if (explicitAlbums.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size)
|
||||
} else {
|
||||
context.getString(R.string.def_album_count)
|
||||
},
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
}))
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setGenre(genres.resolveNames(context))
|
||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Genre.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Playlist.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(cover?.single?.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? {
|
||||
val uid = MediaSessionUID.fromString(mediaId) ?: return null
|
||||
return when (uid) {
|
||||
is MediaSessionUID.Single -> {
|
||||
deviceLibrary.findSong(uid.uid)
|
||||
}
|
||||
is MediaSessionUID.Joined -> {
|
||||
deviceLibrary.findSong(uid.childUid)
|
||||
}
|
||||
is MediaSessionUID.Category -> null
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface MediaSessionUID {
|
||||
enum class Category(
|
||||
val id: String,
|
||||
@StringRes val nameRes: Int,
|
||||
@DrawableRes val bitmapRes: Int?,
|
||||
val mediaType: Int?
|
||||
) : MediaSessionUID {
|
||||
ROOT("root", R.string.info_app_name, null, null),
|
||||
SONGS(
|
||||
"songs",
|
||||
R.string.lbl_songs,
|
||||
R.drawable.ic_song_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_MUSIC),
|
||||
ALBUMS(
|
||||
"albums",
|
||||
R.string.lbl_albums,
|
||||
R.drawable.ic_album_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||
ARTISTS(
|
||||
"artists",
|
||||
R.string.lbl_artists,
|
||||
R.drawable.ic_artist_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
|
||||
GENRES(
|
||||
"genres",
|
||||
R.string.lbl_genres,
|
||||
R.drawable.ic_genre_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_GENRES),
|
||||
PLAYLISTS(
|
||||
"playlists",
|
||||
R.string.lbl_playlists,
|
||||
R.drawable.ic_playlist_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
|
||||
|
||||
override fun toString() = "$ID_CATEGORY:$id"
|
||||
|
||||
companion object {
|
||||
val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES)
|
||||
val USER_MUSIC = listOf(ROOT, PLAYLISTS)
|
||||
val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS)
|
||||
}
|
||||
}
|
||||
|
||||
data class Single(val uid: Music.UID) : MediaSessionUID {
|
||||
override fun toString() = "$ID_ITEM:$uid"
|
||||
}
|
||||
|
||||
data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID {
|
||||
override fun toString() = "$ID_ITEM:$parentUid>$childUid"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category"
|
||||
const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item"
|
||||
|
||||
fun fromString(str: String): MediaSessionUID? {
|
||||
val parts = str.split(":", limit = 2)
|
||||
if (parts.size != 2) {
|
||||
return null
|
||||
}
|
||||
return when (parts[0]) {
|
||||
ID_CATEGORY ->
|
||||
when (parts[1]) {
|
||||
Category.ROOT.id -> Category.ROOT
|
||||
Category.SONGS.id -> Category.SONGS
|
||||
Category.ALBUMS.id -> Category.ALBUMS
|
||||
Category.ARTISTS.id -> Category.ARTISTS
|
||||
Category.GENRES.id -> Category.GENRES
|
||||
Category.PLAYLISTS.id -> Category.PLAYLISTS
|
||||
else -> null
|
||||
}
|
||||
ID_ITEM -> {
|
||||
val uids = parts[1].split(">", limit = 2)
|
||||
if (uids.size == 1) {
|
||||
Music.UID.fromString(uids[0])?.let { Single(it) }
|
||||
} else {
|
||||
Music.UID.fromString(uids[0])?.let { parent ->
|
||||
Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* SystemContentObserver.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.database.ContentObserver
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior known
|
||||
* to the user as automatic rescanning. The active (and not passive) nature of observing the
|
||||
* database is what requires [IndexerService] to stay foreground when this is enabled.
|
||||
*/
|
||||
class SystemContentObserver
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val musicSettings: MusicSettings
|
||||
) : ContentObserver(Handler(Looper.getMainLooper())), Runnable {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun attach() {
|
||||
context.contentResolverSafe.registerContentObserver(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release this instance, preventing it from further observing the database and cancelling any
|
||||
* pending update events.
|
||||
*/
|
||||
fun release() {
|
||||
handler.removeCallbacks(this)
|
||||
context.contentResolverSafe.unregisterContentObserver(this)
|
||||
}
|
||||
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
// Batch rapid-fire updates to the library into a single call to run after 500ms
|
||||
handler.removeCallbacks(this)
|
||||
handler.postDelayed(this, REINDEX_DELAY_MS)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
// Check here if we should even start a reindex. This is much less bug-prone than
|
||||
// registering and de-registering this component as this setting changes.
|
||||
if (musicSettings.shouldBeObserving) {
|
||||
logD("MediaStore changed, starting re-index")
|
||||
musicRepository.requestIndex(true)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REINDEX_DELAY_MS = 500L
|
||||
}
|
||||
}
|
|
@ -1,286 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* IndexerService.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.system
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.database.ContentObserver
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.MediaStore
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.music.IndexingProgress
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.service.ForegroundManager
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [Service] that manages the background music loading process.
|
||||
*
|
||||
* Loading music is a time-consuming process that would likely be killed by the system before it
|
||||
* could complete if ran anywhere else. So, this [Service] manages the music loading process as an
|
||||
* instance of [MusicRepository.IndexingWorker].
|
||||
*
|
||||
* This [Service] also handles automatic rescanning, as that is a similarly long-running background
|
||||
* operation that would be unsuitable elsewhere in the app.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Unify with PlaybackService as part of the service independence project
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class IndexerService :
|
||||
Service(),
|
||||
MusicRepository.IndexingWorker,
|
||||
MusicRepository.IndexingListener,
|
||||
MusicRepository.UpdateListener,
|
||||
MusicSettings.Listener {
|
||||
@Inject lateinit var imageLoader: ImageLoader
|
||||
@Inject lateinit var musicRepository: MusicRepository
|
||||
@Inject lateinit var musicSettings: MusicSettings
|
||||
@Inject lateinit var playbackManager: PlaybackStateManager
|
||||
|
||||
private val serviceJob = Job()
|
||||
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
private var currentIndexJob: Job? = null
|
||||
private lateinit var foregroundManager: ForegroundManager
|
||||
private lateinit var indexingNotification: IndexingNotification
|
||||
private lateinit var observingNotification: ObservingNotification
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
private lateinit var indexerContentObserver: SystemContentObserver
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Initialize the core service components first.
|
||||
foregroundManager = ForegroundManager(this)
|
||||
indexingNotification = IndexingNotification(this)
|
||||
observingNotification = ObservingNotification(this)
|
||||
wakeLock =
|
||||
getSystemServiceCompat(PowerManager::class)
|
||||
.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService")
|
||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
indexerContentObserver = SystemContentObserver()
|
||||
musicSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
musicRepository.addIndexingListener(this)
|
||||
musicRepository.registerWorker(this)
|
||||
|
||||
logD("Service created.")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = START_NOT_STICKY
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// De-initialize core service components first.
|
||||
foregroundManager.release()
|
||||
wakeLock.releaseSafe()
|
||||
// Then cancel the listener-dependent components to ensure that stray reloading
|
||||
// events will not occur.
|
||||
indexerContentObserver.release()
|
||||
musicSettings.unregisterListener(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
musicRepository.removeIndexingListener(this)
|
||||
musicRepository.unregisterWorker(this)
|
||||
// Then cancel any remaining music loading jobs.
|
||||
serviceJob.cancel()
|
||||
}
|
||||
|
||||
// --- CONTROLLER CALLBACKS ---
|
||||
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})")
|
||||
// Cancel the previous music loading job.
|
||||
currentIndexJob?.cancel()
|
||||
// Start a new music loading job on a co-routine.
|
||||
currentIndexJob = musicRepository.index(this@IndexerService, withCache)
|
||||
}
|
||||
|
||||
override val context = this
|
||||
|
||||
override val scope = indexScope
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
logD("Music changed, updating shared objects")
|
||||
// Wipe possibly-invalidated outdated covers
|
||||
imageLoader.memoryCache?.clear()
|
||||
// Clear invalid models from PlaybackStateManager. This is not connected
|
||||
// to a listener as it is bad practice for a shared object to attach to
|
||||
// the listener system of another.
|
||||
playbackManager.toSavedState()?.let { savedState ->
|
||||
playbackManager.applySavedState(
|
||||
savedState.copy(
|
||||
heap =
|
||||
savedState.heap.map { song ->
|
||||
song?.let { deviceLibrary.findSong(it.uid) }
|
||||
}),
|
||||
true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIndexingStateChanged() {
|
||||
val state = musicRepository.indexingState
|
||||
if (state is IndexingState.Indexing) {
|
||||
updateActiveSession(state.progress)
|
||||
} else {
|
||||
updateIdleSession()
|
||||
}
|
||||
}
|
||||
|
||||
// --- INTERNAL ---
|
||||
|
||||
private fun updateActiveSession(progress: IndexingProgress) {
|
||||
// When loading, we want to enter the foreground state so that android does
|
||||
// not shut off the loading process. Note that while we will always post the
|
||||
// notification when initially starting, we will not update the notification
|
||||
// unless it indicates that it has changed.
|
||||
val changed = indexingNotification.updateIndexingState(progress)
|
||||
if (!foregroundManager.tryStartForeground(indexingNotification) && changed) {
|
||||
logD("Notification changed, re-posting notification")
|
||||
indexingNotification.post()
|
||||
}
|
||||
// Make sure we can keep the CPU on while loading music
|
||||
wakeLock.acquireSafe()
|
||||
}
|
||||
|
||||
private fun updateIdleSession() {
|
||||
if (musicSettings.shouldBeObserving) {
|
||||
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
||||
// 1. Newer versions of Android have become more and more restrictive regarding
|
||||
// how a foreground service starts. Thus, it's best to go foreground now so that
|
||||
// we can go foreground later.
|
||||
// 2. If a non-foreground service is killed, the app will probably still be alive,
|
||||
// and thus the music library will not be updated at all.
|
||||
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
|
||||
// this anymore, or at least I only have to use it when the app task is not removed.
|
||||
logD("Need to observe, staying in foreground")
|
||||
if (!foregroundManager.tryStartForeground(observingNotification)) {
|
||||
logD("Notification changed, re-posting notification")
|
||||
observingNotification.post()
|
||||
}
|
||||
} else {
|
||||
// Not observing and done loading, exit foreground.
|
||||
logD("Exiting foreground")
|
||||
foregroundManager.tryStopForeground()
|
||||
}
|
||||
// Release our wake lock (if we were using it)
|
||||
wakeLock.releaseSafe()
|
||||
}
|
||||
|
||||
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
|
||||
private fun PowerManager.WakeLock.acquireSafe() {
|
||||
// Avoid unnecessary acquire calls.
|
||||
if (!wakeLock.isHeld) {
|
||||
logD("Acquiring wake lock")
|
||||
// Time out after a minute, which is the average music loading time for a medium-sized
|
||||
// library. If this runs out, we will re-request the lock, and if music loading is
|
||||
// shorter than the timeout, it will be released early.
|
||||
acquire(WAKELOCK_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */
|
||||
private fun PowerManager.WakeLock.releaseSafe() {
|
||||
// Avoid unnecessary release calls.
|
||||
if (wakeLock.isHeld) {
|
||||
logD("Releasing wake lock")
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// --- SETTING CALLBACKS ---
|
||||
|
||||
override fun onIndexingSettingChanged() {
|
||||
// Music loading configuration changed, need to reload music.
|
||||
requestIndex(true)
|
||||
}
|
||||
|
||||
override fun onObservingChanged() {
|
||||
// Make sure we don't override the service state with the observing
|
||||
// notification if we were actively loading when the automatic rescanning
|
||||
// setting changed. In such a case, the state will still be updated when
|
||||
// the music loading process ends.
|
||||
if (currentIndexJob == null) {
|
||||
logD("Not loading, updating idle session")
|
||||
updateIdleSession()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior
|
||||
* known to the user as automatic rescanning. The active (and not passive) nature of observing
|
||||
* the database is what requires [IndexerService] to stay foreground when this is enabled.
|
||||
*/
|
||||
private inner class SystemContentObserver :
|
||||
ContentObserver(Handler(Looper.getMainLooper())), Runnable {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
init {
|
||||
contentResolverSafe.registerContentObserver(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release this instance, preventing it from further observing the database and cancelling
|
||||
* any pending update events.
|
||||
*/
|
||||
fun release() {
|
||||
handler.removeCallbacks(this)
|
||||
contentResolverSafe.unregisterContentObserver(this)
|
||||
}
|
||||
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
// Batch rapid-fire updates to the library into a single call to run after 500ms
|
||||
handler.removeCallbacks(this)
|
||||
handler.postDelayed(this, REINDEX_DELAY_MS)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
// Check here if we should even start a reindex. This is much less bug-prone than
|
||||
// registering and de-registering this component as this setting changes.
|
||||
if (musicSettings.shouldBeObserving) {
|
||||
logD("MediaStore changed, starting re-index")
|
||||
requestIndex(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
|
||||
const val REINDEX_DELAY_MS = 500L
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
package org.oxycblt.auxio.music.user
|
||||
|
||||
import org.oxycblt.auxio.image.extractor.ParentCover
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
|
@ -46,6 +47,8 @@ private constructor(
|
|||
|
||||
override fun toString() = "Playlist(uid=$uid, name=$name)"
|
||||
|
||||
override val cover = songs.takeIf { it.isNotEmpty() }?.let { ParentCover.from(it.first(), it) }
|
||||
|
||||
/**
|
||||
* Clone the data in this instance to a new [PlaylistImpl] with the given [name].
|
||||
*
|
||||
|
|
|
@ -32,15 +32,16 @@ import org.oxycblt.auxio.music.Album
|
|||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
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.playback.persist.PersistenceRepository
|
||||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
import org.oxycblt.auxio.playback.state.PlaybackCommand
|
||||
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.playback.state.ShuffleMode
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
@ -59,8 +60,8 @@ constructor(
|
|||
private val playbackManager: PlaybackStateManager,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val persistenceRepository: PersistenceRepository,
|
||||
private val commandFactory: PlaybackCommand.Factory,
|
||||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
) : ViewModel(), PlaybackStateManager.Listener, PlaybackSettings.Listener {
|
||||
private var lastPositionJob: Job? = null
|
||||
|
||||
|
@ -189,21 +190,21 @@ constructor(
|
|||
|
||||
fun play(song: Song, with: PlaySong) {
|
||||
logD("Playing $song with $with")
|
||||
playWithImpl(song, with, isImplicitlyShuffled())
|
||||
playWithImpl(song, with, ShuffleMode.IMPLICIT)
|
||||
}
|
||||
|
||||
fun playExplicit(song: Song, with: PlaySong) {
|
||||
playWithImpl(song, with, false)
|
||||
playWithImpl(song, with, ShuffleMode.OFF)
|
||||
}
|
||||
|
||||
fun shuffleExplicit(song: Song, with: PlaySong) {
|
||||
playWithImpl(song, with, true)
|
||||
playWithImpl(song, with, ShuffleMode.ON)
|
||||
}
|
||||
|
||||
/** Shuffle all songs in the music library. */
|
||||
fun shuffleAll() {
|
||||
logD("Shuffling all songs")
|
||||
playFromAllImpl(null, true)
|
||||
playFromAllImpl(null, ShuffleMode.ON)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -214,7 +215,7 @@ constructor(
|
|||
* be prompted on what artist to play. Defaults to null.
|
||||
*/
|
||||
fun playFromArtist(song: Song, artist: Artist? = null) {
|
||||
playFromArtistImpl(song, artist, isImplicitlyShuffled())
|
||||
playFromArtistImpl(song, artist, ShuffleMode.IMPLICIT)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -225,63 +226,68 @@ constructor(
|
|||
* be prompted on what artist to play. Defaults to null.
|
||||
*/
|
||||
fun playFromGenre(song: Song, genre: Genre? = null) {
|
||||
playFromGenreImpl(song, genre, isImplicitlyShuffled())
|
||||
playFromGenreImpl(song, genre, ShuffleMode.IMPLICIT)
|
||||
}
|
||||
|
||||
private fun isImplicitlyShuffled() = playbackManager.isShuffled && playbackSettings.keepShuffle
|
||||
|
||||
private fun playWithImpl(song: Song, with: PlaySong, shuffled: Boolean) {
|
||||
private fun playWithImpl(song: Song, with: PlaySong, shuffle: ShuffleMode) {
|
||||
when (with) {
|
||||
is PlaySong.FromAll -> playFromAllImpl(song, shuffled)
|
||||
is PlaySong.FromAlbum -> playFromAlbumImpl(song, shuffled)
|
||||
is PlaySong.FromArtist -> playFromArtistImpl(song, with.which, shuffled)
|
||||
is PlaySong.FromGenre -> playFromGenreImpl(song, with.which, shuffled)
|
||||
is PlaySong.FromPlaylist -> playFromPlaylistImpl(song, with.which, shuffled)
|
||||
is PlaySong.ByItself -> playItselfImpl(song, shuffled)
|
||||
is PlaySong.FromAll -> playFromAllImpl(song, shuffle)
|
||||
is PlaySong.FromAlbum -> playFromAlbumImpl(song, shuffle)
|
||||
is PlaySong.FromArtist -> playFromArtistImpl(song, with.which, shuffle)
|
||||
is PlaySong.FromGenre -> playFromGenreImpl(song, with.which, shuffle)
|
||||
is PlaySong.FromPlaylist -> playFromPlaylistImpl(song, with.which, shuffle)
|
||||
is PlaySong.ByItself -> playItselfImpl(song, shuffle)
|
||||
}
|
||||
}
|
||||
|
||||
private fun playFromAllImpl(song: Song?, shuffled: Boolean) {
|
||||
playImpl(song, null, shuffled)
|
||||
private fun playItselfImpl(song: Song, shuffle: ShuffleMode) {
|
||||
playbackManager.play(
|
||||
requireNotNull(commandFactory.song(song, shuffle)) {
|
||||
"Invalid playback parameters [$song $shuffle]"
|
||||
})
|
||||
}
|
||||
|
||||
private fun playFromAlbumImpl(song: Song, shuffled: Boolean) {
|
||||
playImpl(song, song.album, shuffled)
|
||||
private fun playFromAllImpl(song: Song?, shuffle: ShuffleMode) {
|
||||
val params =
|
||||
if (song != null) {
|
||||
commandFactory.songFromAll(song, shuffle)
|
||||
} else {
|
||||
commandFactory.all(shuffle)
|
||||
}
|
||||
|
||||
playImpl(params)
|
||||
}
|
||||
|
||||
private fun playFromArtistImpl(song: Song, artist: Artist?, shuffled: Boolean) {
|
||||
if (artist != null) {
|
||||
logD("Playing $song from $artist")
|
||||
playImpl(song, artist, shuffled)
|
||||
} else if (song.artists.size == 1) {
|
||||
logD("$song has one artist, playing from it")
|
||||
playImpl(song, song.artists[0], shuffled)
|
||||
} else {
|
||||
logD("$song has multiple artists, showing choice dialog")
|
||||
startPlaybackDecision(PlaybackDecision.PlayFromArtist(song))
|
||||
private fun playFromAlbumImpl(song: Song, shuffle: ShuffleMode) {
|
||||
logD("Playing $song from album")
|
||||
playImpl(commandFactory.songFromAlbum(song, shuffle))
|
||||
}
|
||||
|
||||
private fun playFromArtistImpl(song: Song, artist: Artist?, shuffle: ShuffleMode) {
|
||||
val params = commandFactory.songFromArtist(song, artist, shuffle)
|
||||
if (params != null) {
|
||||
playbackManager.play(params)
|
||||
return
|
||||
}
|
||||
logD(
|
||||
"Cannot use given artist parameter for $song [$artist from ${song.artists}], showing choice dialog")
|
||||
startPlaybackDecision(PlaybackDecision.PlayFromArtist(song))
|
||||
}
|
||||
|
||||
private fun playFromGenreImpl(song: Song, genre: Genre?, shuffled: Boolean) {
|
||||
if (genre != null) {
|
||||
logD("Playing $song from $genre")
|
||||
playImpl(song, genre, shuffled)
|
||||
} else if (song.genres.size == 1) {
|
||||
logD("$song has one genre, playing from it")
|
||||
playImpl(song, song.genres[0], shuffled)
|
||||
} else {
|
||||
logD("$song has multiple genres, showing choice dialog")
|
||||
startPlaybackDecision(PlaybackDecision.PlayFromGenre(song))
|
||||
private fun playFromGenreImpl(song: Song, genre: Genre?, shuffle: ShuffleMode) {
|
||||
val params = commandFactory.songFromGenre(song, genre, shuffle)
|
||||
if (params != null) {
|
||||
playbackManager.play(params)
|
||||
return
|
||||
}
|
||||
logD(
|
||||
"Cannot use given genre parameter for $song [$genre from ${song.genres}], showing choice dialog")
|
||||
startPlaybackDecision(PlaybackDecision.PlayFromArtist(song))
|
||||
}
|
||||
|
||||
private fun playFromPlaylistImpl(song: Song, playlist: Playlist, shuffled: Boolean) {
|
||||
private fun playFromPlaylistImpl(song: Song, playlist: Playlist, shuffle: ShuffleMode) {
|
||||
logD("Playing $song from $playlist")
|
||||
playImpl(song, playlist, shuffled)
|
||||
}
|
||||
|
||||
private fun playItselfImpl(song: Song, shuffled: Boolean) {
|
||||
playImpl(song, listOf(song), shuffled)
|
||||
playImpl(commandFactory.songFromPlaylist(song, playlist, shuffle))
|
||||
}
|
||||
|
||||
private fun startPlaybackDecision(decision: PlaybackDecision) {
|
||||
|
@ -300,7 +306,7 @@ constructor(
|
|||
*/
|
||||
fun play(album: Album) {
|
||||
logD("Playing $album")
|
||||
playImpl(null, album, false)
|
||||
playImpl(commandFactory.album(album, ShuffleMode.OFF))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -310,7 +316,7 @@ constructor(
|
|||
*/
|
||||
fun shuffle(album: Album) {
|
||||
logD("Shuffling $album")
|
||||
playImpl(null, album, true)
|
||||
playImpl(commandFactory.album(album, ShuffleMode.ON))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -320,7 +326,7 @@ constructor(
|
|||
*/
|
||||
fun play(artist: Artist) {
|
||||
logD("Playing $artist")
|
||||
playImpl(null, artist, false)
|
||||
playImpl(commandFactory.artist(artist, ShuffleMode.OFF))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -330,7 +336,7 @@ constructor(
|
|||
*/
|
||||
fun shuffle(artist: Artist) {
|
||||
logD("Shuffling $artist")
|
||||
playImpl(null, artist, true)
|
||||
playImpl(commandFactory.artist(artist, ShuffleMode.ON))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -340,7 +346,7 @@ constructor(
|
|||
*/
|
||||
fun play(genre: Genre) {
|
||||
logD("Playing $genre")
|
||||
playImpl(null, genre, false)
|
||||
playImpl(commandFactory.genre(genre, ShuffleMode.OFF))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -350,7 +356,7 @@ constructor(
|
|||
*/
|
||||
fun shuffle(genre: Genre) {
|
||||
logD("Shuffling $genre")
|
||||
playImpl(null, genre, true)
|
||||
playImpl(commandFactory.genre(genre, ShuffleMode.ON))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -360,7 +366,7 @@ constructor(
|
|||
*/
|
||||
fun play(playlist: Playlist) {
|
||||
logD("Playing $playlist")
|
||||
playImpl(null, playlist, false)
|
||||
playImpl(commandFactory.playlist(playlist, ShuffleMode.OFF))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -370,7 +376,7 @@ constructor(
|
|||
*/
|
||||
fun shuffle(playlist: Playlist) {
|
||||
logD("Shuffling $playlist")
|
||||
playImpl(null, playlist, true)
|
||||
playImpl(commandFactory.playlist(playlist, ShuffleMode.ON))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -380,7 +386,7 @@ constructor(
|
|||
*/
|
||||
fun play(songs: List<Song>) {
|
||||
logD("Playing ${songs.size} songs")
|
||||
playbackManager.play(null, null, songs, false)
|
||||
playImpl(commandFactory.songs(songs, ShuffleMode.OFF))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -390,28 +396,11 @@ constructor(
|
|||
*/
|
||||
fun shuffle(songs: List<Song>) {
|
||||
logD("Shuffling ${songs.size} songs")
|
||||
playbackManager.play(null, null, songs, true)
|
||||
playImpl(commandFactory.songs(songs, ShuffleMode.ON))
|
||||
}
|
||||
|
||||
private fun playImpl(song: Song?, queue: List<Song>, shuffled: Boolean) {
|
||||
check(song == null || queue.contains(song)) { "Song to play not in queue" }
|
||||
playbackManager.play(song, null, queue, shuffled)
|
||||
}
|
||||
|
||||
private fun playImpl(song: Song?, parent: MusicParent?, shuffled: Boolean) {
|
||||
check(song == null || parent == null || parent.songs.contains(song)) {
|
||||
"Song to play not in parent"
|
||||
}
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val queue =
|
||||
when (parent) {
|
||||
is Genre -> listSettings.genreSongSort.songs(parent.songs)
|
||||
is Artist -> listSettings.artistSongSort.songs(parent.songs)
|
||||
is Album -> listSettings.albumSongSort.songs(parent.songs)
|
||||
is Playlist -> parent.songs
|
||||
null -> listSettings.songSort.songs(deviceLibrary.songs)
|
||||
}
|
||||
playbackManager.play(song, parent, queue, shuffled)
|
||||
private fun playImpl(command: PlaybackCommand?) {
|
||||
playbackManager.play(requireNotNull(command) { "Invalid playback parameters" })
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -617,49 +606,6 @@ constructor(
|
|||
}
|
||||
_openPanel.put(panel)
|
||||
}
|
||||
|
||||
// --- SAVE/RESTORE FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Force-save the current playback state.
|
||||
*
|
||||
* @param onDone Called when the save is completed with true if successful, and false otherwise.
|
||||
*/
|
||||
fun savePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
logD("Saving playback state")
|
||||
viewModelScope.launch {
|
||||
onDone(persistenceRepository.saveState(playbackManager.toSavedState()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current playback state.
|
||||
*
|
||||
* @param onDone Called when the wipe is completed with true if successful, and false otherwise.
|
||||
*/
|
||||
fun wipePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
logD("Wiping playback state")
|
||||
viewModelScope.launch { onDone(persistenceRepository.saveState(null)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-restore the current playback state.
|
||||
*
|
||||
* @param onDone Called when the restoration is completed with true if successful, and false
|
||||
* otherwise.
|
||||
*/
|
||||
fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) {
|
||||
logD("Force-restoring playback state")
|
||||
viewModelScope.launch {
|
||||
val savedState = persistenceRepository.readState()
|
||||
if (savedState != null) {
|
||||
playbackManager.applySavedState(savedState, true)
|
||||
onDone(true)
|
||||
return@launch
|
||||
}
|
||||
onDone(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -57,7 +57,7 @@ constructor(
|
|||
flush()
|
||||
}
|
||||
|
||||
init {
|
||||
fun attach() {
|
||||
playbackManager.addListener(this)
|
||||
playbackSettings.registerListener(this)
|
||||
}
|
||||
|
|
|
@ -16,11 +16,10 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.system
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.exoplayer.source.ShuffleOrder
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* A ShuffleOrder that fixes the poorly defined default implementation of cloneAndInsert. Whereas
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.system
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.BroadcastReceiver
|
|
@ -0,0 +1,591 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* ExoPlaybackStateHolder.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.media.audiofx.AudioEffect
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
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.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.image.ImageSettings
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.service.toMediaItem
|
||||
import org.oxycblt.auxio.music.service.toSong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
import org.oxycblt.auxio.playback.state.PlaybackCommand
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateHolder
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.Progression
|
||||
import org.oxycblt.auxio.playback.state.RawQueue
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.state.ShuffleMode
|
||||
import org.oxycblt.auxio.playback.state.StateAck
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
class ExoPlaybackStateHolder(
|
||||
private val context: Context,
|
||||
private val player: ExoPlayer,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val persistenceRepository: PersistenceRepository,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val commandFactory: PlaybackCommand.Factory,
|
||||
private val replayGainProcessor: ReplayGainAudioProcessor,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val imageSettings: ImageSettings
|
||||
) :
|
||||
PlaybackStateHolder,
|
||||
Player.Listener,
|
||||
MusicRepository.UpdateListener,
|
||||
PlaybackSettings.Listener,
|
||||
ImageSettings.Listener {
|
||||
private val saveJob = Job()
|
||||
private val saveScope = CoroutineScope(Dispatchers.IO + saveJob)
|
||||
private val restoreScope = CoroutineScope(Dispatchers.IO + saveJob)
|
||||
private var currentSaveJob: Job? = null
|
||||
private var openAudioEffectSession = false
|
||||
|
||||
var sessionOngoing = false
|
||||
private set
|
||||
|
||||
fun attach() {
|
||||
imageSettings.registerListener(this)
|
||||
player.addListener(this)
|
||||
replayGainProcessor.attach()
|
||||
playbackManager.registerStateHolder(this)
|
||||
playbackSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
saveJob.cancel()
|
||||
player.removeListener(this)
|
||||
playbackManager.unregisterStateHolder(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
replayGainProcessor.release()
|
||||
imageSettings.unregisterListener(this)
|
||||
player.release()
|
||||
}
|
||||
|
||||
override var parent: MusicParent? = null
|
||||
private set
|
||||
|
||||
val mediaSessionPlayer: Player
|
||||
get() =
|
||||
MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository)
|
||||
|
||||
override val progression: Progression
|
||||
get() {
|
||||
val mediaItem = player.currentMediaItem ?: return Progression.nil()
|
||||
val duration = mediaItem.mediaMetadata.extras?.getLong("durationMs") ?: Long.MAX_VALUE
|
||||
val clampedPosition = player.currentPosition.coerceAtLeast(0).coerceAtMost(duration)
|
||||
return Progression.from(player.playWhenReady, player.isPlaying, clampedPosition)
|
||||
}
|
||||
|
||||
override val repeatMode
|
||||
get() =
|
||||
when (val repeatMode = player.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")
|
||||
}
|
||||
|
||||
override val audioSessionId: Int
|
||||
get() = player.audioSessionId
|
||||
|
||||
override fun resolveQueue(): RawQueue {
|
||||
val deviceLibrary =
|
||||
musicRepository.deviceLibrary
|
||||
// No library, cannot do anything.
|
||||
?: return RawQueue(emptyList(), emptyList(), 0)
|
||||
val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) }
|
||||
val shuffledMapping =
|
||||
if (player.shuffleModeEnabled) {
|
||||
player.unscrambleQueueIndices()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return RawQueue(
|
||||
heap.mapNotNull { it.toSong(deviceLibrary) },
|
||||
shuffledMapping,
|
||||
player.currentMediaItemIndex)
|
||||
}
|
||||
|
||||
override fun handleDeferred(action: DeferredPlayback): Boolean {
|
||||
val deviceLibrary =
|
||||
musicRepository.deviceLibrary
|
||||
// No library, cannot do anything.
|
||||
?: return false
|
||||
|
||||
when (action) {
|
||||
// Restore state -> Start a new restoreState job
|
||||
is DeferredPlayback.RestoreState -> {
|
||||
logD("Restoring playback state")
|
||||
restoreScope.launch {
|
||||
persistenceRepository.readState()?.let {
|
||||
// Apply the saved state on the main thread to prevent code expecting
|
||||
// state updates on the main thread from crashing.
|
||||
withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shuffle all -> Start new playback from all songs
|
||||
is DeferredPlayback.ShuffleAll -> {
|
||||
logD("Shuffling all tracks")
|
||||
playbackManager.play(
|
||||
requireNotNull(commandFactory.all(ShuffleMode.ON)) {
|
||||
"Invalid playback parameters"
|
||||
})
|
||||
}
|
||||
// Open -> Try to find the Song for the given file and then play it from all songs
|
||||
is DeferredPlayback.Open -> {
|
||||
logD("Opening specified file")
|
||||
deviceLibrary.findSongForUri(context, action.uri)?.let { song ->
|
||||
playbackManager.play(
|
||||
requireNotNull(commandFactory.song(song, ShuffleMode.IMPLICIT)) {
|
||||
"Invalid playback parameters"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun playing(playing: Boolean) {
|
||||
player.playWhenReady = playing
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
player.seekTo(positionMs)
|
||||
deferSave()
|
||||
// Ack handled w/ExoPlayer events
|
||||
}
|
||||
|
||||
override fun repeatMode(repeatMode: RepeatMode) {
|
||||
player.repeatMode =
|
||||
when (repeatMode) {
|
||||
RepeatMode.NONE -> Player.REPEAT_MODE_OFF
|
||||
RepeatMode.ALL -> Player.REPEAT_MODE_ALL
|
||||
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
|
||||
}
|
||||
updatePauseOnRepeat()
|
||||
playbackManager.ack(this, StateAck.RepeatModeChanged)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun newPlayback(command: PlaybackCommand) {
|
||||
parent = command.parent
|
||||
player.shuffleModeEnabled = command.shuffled
|
||||
player.setMediaItems(command.queue.map { it.toMediaItem(context, null) })
|
||||
val startIndex =
|
||||
command.song
|
||||
?.let { command.queue.indexOf(it) }
|
||||
.also { check(it != -1) { "Start song not in queue" } }
|
||||
if (command.shuffled) {
|
||||
player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1))
|
||||
}
|
||||
val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(command.shuffled)
|
||||
player.seekTo(target, C.TIME_UNSET)
|
||||
player.prepare()
|
||||
player.play()
|
||||
playbackManager.ack(this, StateAck.NewPlayback)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun shuffled(shuffled: Boolean) {
|
||||
player.setShuffleModeEnabled(shuffled)
|
||||
if (player.shuffleModeEnabled) {
|
||||
// Have to manually refresh the shuffle seed and anchor it to the new current songs
|
||||
player.setShuffleOrder(
|
||||
BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex))
|
||||
}
|
||||
playbackManager.ack(this, StateAck.QueueReordered)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun next() {
|
||||
// Replicate the old pseudo-circular queue behavior when no repeat option is implemented.
|
||||
// Basically, you can't skip back and wrap around the queue, but you can skip forward and
|
||||
// wrap around the queue, albeit playback will be paused.
|
||||
if (player.repeatMode == Player.REPEAT_MODE_ALL || player.hasNextMediaItem()) {
|
||||
player.seekToNext()
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
player.seekTo(
|
||||
player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled), C.TIME_UNSET)
|
||||
// TODO: Dislike the UX implications of this, I feel should I bite the bullet
|
||||
// and switch to dynamic skip enable/disable?
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
playbackManager.ack(this, StateAck.IndexMoved)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun prev() {
|
||||
if (playbackSettings.rewindWithPrev) {
|
||||
player.seekToPrevious()
|
||||
} else if (player.hasPreviousMediaItem()) {
|
||||
player.seekToPreviousMediaItem()
|
||||
} else {
|
||||
player.seekTo(0)
|
||||
}
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
playbackManager.ack(this, StateAck.IndexMoved)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun goto(index: Int) {
|
||||
val indices = player.unscrambleQueueIndices()
|
||||
if (indices.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val trueIndex = indices[index]
|
||||
player.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
playbackManager.ack(this, StateAck.IndexMoved)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun playNext(songs: List<Song>, ack: StateAck.PlayNext) {
|
||||
val currTimeline = player.currentTimeline
|
||||
val nextIndex =
|
||||
if (currTimeline.isEmpty) {
|
||||
C.INDEX_UNSET
|
||||
} else {
|
||||
currTimeline.getNextWindowIndex(
|
||||
player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled)
|
||||
}
|
||||
|
||||
if (nextIndex == C.INDEX_UNSET) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
||||
} else {
|
||||
player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) })
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun move(from: Int, to: Int, ack: StateAck.Move) {
|
||||
val indices = player.unscrambleQueueIndices()
|
||||
if (indices.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val trueFrom = indices[from]
|
||||
val trueTo = indices[to]
|
||||
// ExoPlayer does not actually update it's ShuffleOrder when moving items. Retain a
|
||||
// semblance of "normalcy" by doing a weird no-op swap that actually moves the item.
|
||||
when {
|
||||
trueFrom > trueTo -> {
|
||||
player.moveMediaItem(trueFrom, trueTo)
|
||||
player.moveMediaItem(trueTo + 1, trueFrom)
|
||||
}
|
||||
trueTo > trueFrom -> {
|
||||
player.moveMediaItem(trueFrom, trueTo)
|
||||
player.moveMediaItem(trueTo - 1, trueFrom)
|
||||
}
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun remove(at: Int, ack: StateAck.Remove) {
|
||||
val indices = player.unscrambleQueueIndices()
|
||||
if (indices.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val trueIndex = indices[at]
|
||||
val songWillChange = player.currentMediaItemIndex == trueIndex
|
||||
player.removeMediaItem(trueIndex)
|
||||
if (songWillChange && !playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun applySavedState(
|
||||
parent: MusicParent?,
|
||||
rawQueue: RawQueue,
|
||||
ack: StateAck.NewPlayback?
|
||||
) {
|
||||
logD("Applying saved state")
|
||||
var sendEvent = false
|
||||
if (this.parent != parent) {
|
||||
this.parent = parent
|
||||
sendEvent = true
|
||||
}
|
||||
if (rawQueue != resolveQueue()) {
|
||||
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
|
||||
if (rawQueue.isShuffled) {
|
||||
player.shuffleModeEnabled = true
|
||||
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
||||
} else {
|
||||
player.shuffleModeEnabled = false
|
||||
}
|
||||
player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
|
||||
player.prepare()
|
||||
player.pause()
|
||||
sendEvent = true
|
||||
}
|
||||
if (sendEvent) {
|
||||
ack?.let { playbackManager.ack(this, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun endSession() {
|
||||
// This session has ended, so we need to reset this flag for when the next
|
||||
// session starts.
|
||||
save {
|
||||
// User could feasibly start playing again if they were fast enough, so
|
||||
// we need to avoid stopping the foreground state if that's the case.
|
||||
if (playbackManager.progression.isPlaying) {
|
||||
playbackManager.playing(false)
|
||||
}
|
||||
sessionOngoing = false
|
||||
playbackManager.ack(this, StateAck.SessionEnded)
|
||||
}
|
||||
}
|
||||
|
||||
override fun reset(ack: StateAck.NewPlayback) {
|
||||
player.setMediaItems(listOf())
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
// --- PLAYER OVERRIDES ---
|
||||
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
super.onPlayWhenReadyChanged(playWhenReady, reason)
|
||||
|
||||
if (player.playWhenReady) {
|
||||
// Mark that we have started playing so that the notification can now be posted.
|
||||
logD("Player has started playing")
|
||||
sessionOngoing = true
|
||||
if (!openAudioEffectSession) {
|
||||
// Convention to start an audioeffect session on play/pause rather than
|
||||
// start/stop
|
||||
logD("Opening audio effect session")
|
||||
broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
openAudioEffectSession = true
|
||||
}
|
||||
} else if (openAudioEffectSession) {
|
||||
// Make sure to close the audio session when we stop playback.
|
||||
logD("Closing audio effect session")
|
||||
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
openAudioEffectSession = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
|
||||
if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) {
|
||||
goto(0)
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
super.onMediaItemTransition(mediaItem, reason)
|
||||
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
|
||||
playbackManager.ack(this, StateAck.IndexMoved)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
|
||||
// So many actions trigger progression changes that it becomes easier just to handle it
|
||||
// in an ExoPlayer callback anyway. This doesn't really cause issues anywhere.
|
||||
if (events.containsAny(
|
||||
Player.EVENT_PLAY_WHEN_READY_CHANGED,
|
||||
Player.EVENT_IS_PLAYING_CHANGED,
|
||||
Player.EVENT_POSITION_DISCONTINUITY)) {
|
||||
logD("Player state changed, must synchronize state")
|
||||
playbackManager.ack(this, StateAck.ProgressionChanged)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
// TODO: Replace with no skipping and a notification instead
|
||||
// If there's any issue, just go to the next song.
|
||||
logE("Player error occurred")
|
||||
logE(error.stackTraceToString())
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
private fun broadcastAudioEffectAction(event: String) {
|
||||
logD("Broadcasting AudioEffect event: $event")
|
||||
context.sendBroadcast(
|
||||
Intent(event)
|
||||
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
|
||||
.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
|
||||
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
|
||||
}
|
||||
|
||||
// --- MUSICREPOSITORY METHODS ---
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (changes.deviceLibrary && musicRepository.deviceLibrary != null) {
|
||||
// We now have a library, see if we have anything we need to do.
|
||||
logD("Library obtained, requesting action")
|
||||
playbackManager.requestAction(this)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PLAYBACKSETTINGS OVERRIDES ---
|
||||
|
||||
override fun onPauseOnRepeatChanged() {
|
||||
super.onPauseOnRepeatChanged()
|
||||
updatePauseOnRepeat()
|
||||
}
|
||||
|
||||
private fun updatePauseOnRepeat() {
|
||||
player.pauseAtEndOfMediaItems =
|
||||
player.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat
|
||||
}
|
||||
|
||||
private fun save(cb: () -> Unit) {
|
||||
saveJob {
|
||||
persistenceRepository.saveState(playbackManager.toSavedState())
|
||||
withContext(Dispatchers.Main) { cb() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun deferSave() {
|
||||
saveJob {
|
||||
logD("Waiting for save buffer")
|
||||
delay(SAVE_BUFFER)
|
||||
yield()
|
||||
logD("Committing saved state")
|
||||
persistenceRepository.saveState(playbackManager.toSavedState())
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveJob(block: suspend () -> Unit) {
|
||||
currentSaveJob?.let {
|
||||
logD("Discarding prior save job")
|
||||
it.cancel()
|
||||
}
|
||||
currentSaveJob = saveScope.launch { block() }
|
||||
}
|
||||
|
||||
class Factory
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val persistenceRepository: PersistenceRepository,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val commandFactory: PlaybackCommand.Factory,
|
||||
private val mediaSourceFactory: MediaSource.Factory,
|
||||
private val replayGainProcessor: ReplayGainAudioProcessor,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val imageSettings: ImageSettings,
|
||||
) {
|
||||
fun create(): ExoPlaybackStateHolder {
|
||||
// Since Auxio is a music player, only specify an audio renderer to save
|
||||
// battery/apk size/cache size
|
||||
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
||||
arrayOf(
|
||||
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
|
||||
MediaCodecAudioRenderer(
|
||||
context,
|
||||
MediaCodecSelector.DEFAULT,
|
||||
handler,
|
||||
audioListener,
|
||||
AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
|
||||
replayGainProcessor))
|
||||
}
|
||||
|
||||
val exoPlayer =
|
||||
ExoPlayer.Builder(context, audioRenderer)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
// Enable automatic WakeLock support
|
||||
.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
.setAudioAttributes(
|
||||
// Signal that we are a music player.
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
true)
|
||||
.build()
|
||||
|
||||
return ExoPlaybackStateHolder(
|
||||
context,
|
||||
exoPlayer,
|
||||
playbackManager,
|
||||
persistenceRepository,
|
||||
playbackSettings,
|
||||
commandFactory,
|
||||
replayGainProcessor,
|
||||
musicRepository,
|
||||
imageSettings)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SAVE_BUFFER = 5000L
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.system
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
|
@ -25,11 +25,13 @@ import android.content.Intent
|
|||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.AuxioService
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService].
|
||||
* A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to
|
||||
* [PlaybackServiceFragment].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -46,7 +48,7 @@ class MediaButtonReceiver : BroadcastReceiver() {
|
|||
// wrong action at the wrong time will result in the app crashing, and there is
|
||||
// nothing I can do about it.
|
||||
logD("Delivering media button intent $intent")
|
||||
intent.component = ComponentName(context, PlaybackService::class.java)
|
||||
intent.component = ComponentName(context, AuxioService::class.java)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,390 @@
|
|||
/*
|
||||
* 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
|
||||
}
|
|
@ -0,0 +1,273 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,280 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* PlaybackActionHandler.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionCommands
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.ActionMode
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.Progression
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.widgets.WidgetComponent
|
||||
import org.oxycblt.auxio.widgets.WidgetProvider
|
||||
|
||||
class PlaybackActionHandler
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val widgetComponent: WidgetComponent
|
||||
) : PlaybackStateManager.Listener, PlaybackSettings.Listener {
|
||||
|
||||
interface Callback {
|
||||
fun onCustomLayoutChanged(layout: List<CommandButton>)
|
||||
}
|
||||
|
||||
private val systemReceiver =
|
||||
SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent)
|
||||
private var callback: Callback? = null
|
||||
|
||||
fun attach(callback: Callback) {
|
||||
this.callback = callback
|
||||
playbackManager.addListener(this)
|
||||
playbackSettings.registerListener(this)
|
||||
ContextCompat.registerReceiver(
|
||||
context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
callback = null
|
||||
playbackManager.removeListener(this)
|
||||
playbackSettings.unregisterListener(this)
|
||||
context.unregisterReceiver(systemReceiver)
|
||||
widgetComponent.release()
|
||||
}
|
||||
|
||||
fun withCommands(commands: SessionCommands) =
|
||||
commands
|
||||
.buildUpon()
|
||||
.add(SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle.EMPTY))
|
||||
.add(SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle.EMPTY))
|
||||
.add(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle.EMPTY))
|
||||
.build()
|
||||
|
||||
fun handleCommand(command: SessionCommand): Boolean {
|
||||
when (command.customAction) {
|
||||
PlaybackActions.ACTION_INC_REPEAT_MODE ->
|
||||
playbackManager.repeatMode(playbackManager.repeatMode.increment())
|
||||
PlaybackActions.ACTION_INVERT_SHUFFLE ->
|
||||
playbackManager.shuffled(!playbackManager.isShuffled)
|
||||
PlaybackActions.ACTION_EXIT -> playbackManager.endSession()
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun createCustomLayout(): List<CommandButton> {
|
||||
val actions = mutableListOf<CommandButton>()
|
||||
|
||||
when (playbackSettings.notificationAction) {
|
||||
ActionMode.REPEAT -> {
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(playbackManager.repeatMode.icon)
|
||||
.setDisplayName(context.getString(R.string.desc_change_repeat))
|
||||
.setSessionCommand(
|
||||
SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle()))
|
||||
.setEnabled(true)
|
||||
.build())
|
||||
}
|
||||
ActionMode.SHUFFLE -> {
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(
|
||||
if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24
|
||||
else R.drawable.ic_shuffle_off_24)
|
||||
.setDisplayName(context.getString(R.string.lbl_shuffle))
|
||||
.setSessionCommand(
|
||||
SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle()))
|
||||
.setEnabled(true)
|
||||
.build())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(R.drawable.ic_skip_prev_24)
|
||||
.setDisplayName(context.getString(R.string.desc_skip_prev))
|
||||
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.setEnabled(true)
|
||||
.build())
|
||||
|
||||
actions.add(
|
||||
CommandButton.Builder()
|
||||
.setIconResId(R.drawable.ic_close_24)
|
||||
.setDisplayName(context.getString(R.string.desc_exit))
|
||||
.setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle()))
|
||||
.setEnabled(true)
|
||||
.build())
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
override fun onPauseOnRepeatChanged() {
|
||||
super.onPauseOnRepeatChanged()
|
||||
callback?.onCustomLayoutChanged(createCustomLayout())
|
||||
}
|
||||
|
||||
override fun onProgressionChanged(progression: Progression) {
|
||||
super.onProgressionChanged(progression)
|
||||
callback?.onCustomLayoutChanged(createCustomLayout())
|
||||
}
|
||||
|
||||
override fun onRepeatModeChanged(repeatMode: RepeatMode) {
|
||||
super.onRepeatModeChanged(repeatMode)
|
||||
callback?.onCustomLayoutChanged(createCustomLayout())
|
||||
}
|
||||
|
||||
override fun onQueueReordered(queue: List<Song>, index: Int, isShuffled: Boolean) {
|
||||
super.onQueueReordered(queue, index, isShuffled)
|
||||
callback?.onCustomLayoutChanged(createCustomLayout())
|
||||
}
|
||||
|
||||
override fun onNotificationActionChanged() {
|
||||
super.onNotificationActionChanged()
|
||||
callback?.onCustomLayoutChanged(createCustomLayout())
|
||||
}
|
||||
}
|
||||
|
||||
object PlaybackActions {
|
||||
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"
|
||||
const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE"
|
||||
const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV"
|
||||
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
|
||||
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
|
||||
const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
|
||||
}
|
||||
|
||||
/**
|
||||
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
|
||||
* active [IntentFilter] to be registered.
|
||||
*/
|
||||
class SystemPlaybackReceiver(
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val widgetComponent: WidgetComponent
|
||||
) : BroadcastReceiver() {
|
||||
private var initialHeadsetPlugEventHandled = false
|
||||
|
||||
val intentFilter =
|
||||
IntentFilter().apply {
|
||||
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||
addAction(AudioManager.ACTION_HEADSET_PLUG)
|
||||
addAction(PlaybackActions.ACTION_INC_REPEAT_MODE)
|
||||
addAction(PlaybackActions.ACTION_INVERT_SHUFFLE)
|
||||
addAction(PlaybackActions.ACTION_SKIP_PREV)
|
||||
addAction(PlaybackActions.ACTION_PLAY_PAUSE)
|
||||
addAction(PlaybackActions.ACTION_SKIP_NEXT)
|
||||
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
// --- SYSTEM EVENTS ---
|
||||
|
||||
// Android has three different ways of handling audio plug events for some reason:
|
||||
// 1. ACTION_HEADSET_PLUG, which only works with wired headsets
|
||||
// 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires
|
||||
// granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less
|
||||
// a non-starter since both require me to display a permission prompt
|
||||
// 3. Some internal framework thing that also handles bluetooth headsets
|
||||
// Just use ACTION_HEADSET_PLUG.
|
||||
AudioManager.ACTION_HEADSET_PLUG -> {
|
||||
logD("Received headset plug event")
|
||||
when (intent.getIntExtra("state", -1)) {
|
||||
0 -> pauseFromHeadsetPlug()
|
||||
1 -> playFromHeadsetPlug()
|
||||
}
|
||||
|
||||
initialHeadsetPlugEventHandled = true
|
||||
}
|
||||
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> {
|
||||
logD("Received Headset noise event")
|
||||
pauseFromHeadsetPlug()
|
||||
}
|
||||
|
||||
// --- AUXIO EVENTS ---
|
||||
PlaybackActions.ACTION_PLAY_PAUSE -> {
|
||||
logD("Received play event")
|
||||
playbackManager.playing(!playbackManager.progression.isPlaying)
|
||||
}
|
||||
PlaybackActions.ACTION_INC_REPEAT_MODE -> {
|
||||
logD("Received repeat mode event")
|
||||
playbackManager.repeatMode(playbackManager.repeatMode.increment())
|
||||
}
|
||||
PlaybackActions.ACTION_INVERT_SHUFFLE -> {
|
||||
logD("Received shuffle event")
|
||||
playbackManager.shuffled(!playbackManager.isShuffled)
|
||||
}
|
||||
PlaybackActions.ACTION_SKIP_PREV -> {
|
||||
logD("Received skip previous event")
|
||||
playbackManager.prev()
|
||||
}
|
||||
PlaybackActions.ACTION_SKIP_NEXT -> {
|
||||
logD("Received skip next event")
|
||||
playbackManager.next()
|
||||
}
|
||||
PlaybackActions.ACTION_EXIT -> {
|
||||
logD("Received exit event")
|
||||
playbackManager.endSession()
|
||||
}
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> {
|
||||
logD("Received widget update event")
|
||||
widgetComponent.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playFromHeadsetPlug() {
|
||||
// ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached,
|
||||
// which would result in unexpected playback. Work around it by dropping the first
|
||||
// call to this function, which should come from that Intent.
|
||||
if (playbackSettings.headsetAutoplay &&
|
||||
playbackManager.currentSong != null &&
|
||||
initialHeadsetPlugEventHandled) {
|
||||
logD("Device connected, resuming")
|
||||
playbackManager.playing(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pauseFromHeadsetPlug() {
|
||||
if (playbackManager.currentSong != null) {
|
||||
logD("Device disconnected, pausing")
|
||||
playbackManager.playing(false)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.system
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.datasource.ContentDataSource
|
|
@ -0,0 +1,187 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* PlaybackCommand.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.state
|
||||
|
||||
import javax.inject.Inject
|
||||
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.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
|
||||
/**
|
||||
* @param song A particular [Song] to play, or null to play the first [Song] in the new queue.
|
||||
* @param queue The queue of [Song]s to play from.
|
||||
* @param parent The [MusicParent] to play from, or null if to play from an non-specific collection
|
||||
* of "All [Song]s".
|
||||
* @param shuffled Whether to shuffle or not.
|
||||
*/
|
||||
interface PlaybackCommand {
|
||||
val song: Song?
|
||||
val parent: MusicParent?
|
||||
val queue: List<Song>
|
||||
val shuffled: Boolean
|
||||
|
||||
interface Factory {
|
||||
fun song(song: Song, shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun songFromAll(song: Song, shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun songFromAlbum(song: Song, shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun songFromArtist(song: Song, artist: Artist?, shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun songFromGenre(song: Song, genre: Genre?, shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun songFromPlaylist(song: Song, playlist: Playlist, shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun all(shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun songs(songs: List<Song>, shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun album(album: Album, shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun artist(artist: Artist, shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun genre(genre: Genre, shuffle: ShuffleMode): PlaybackCommand?
|
||||
|
||||
fun playlist(playlist: Playlist, shuffle: ShuffleMode): PlaybackCommand?
|
||||
}
|
||||
}
|
||||
|
||||
enum class ShuffleMode {
|
||||
ON,
|
||||
OFF,
|
||||
IMPLICIT
|
||||
}
|
||||
|
||||
class PlaybackCommandFactoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
val playbackManager: PlaybackStateManager,
|
||||
val playbackSettings: PlaybackSettings,
|
||||
val listSettings: ListSettings,
|
||||
val musicRepository: MusicRepository
|
||||
) : PlaybackCommand.Factory {
|
||||
data class PlaybackCommandImpl(
|
||||
override val song: Song?,
|
||||
override val parent: MusicParent?,
|
||||
override val queue: List<Song>,
|
||||
override val shuffled: Boolean
|
||||
) : PlaybackCommand
|
||||
|
||||
override fun song(song: Song, shuffle: ShuffleMode) =
|
||||
newCommand(song, null, listOf(song), shuffle)
|
||||
|
||||
override fun songFromAll(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle)
|
||||
|
||||
override fun songFromAlbum(song: Song, shuffle: ShuffleMode) =
|
||||
newCommand(song, song.album, listSettings.albumSongSort, shuffle)
|
||||
|
||||
override fun songFromArtist(song: Song, artist: Artist?, shuffle: ShuffleMode) =
|
||||
newCommand(song, artist, song.artists, listSettings.artistSongSort, shuffle)
|
||||
|
||||
override fun songFromGenre(song: Song, genre: Genre?, shuffle: ShuffleMode) =
|
||||
newCommand(song, genre, song.genres, listSettings.genreSongSort, shuffle)
|
||||
|
||||
override fun songFromPlaylist(song: Song, playlist: Playlist, shuffle: ShuffleMode) =
|
||||
newCommand(song, playlist, playlist.songs, shuffle)
|
||||
|
||||
override fun all(shuffle: ShuffleMode) = newCommand(null, shuffle)
|
||||
|
||||
override fun songs(songs: List<Song>, shuffle: ShuffleMode) =
|
||||
newCommand(null, null, songs, shuffle)
|
||||
|
||||
override fun album(album: Album, shuffle: ShuffleMode) =
|
||||
newCommand(null, album, listSettings.albumSongSort, shuffle)
|
||||
|
||||
override fun artist(artist: Artist, shuffle: ShuffleMode) =
|
||||
newCommand(null, artist, listSettings.artistSongSort, shuffle)
|
||||
|
||||
override fun genre(genre: Genre, shuffle: ShuffleMode) =
|
||||
newCommand(null, genre, listSettings.genreSongSort, shuffle)
|
||||
|
||||
override fun playlist(playlist: Playlist, shuffle: ShuffleMode) =
|
||||
newCommand(null, playlist, playlist.songs, shuffle)
|
||||
|
||||
private fun <T : MusicParent> newCommand(
|
||||
song: Song,
|
||||
parent: T?,
|
||||
parents: List<T>,
|
||||
sort: Sort,
|
||||
shuffle: ShuffleMode
|
||||
): PlaybackCommand? {
|
||||
return if (parent != null) {
|
||||
newCommand(song, parent, sort, shuffle)
|
||||
} else if (song.genres.size == 1) {
|
||||
newCommand(song, parents.first(), sort, shuffle)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun newCommand(song: Song?, shuffle: ShuffleMode): PlaybackCommand? {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return null
|
||||
return newCommand(song, null, deviceLibrary.songs, listSettings.songSort, shuffle)
|
||||
}
|
||||
|
||||
private fun newCommand(
|
||||
song: Song?,
|
||||
parent: MusicParent,
|
||||
sort: Sort,
|
||||
shuffle: ShuffleMode
|
||||
): PlaybackCommand? {
|
||||
val songs = sort.songs(parent.songs)
|
||||
return newCommand(song, parent, songs, sort, shuffle)
|
||||
}
|
||||
|
||||
private fun newCommand(
|
||||
song: Song?,
|
||||
parent: MusicParent?,
|
||||
queue: Collection<Song>,
|
||||
sort: Sort,
|
||||
shuffle: ShuffleMode
|
||||
): PlaybackCommand? {
|
||||
if (queue.isEmpty() || (song != null && song !in queue)) {
|
||||
return null
|
||||
}
|
||||
return newCommand(song, parent, sort.songs(queue), shuffle)
|
||||
}
|
||||
|
||||
private fun newCommand(
|
||||
song: Song?,
|
||||
parent: MusicParent?,
|
||||
queue: List<Song>,
|
||||
shuffle: ShuffleMode
|
||||
): PlaybackCommand {
|
||||
return PlaybackCommandImpl(song, parent, queue, isShuffled(shuffle))
|
||||
}
|
||||
|
||||
private fun isShuffled(shuffle: ShuffleMode) =
|
||||
when (shuffle) {
|
||||
ShuffleMode.ON -> true
|
||||
ShuffleMode.OFF -> false
|
||||
ShuffleMode.IMPLICIT -> playbackSettings.keepShuffle && playbackManager.isShuffled
|
||||
}
|
||||
}
|
|
@ -51,15 +51,8 @@ interface PlaybackStateHolder {
|
|||
/** The current audio session ID of the audio player. */
|
||||
val audioSessionId: Int
|
||||
|
||||
/**
|
||||
* Applies a completely new playback state to the holder.
|
||||
*
|
||||
* @param queue The new queue to use.
|
||||
* @param start The song to start playback from. Should be in the queue.
|
||||
* @param parent The parent to play from.
|
||||
* @param shuffled Whether the queue should be shuffled.
|
||||
*/
|
||||
fun newPlayback(queue: List<Song>, start: Song?, parent: MusicParent?, shuffled: Boolean)
|
||||
/** Applies a completely new playback state to the holder. */
|
||||
fun newPlayback(command: PlaybackCommand)
|
||||
|
||||
/**
|
||||
* Update the playing state of the audio player.
|
||||
|
@ -154,6 +147,9 @@ interface PlaybackStateHolder {
|
|||
*/
|
||||
fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?)
|
||||
|
||||
/** End whatever ongoing playback session may be going on */
|
||||
fun endSession()
|
||||
|
||||
/** Reset this instance to an empty state. */
|
||||
fun reset(ack: StateAck.NewPlayback)
|
||||
}
|
||||
|
@ -202,6 +198,8 @@ sealed interface StateAck {
|
|||
|
||||
/** @see PlaybackStateHolder.repeatMode */
|
||||
data object RepeatModeChanged : StateAck
|
||||
|
||||
data object SessionEnded : StateAck
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -111,13 +111,9 @@ interface PlaybackStateManager {
|
|||
/**
|
||||
* Start new playback.
|
||||
*
|
||||
* @param song A particular [Song] to play, or null to play the first [Song] in the new queue.
|
||||
* @param queue The queue of [Song]s to play from.
|
||||
* @param parent The [MusicParent] to play from, or null if to play from an non-specific
|
||||
* collection of "All [Song]s".
|
||||
* @param shuffled Whether to shuffle or not.
|
||||
* @param command The parameters to start playback with.
|
||||
*/
|
||||
fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean)
|
||||
fun play(command: PlaybackCommand)
|
||||
|
||||
/**
|
||||
* Go to the next [Song] in the queue. Will go to the first [Song] in the queue if there is no
|
||||
|
@ -237,8 +233,7 @@ interface PlaybackStateManager {
|
|||
*/
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
/** Rewind to the beginning of the currently playing [Song]. */
|
||||
fun rewind() = seekTo(0)
|
||||
fun endSession()
|
||||
|
||||
/**
|
||||
* Converts the current state of this instance into a [SavedState].
|
||||
|
@ -317,6 +312,8 @@ interface PlaybackStateManager {
|
|||
* @param repeatMode The new [RepeatMode].
|
||||
*/
|
||||
fun onRepeatModeChanged(repeatMode: RepeatMode) {}
|
||||
|
||||
fun onSessionEnded() {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -441,12 +438,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
// --- PLAYING FUNCTIONS ---
|
||||
|
||||
@Synchronized
|
||||
override fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean) {
|
||||
override fun play(command: PlaybackCommand) {
|
||||
val stateHolder = stateHolder ?: return
|
||||
logD("Playing $song from $parent in ${queue.size}-song queue [shuffled=$shuffled]")
|
||||
logD("Playing $command")
|
||||
// Played something, so we are initialized now
|
||||
isInitialized = true
|
||||
stateHolder.newPlayback(queue, song, parent, shuffled)
|
||||
stateHolder.newPlayback(command)
|
||||
}
|
||||
|
||||
// --- QUEUE FUNCTIONS ---
|
||||
|
@ -476,7 +473,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
override fun playNext(songs: List<Song>) {
|
||||
if (currentSong == null) {
|
||||
logD("Nothing playing, short-circuiting to new playback")
|
||||
play(songs[0], null, songs, false)
|
||||
play(QueueCommand(songs))
|
||||
} else {
|
||||
val stateHolder = stateHolder ?: return
|
||||
logD("Adding ${songs.size} songs to start of queue")
|
||||
|
@ -488,7 +485,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
override fun addToQueue(songs: List<Song>) {
|
||||
if (currentSong == null) {
|
||||
logD("Nothing playing, short-circuiting to new playback")
|
||||
play(songs[0], null, songs, false)
|
||||
play(QueueCommand(songs))
|
||||
} else {
|
||||
val stateHolder = stateHolder ?: return
|
||||
logD("Adding ${songs.size} songs to end of queue")
|
||||
|
@ -496,6 +493,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
}
|
||||
}
|
||||
|
||||
private class QueueCommand(override val queue: List<Song>) : PlaybackCommand {
|
||||
override val song: Song? = null
|
||||
override val parent: MusicParent? = null
|
||||
override val shuffled = false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun moveQueueItem(src: Int, dst: Int) {
|
||||
val stateHolder = stateHolder ?: return
|
||||
|
@ -562,6 +565,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
stateHolder.seekTo(positionMs)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun endSession() {
|
||||
val stateHolder = stateHolder ?: return
|
||||
logD("Ending session")
|
||||
stateHolder.endSession()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) {
|
||||
if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
|
||||
|
@ -688,6 +698,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
)
|
||||
listeners.forEach { it.onRepeatModeChanged(stateMirror.repeatMode) }
|
||||
}
|
||||
is StateAck.SessionEnded -> {
|
||||
listeners.forEach { it.onSessionEnded() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -782,15 +795,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
|||
index
|
||||
})
|
||||
|
||||
// Valid state where something needs to be played, direct the stateholder to apply
|
||||
// this new state.
|
||||
val oldStateMirror = stateMirror
|
||||
if (oldStateMirror.rawQueue != rawQueue) {
|
||||
logD("Queue changed, must reload player")
|
||||
stateHolder.playing(false)
|
||||
stateHolder.applySavedState(parent, rawQueue, StateAck.NewPlayback)
|
||||
stateHolder.seekTo(savedState.positionMs)
|
||||
}
|
||||
stateHolder.applySavedState(savedState.parent, rawQueue, StateAck.NewPlayback)
|
||||
stateHolder.seekTo(savedState.positionMs)
|
||||
stateHolder.repeatMode(savedState.repeatMode)
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* CoverUri.kt is part of Auxio.
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* PlaybackStateModule.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
|
||||
|
@ -16,17 +16,15 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
package org.oxycblt.auxio.playback.state
|
||||
|
||||
import android.net.Uri
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
/**
|
||||
* Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading
|
||||
* images.
|
||||
*
|
||||
* @param mediaStore The album cover [Uri] obtained from MediaStore.
|
||||
* @param song The [Uri] of the first song (by track) of the album, which can also be used to obtain
|
||||
* an album cover.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class CoverUri(val mediaStore: Uri, val song: Uri)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface PlaybackStateModule {
|
||||
@Binds fun playbackCommandFactory(factory: PlaybackCommandFactoryImpl): PlaybackCommand.Factory
|
||||
}
|
|
@ -1,482 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* MediaSessionComponent.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.system
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
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.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
|
||||
|
||||
/**
|
||||
* A component that mirrors the current playback state into the [MediaSessionCompat] and
|
||||
* [NotificationComponent].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MediaSessionComponent
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val bitmapProvider: BitmapProvider,
|
||||
private val imageSettings: ImageSettings
|
||||
) :
|
||||
MediaSessionCompat.Callback(),
|
||||
PlaybackStateManager.Listener,
|
||||
ImageSettings.Listener,
|
||||
PlaybackSettings.Listener {
|
||||
private val mediaSession =
|
||||
MediaSessionCompat(context, context.packageName).apply {
|
||||
isActive = true
|
||||
setQueueTitle(context.getString(R.string.lbl_queue))
|
||||
}
|
||||
|
||||
private val notification = NotificationComponent(context, mediaSession.sessionToken)
|
||||
|
||||
private var listener: Listener? = null
|
||||
|
||||
init {
|
||||
playbackManager.addListener(this)
|
||||
playbackSettings.registerListener(this)
|
||||
imageSettings.registerListener(this)
|
||||
mediaSession.setCallback(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a system media button [Intent] to the [MediaSessionCompat].
|
||||
*
|
||||
* @param intent The [Intent.ACTION_MEDIA_BUTTON] [Intent] to forward.
|
||||
*/
|
||||
fun handleMediaButtonIntent(intent: Intent) {
|
||||
logD("Forwarding $intent to MediaButtonReciever")
|
||||
MediaButtonReceiver.handleIntent(mediaSession, intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a [Listener] for notification updates to this service.
|
||||
*
|
||||
* @param listener The [Listener] to register.
|
||||
*/
|
||||
fun registerListener(listener: Listener) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* Release this instance, closing the [MediaSessionCompat] and preventing any further updates to
|
||||
* the [NotificationComponent].
|
||||
*/
|
||||
fun release() {
|
||||
listener = null
|
||||
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) {
|
||||
listener?.onPostNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
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 ---
|
||||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
super.onPlayFromMediaId(mediaId, extras)
|
||||
// STUB: Unimplemented, no media browser
|
||||
}
|
||||
|
||||
override fun onPlayFromUri(uri: Uri?, extras: Bundle?) {
|
||||
super.onPlayFromUri(uri, extras)
|
||||
// STUB: Unimplemented, no media browser
|
||||
}
|
||||
|
||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||
super.onPlayFromSearch(query, extras)
|
||||
// STUB: Unimplemented, no media browser
|
||||
}
|
||||
|
||||
override fun onAddQueueItem(description: MediaDescriptionCompat?) {
|
||||
super.onAddQueueItem(description)
|
||||
// STUB: Unimplemented
|
||||
}
|
||||
|
||||
override fun onRemoveQueueItem(description: MediaDescriptionCompat?) {
|
||||
super.onRemoveQueueItem(description)
|
||||
// STUB: Unimplemented
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
playbackManager.playing(true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
playbackManager.playing(false)
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
playbackManager.prev()
|
||||
}
|
||||
|
||||
override fun onSeekTo(position: Long) {
|
||||
playbackManager.seekTo(position)
|
||||
}
|
||||
|
||||
override fun onFastForward() {
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
override fun onRewind() {
|
||||
playbackManager.rewind()
|
||||
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 onSkipToQueueItem(id: Long) {
|
||||
playbackManager.goto(id.toInt())
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
// Get the service to shut down with the ACTION_EXIT intent
|
||||
context.sendBroadcast(Intent(PlaybackService.ACTION_EXIT))
|
||||
}
|
||||
|
||||
// --- 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 builder =
|
||||
MediaMetadataCompat.Builder()
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name.resolve(context))
|
||||
// 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,
|
||||
parent?.run { name.resolve(context) }
|
||||
?: context.getString(R.string.lbl_all_songs))
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
|
||||
// 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())
|
||||
}
|
||||
|
||||
// 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")
|
||||
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)
|
||||
listener?.onPostNotification(notification)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
MediaDescriptionCompat.Builder()
|
||||
// Media ID should not be the item index but rather the UID,
|
||||
// as it's used to request a song to be played from the queue.
|
||||
.setMediaId(song.uid.toString())
|
||||
.setTitle(song.name.resolve(context))
|
||||
.setSubtitle(song.artists.resolveNames(context))
|
||||
// Since we usually have to load many songs into the queue, use the
|
||||
// MediaStore URI instead of loading a bitmap.
|
||||
.setIconUri(song.album.coverUri.mediaStore)
|
||||
.setMediaUri(song.uri)
|
||||
.build()
|
||||
// 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(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(
|
||||
PlaybackService.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(
|
||||
PlaybackService.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(
|
||||
PlaybackService.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")
|
||||
listener?.onPostNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
/** An interface for handling changes in the notification configuration. */
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the [NotificationComponent] changes, requiring it to be re-posed.
|
||||
*
|
||||
* @param notification The new [NotificationComponent].
|
||||
*/
|
||||
fun onPostNotification(notification: NotificationComponent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val emptyMetadata = MediaMetadataCompat.Builder().build()
|
||||
private const val ACTIONS =
|
||||
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_STOP
|
||||
}
|
||||
}
|
|
@ -1,160 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* NotificationComponent.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.system
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media.app.NotificationCompat.MediaStyle
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.service.ForegroundServiceNotification
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newBroadcastPendingIntent
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
/**
|
||||
* The playback notification component. Due to race conditions regarding notification updates, this
|
||||
* component is not self-sufficient. [MediaSessionComponent] should be used instead of manage it.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
class NotificationComponent(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, PlaybackService.ACTION_SKIP_PREV, R.drawable.ic_skip_prev_24))
|
||||
addAction(buildPlayPauseAction(context, true))
|
||||
addAction(
|
||||
buildAction(context, PlaybackService.ACTION_SKIP_NEXT, R.drawable.ic_skip_next_24))
|
||||
addAction(buildAction(context, PlaybackService.ACTION_EXIT, R.drawable.ic_close_24))
|
||||
|
||||
setStyle(MediaStyle().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, PlaybackService.ACTION_PLAY_PAUSE, drawableRes)
|
||||
}
|
||||
|
||||
private fun buildRepeatAction(
|
||||
context: Context,
|
||||
repeatMode: RepeatMode
|
||||
): NotificationCompat.Action {
|
||||
return buildAction(context, PlaybackService.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, PlaybackService.ACTION_INVERT_SHUFFLE, drawableRes)
|
||||
}
|
||||
|
||||
private fun buildAction(context: Context, actionName: String, @DrawableRes iconRes: Int) =
|
||||
NotificationCompat.Action.Builder(
|
||||
iconRes, actionName, context.newBroadcastPendingIntent(actionName))
|
||||
.build()
|
||||
|
||||
private companion object {
|
||||
/** Notification channel used by solely the playback notification. */
|
||||
val CHANNEL_INFO =
|
||||
ChannelInfo(
|
||||
id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK",
|
||||
nameRes = R.string.lbl_playback)
|
||||
}
|
||||
}
|
|
@ -1,800 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* PlaybackService.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.system
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioManager
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateHolder
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.Progression
|
||||
import org.oxycblt.auxio.playback.state.RawQueue
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.state.StateAck
|
||||
import org.oxycblt.auxio.service.ForegroundManager
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.widgets.WidgetComponent
|
||||
import org.oxycblt.auxio.widgets.WidgetProvider
|
||||
|
||||
/**
|
||||
* A service that manages the system-side aspects of playback, such as:
|
||||
* - The single [ExoPlayer] instance.
|
||||
* - The Media Notification
|
||||
* - Headset management
|
||||
* - Widgets
|
||||
*
|
||||
* This service is headless and does not manage the playback state. Moreover, the player instance is
|
||||
* not the source of truth for the state, but rather the means to control system-side playback. Both
|
||||
* of those tasks are what [PlaybackStateManager] is for.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Refactor lifecycle to run completely headless (i.e no activity needed)
|
||||
* TODO: Android Auto
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class PlaybackService :
|
||||
Service(),
|
||||
Player.Listener,
|
||||
PlaybackStateHolder,
|
||||
PlaybackSettings.Listener,
|
||||
MediaSessionComponent.Listener,
|
||||
MusicRepository.UpdateListener {
|
||||
// Player components
|
||||
private lateinit var player: ExoPlayer
|
||||
@Inject lateinit var mediaSourceFactory: MediaSource.Factory
|
||||
@Inject lateinit var replayGainProcessor: ReplayGainAudioProcessor
|
||||
|
||||
// System backend components
|
||||
@Inject lateinit var mediaSessionComponent: MediaSessionComponent
|
||||
@Inject lateinit var widgetComponent: WidgetComponent
|
||||
private val systemReceiver = PlaybackReceiver()
|
||||
|
||||
// Shared components
|
||||
@Inject lateinit var playbackManager: PlaybackStateManager
|
||||
@Inject lateinit var playbackSettings: PlaybackSettings
|
||||
@Inject lateinit var persistenceRepository: PersistenceRepository
|
||||
@Inject lateinit var listSettings: ListSettings
|
||||
@Inject lateinit var musicRepository: MusicRepository
|
||||
|
||||
// State
|
||||
private lateinit var foregroundManager: ForegroundManager
|
||||
private var hasPlayed = false
|
||||
private var openAudioEffectSession = false
|
||||
|
||||
// Coroutines
|
||||
private val serviceJob = Job()
|
||||
private val restoreScope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
private val saveScope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
private var currentSaveJob: Job? = null
|
||||
|
||||
// --- SERVICE OVERRIDES ---
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Since Auxio is a music player, only specify an audio renderer to save
|
||||
// battery/apk size/cache size
|
||||
val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ ->
|
||||
arrayOf(
|
||||
FfmpegAudioRenderer(handler, audioListener, replayGainProcessor),
|
||||
MediaCodecAudioRenderer(
|
||||
this,
|
||||
MediaCodecSelector.DEFAULT,
|
||||
handler,
|
||||
audioListener,
|
||||
AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES,
|
||||
replayGainProcessor))
|
||||
}
|
||||
|
||||
player =
|
||||
ExoPlayer.Builder(this, audioRenderer)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
// Enable automatic WakeLock support
|
||||
.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
.setAudioAttributes(
|
||||
// Signal that we are a music player.
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
true)
|
||||
.build()
|
||||
.also { it.addListener(this) }
|
||||
foregroundManager = ForegroundManager(this)
|
||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
playbackManager.registerStateHolder(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
mediaSessionComponent.registerListener(this)
|
||||
playbackSettings.registerListener(this)
|
||||
|
||||
val intentFilter =
|
||||
IntentFilter().apply {
|
||||
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||
addAction(AudioManager.ACTION_HEADSET_PLUG)
|
||||
addAction(ACTION_INC_REPEAT_MODE)
|
||||
addAction(ACTION_INVERT_SHUFFLE)
|
||||
addAction(ACTION_SKIP_PREV)
|
||||
addAction(ACTION_PLAY_PAUSE)
|
||||
addAction(ACTION_SKIP_NEXT)
|
||||
addAction(ACTION_EXIT)
|
||||
addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
|
||||
}
|
||||
|
||||
ContextCompat.registerReceiver(
|
||||
this, systemReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED)
|
||||
|
||||
logD("Service created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
// Forward system media button sent by MediaButtonReceiver to MediaSessionComponent
|
||||
if (intent.action == Intent.ACTION_MEDIA_BUTTON) {
|
||||
mediaSessionComponent.handleMediaButtonIntent(intent)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? = null
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
if (!playbackManager.progression.isPlaying) {
|
||||
playbackManager.playing(false)
|
||||
endSession()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
foregroundManager.release()
|
||||
|
||||
// Pause just in case this destruction was unexpected.
|
||||
playbackManager.playing(false)
|
||||
playbackManager.unregisterStateHolder(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
playbackSettings.unregisterListener(this)
|
||||
|
||||
unregisterReceiver(systemReceiver)
|
||||
serviceJob.cancel()
|
||||
|
||||
widgetComponent.release()
|
||||
mediaSessionComponent.release()
|
||||
|
||||
replayGainProcessor.release()
|
||||
player.release()
|
||||
if (openAudioEffectSession) {
|
||||
// Make sure to close the audio session when we release the player.
|
||||
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
openAudioEffectSession = false
|
||||
}
|
||||
|
||||
logD("Service destroyed")
|
||||
}
|
||||
|
||||
// --- PLAYBACKSTATEHOLDER OVERRIDES ---
|
||||
|
||||
override val progression: Progression
|
||||
get() =
|
||||
player.currentMediaItem?.let {
|
||||
Progression.from(
|
||||
player.playWhenReady,
|
||||
player.isPlaying,
|
||||
// The position value can be below zero or past the expected duration, make
|
||||
// sure we handle that.
|
||||
player.currentPosition.coerceAtLeast(0).coerceAtMost(it.song.durationMs))
|
||||
}
|
||||
?: Progression.nil()
|
||||
|
||||
override val repeatMode
|
||||
get() =
|
||||
when (val repeatMode = player.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")
|
||||
}
|
||||
|
||||
override var parent: MusicParent? = null
|
||||
|
||||
override fun resolveQueue(): RawQueue {
|
||||
val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it).song }
|
||||
val shuffledMapping =
|
||||
if (player.shuffleModeEnabled) {
|
||||
player.unscrambleQueueIndices()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return RawQueue(heap, shuffledMapping, player.currentMediaItemIndex)
|
||||
}
|
||||
|
||||
override val audioSessionId: Int
|
||||
get() = player.audioSessionId
|
||||
|
||||
override fun newPlayback(
|
||||
queue: List<Song>,
|
||||
start: Song?,
|
||||
parent: MusicParent?,
|
||||
shuffled: Boolean
|
||||
) {
|
||||
this.parent = parent
|
||||
player.shuffleModeEnabled = shuffled
|
||||
player.setMediaItems(queue.map { it.toMediaItem() })
|
||||
val startIndex =
|
||||
start
|
||||
?.let { queue.indexOf(start) }
|
||||
.also { check(it != -1) { "Start song not in queue" } }
|
||||
if (shuffled) {
|
||||
player.setShuffleOrder(BetterShuffleOrder(queue.size, startIndex ?: -1))
|
||||
}
|
||||
val target =
|
||||
startIndex ?: player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled)
|
||||
player.seekTo(target, C.TIME_UNSET)
|
||||
player.prepare()
|
||||
player.play()
|
||||
playbackManager.ack(this, StateAck.NewPlayback)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun playing(playing: Boolean) {
|
||||
player.playWhenReady = playing
|
||||
// Dispatched later once all of the changes have been accumulated
|
||||
// Playing state is not persisted, do not need to save
|
||||
}
|
||||
|
||||
override fun repeatMode(repeatMode: RepeatMode) {
|
||||
player.repeatMode =
|
||||
when (repeatMode) {
|
||||
RepeatMode.NONE -> Player.REPEAT_MODE_OFF
|
||||
RepeatMode.ALL -> Player.REPEAT_MODE_ALL
|
||||
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
|
||||
}
|
||||
playbackManager.ack(this, StateAck.RepeatModeChanged)
|
||||
updatePauseOnRepeat()
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
player.seekTo(positionMs)
|
||||
// Dispatched later once all of the changes have been accumulated
|
||||
// Deferred save is handled on position discontinuity
|
||||
}
|
||||
|
||||
override fun next() {
|
||||
// Replicate the old pseudo-circular queue behavior when no repeat option is implemented.
|
||||
// Basically, you can't skip back and wrap around the queue, but you can skip forward and
|
||||
// wrap around the queue, albeit playback will be paused.
|
||||
if (player.repeatMode != Player.REPEAT_MODE_OFF || player.hasNextMediaItem()) {
|
||||
player.seekToNext()
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
goto(0)
|
||||
// TODO: Dislike the UX implications of this, I feel should I bite the bullet
|
||||
// and switch to dynamic skip enable/disable?
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
playbackManager.ack(this, StateAck.IndexMoved)
|
||||
// Deferred save is handled on position discontinuity
|
||||
}
|
||||
|
||||
override fun prev() {
|
||||
if (playbackSettings.rewindWithPrev) {
|
||||
player.seekToPrevious()
|
||||
} else {
|
||||
player.seekToPreviousMediaItem()
|
||||
}
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
playbackManager.ack(this, StateAck.IndexMoved)
|
||||
// Deferred save is handled on position discontinuity
|
||||
}
|
||||
|
||||
override fun goto(index: Int) {
|
||||
val indices = player.unscrambleQueueIndices()
|
||||
if (indices.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val trueIndex = indices[index]
|
||||
player.seekTo(trueIndex, C.TIME_UNSET)
|
||||
if (!playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
playbackManager.ack(this, StateAck.IndexMoved)
|
||||
// Deferred save is handled on position discontinuity
|
||||
}
|
||||
|
||||
override fun shuffled(shuffled: Boolean) {
|
||||
logD("Reordering queue to $shuffled")
|
||||
player.shuffleModeEnabled = shuffled
|
||||
if (shuffled) {
|
||||
// Have to manually refresh the shuffle seed and anchor it to the new current songs
|
||||
player.setShuffleOrder(
|
||||
BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex))
|
||||
}
|
||||
playbackManager.ack(this, StateAck.QueueReordered)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun playNext(songs: List<Song>, ack: StateAck.PlayNext) {
|
||||
val currTimeline = player.currentTimeline
|
||||
val nextIndex =
|
||||
if (currTimeline.isEmpty) {
|
||||
C.INDEX_UNSET
|
||||
} else {
|
||||
currTimeline.getNextWindowIndex(
|
||||
player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled)
|
||||
}
|
||||
|
||||
if (nextIndex == C.INDEX_UNSET) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem() })
|
||||
} else {
|
||||
player.addMediaItems(nextIndex, songs.map { it.toMediaItem() })
|
||||
}
|
||||
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem() })
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun move(from: Int, to: Int, ack: StateAck.Move) {
|
||||
val indices = player.unscrambleQueueIndices()
|
||||
if (indices.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val trueFrom = indices[from]
|
||||
val trueTo = indices[to]
|
||||
|
||||
when {
|
||||
trueFrom > trueTo -> {
|
||||
player.moveMediaItem(trueFrom, trueTo)
|
||||
player.moveMediaItem(trueTo + 1, trueFrom)
|
||||
}
|
||||
trueTo > trueFrom -> {
|
||||
player.moveMediaItem(trueFrom, trueTo)
|
||||
player.moveMediaItem(trueTo - 1, trueFrom)
|
||||
}
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun remove(at: Int, ack: StateAck.Remove) {
|
||||
val indices = player.unscrambleQueueIndices()
|
||||
if (indices.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val trueIndex = indices[at]
|
||||
val songWillChange = player.currentMediaItemIndex == trueIndex
|
||||
player.removeMediaItem(trueIndex)
|
||||
if (songWillChange && !playbackSettings.rememberPause) {
|
||||
player.play()
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun handleDeferred(action: DeferredPlayback): Boolean {
|
||||
val deviceLibrary =
|
||||
musicRepository.deviceLibrary
|
||||
// No library, cannot do anything.
|
||||
?: return false
|
||||
|
||||
when (action) {
|
||||
// Restore state -> Start a new restoreState job
|
||||
is DeferredPlayback.RestoreState -> {
|
||||
logD("Restoring playback state")
|
||||
restoreScope.launch {
|
||||
persistenceRepository.readState()?.let {
|
||||
// Apply the saved state on the main thread to prevent code expecting
|
||||
// state updates on the main thread from crashing.
|
||||
withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shuffle all -> Start new playback from all songs
|
||||
is DeferredPlayback.ShuffleAll -> {
|
||||
logD("Shuffling all tracks")
|
||||
playbackManager.play(
|
||||
null, null, listSettings.songSort.songs(deviceLibrary.songs), true)
|
||||
}
|
||||
// Open -> Try to find the Song for the given file and then play it from all songs
|
||||
is DeferredPlayback.Open -> {
|
||||
logD("Opening specified file")
|
||||
deviceLibrary.findSongForUri(application, action.uri)?.let { song ->
|
||||
playbackManager.play(
|
||||
song,
|
||||
null,
|
||||
listSettings.songSort.songs(deviceLibrary.songs),
|
||||
player.shuffleModeEnabled && playbackSettings.keepShuffle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun applySavedState(
|
||||
parent: MusicParent?,
|
||||
rawQueue: RawQueue,
|
||||
ack: StateAck.NewPlayback?
|
||||
) {
|
||||
this.parent = parent
|
||||
player.setMediaItems(rawQueue.heap.map { it.toMediaItem() })
|
||||
if (rawQueue.isShuffled) {
|
||||
player.shuffleModeEnabled = true
|
||||
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
||||
} else {
|
||||
player.shuffleModeEnabled = false
|
||||
}
|
||||
player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
|
||||
player.prepare()
|
||||
ack?.let { playbackManager.ack(this, it) }
|
||||
}
|
||||
|
||||
override fun reset(ack: StateAck.NewPlayback) {
|
||||
player.setMediaItems(emptyList())
|
||||
playbackManager.ack(this, ack)
|
||||
}
|
||||
|
||||
// --- PLAYER OVERRIDES ---
|
||||
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
super.onPlayWhenReadyChanged(playWhenReady, reason)
|
||||
|
||||
if (player.playWhenReady) {
|
||||
// Mark that we have started playing so that the notification can now be posted.
|
||||
hasPlayed = true
|
||||
logD("Player has started playing")
|
||||
if (!openAudioEffectSession) {
|
||||
// Convention to start an audioeffect session on play/pause rather than
|
||||
// start/stop
|
||||
logD("Opening audio effect session")
|
||||
broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
openAudioEffectSession = true
|
||||
}
|
||||
} else if (openAudioEffectSession) {
|
||||
// Make sure to close the audio session when we stop playback.
|
||||
logD("Closing audio effect session")
|
||||
broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION)
|
||||
openAudioEffectSession = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
super.onMediaItemTransition(mediaItem, reason)
|
||||
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
|
||||
reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) {
|
||||
playbackManager.ack(this, StateAck.IndexMoved)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
|
||||
if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) {
|
||||
goto(0)
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
|
||||
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
|
||||
// TODO: Once position also naturally drifts by some threshold, save
|
||||
deferSave()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
|
||||
if (events.containsAny(
|
||||
Player.EVENT_PLAY_WHEN_READY_CHANGED,
|
||||
Player.EVENT_IS_PLAYING_CHANGED,
|
||||
Player.EVENT_POSITION_DISCONTINUITY)) {
|
||||
logD("Player state changed, must synchronize state")
|
||||
playbackManager.ack(this, StateAck.ProgressionChanged)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
// TODO: Replace with no skipping and a notification instead
|
||||
// If there's any issue, just go to the next song.
|
||||
logE("Player error occured")
|
||||
logE(error.stackTraceToString())
|
||||
playbackManager.next()
|
||||
}
|
||||
|
||||
// --- OTHER OVERRIDES ---
|
||||
|
||||
override fun onPauseOnRepeatChanged() {
|
||||
updatePauseOnRepeat()
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (changes.deviceLibrary && musicRepository.deviceLibrary != null) {
|
||||
// We now have a library, see if we have anything we need to do.
|
||||
logD("Library obtained, requesting action")
|
||||
playbackManager.requestAction(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostNotification(notification: NotificationComponent) {
|
||||
// Do not post the notification if playback hasn't started yet. This prevents errors
|
||||
// where changing a setting would cause the notification to appear in an unfriendly
|
||||
// manner.
|
||||
if (hasPlayed) {
|
||||
logD("Played before, starting foreground state")
|
||||
if (!foregroundManager.tryStartForeground(notification)) {
|
||||
logD("Notification changed, re-posting")
|
||||
notification.post()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- PLAYER MANAGEMENT ---
|
||||
|
||||
private fun updatePauseOnRepeat() {
|
||||
player.pauseAtEndOfMediaItems =
|
||||
playbackManager.repeatMode == RepeatMode.TRACK && playbackSettings.pauseOnRepeat
|
||||
}
|
||||
|
||||
private fun ExoPlayer.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
|
||||
}
|
||||
|
||||
private fun Song.toMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build()
|
||||
|
||||
private val MediaItem.song: Song
|
||||
get() = requireNotNull(localConfiguration).tag as Song
|
||||
|
||||
// --- OTHER FUNCTIONS ---
|
||||
|
||||
private fun deferSave() {
|
||||
saveJob {
|
||||
logD("Waiting for save buffer")
|
||||
delay(SAVE_BUFFER)
|
||||
yield()
|
||||
logD("Committing saved state")
|
||||
persistenceRepository.saveState(playbackManager.toSavedState())
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveJob(block: suspend () -> Unit) {
|
||||
currentSaveJob?.let {
|
||||
logD("Discarding prior save job")
|
||||
it.cancel()
|
||||
}
|
||||
currentSaveJob = saveScope.launch { block() }
|
||||
}
|
||||
|
||||
private fun broadcastAudioEffectAction(event: String) {
|
||||
logD("Broadcasting AudioEffect event: $event")
|
||||
sendBroadcast(
|
||||
Intent(event)
|
||||
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
|
||||
.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
|
||||
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC))
|
||||
}
|
||||
|
||||
private fun endSession() {
|
||||
// This session has ended, so we need to reset this flag for when the next
|
||||
// session starts.
|
||||
saveJob {
|
||||
logD("Committing saved state")
|
||||
persistenceRepository.saveState(playbackManager.toSavedState())
|
||||
withContext(Dispatchers.Main) {
|
||||
// User could feasibly start playing again if they were fast enough, so
|
||||
// we need to avoid stopping the foreground state if that's the case.
|
||||
if (!player.isPlaying) {
|
||||
hasPlayed = false
|
||||
playbackManager.playing(false)
|
||||
foregroundManager.tryStopForeground()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require
|
||||
* an active [IntentFilter] to be registered.
|
||||
*/
|
||||
private inner class PlaybackReceiver : BroadcastReceiver() {
|
||||
private var initialHeadsetPlugEventHandled = false
|
||||
|
||||
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 ---
|
||||
ACTION_PLAY_PAUSE -> {
|
||||
logD("Received play event")
|
||||
playbackManager.playing(!playbackManager.progression.isPlaying)
|
||||
}
|
||||
ACTION_INC_REPEAT_MODE -> {
|
||||
logD("Received repeat mode event")
|
||||
playbackManager.repeatMode(playbackManager.repeatMode.increment())
|
||||
}
|
||||
ACTION_INVERT_SHUFFLE -> {
|
||||
logD("Received shuffle event")
|
||||
playbackManager.shuffled(!playbackManager.isShuffled)
|
||||
}
|
||||
ACTION_SKIP_PREV -> {
|
||||
logD("Received skip previous event")
|
||||
playbackManager.prev()
|
||||
}
|
||||
ACTION_SKIP_NEXT -> {
|
||||
logD("Received skip next event")
|
||||
playbackManager.next()
|
||||
}
|
||||
ACTION_EXIT -> {
|
||||
logD("Received exit event")
|
||||
playbackManager.playing(false)
|
||||
endSession()
|
||||
}
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> {
|
||||
logD("Received widget update event")
|
||||
widgetComponent.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playFromHeadsetPlug() {
|
||||
// ACTION_HEADSET_PLUG will fire when this BroadcastReciever 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SAVE_BUFFER = 5000L
|
||||
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"
|
||||
}
|
||||
}
|
|
@ -56,11 +56,11 @@ interface SearchEngine {
|
|||
* @param playlists A list of [Playlist], null if empty.
|
||||
*/
|
||||
data class Items(
|
||||
val songs: Collection<Song>?,
|
||||
val albums: Collection<Album>?,
|
||||
val artists: Collection<Artist>?,
|
||||
val genres: Collection<Genre>?,
|
||||
val playlists: Collection<Playlist>?
|
||||
val songs: Collection<Song>? = null,
|
||||
val albums: Collection<Album>? = null,
|
||||
val artists: Collection<Artist>? = null,
|
||||
val genres: Collection<Genre>? = null,
|
||||
val playlists: Collection<Playlist>? = null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,78 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* ForegroundManager.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.service
|
||||
|
||||
import android.app.Service
|
||||
import androidx.core.app.ServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A utility to create consistent foreground behavior for a given [Service].
|
||||
*
|
||||
* @param service [Service] to wrap in this instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Merge with unified service when done.
|
||||
*/
|
||||
class ForegroundManager(private val service: Service) {
|
||||
private var isForeground = false
|
||||
|
||||
/** Release this instance. */
|
||||
fun release() {
|
||||
tryStopForeground()
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to enter a foreground state.
|
||||
*
|
||||
* @param notification The [ForegroundServiceNotification] to show in order to signal the
|
||||
* foreground state.
|
||||
* @return true if the state was changed, false otherwise
|
||||
* @see Service.startForeground
|
||||
*/
|
||||
fun tryStartForeground(notification: ForegroundServiceNotification): Boolean {
|
||||
if (isForeground) {
|
||||
// Nothing to do.
|
||||
return false
|
||||
}
|
||||
|
||||
logD("Starting foreground state")
|
||||
service.startForeground(notification.code, notification.build())
|
||||
isForeground = true
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to exit a foreground state. Will remove the foreground notification.
|
||||
*
|
||||
* @return true if the state was changed, false otherwise
|
||||
* @see Service.stopForeground
|
||||
*/
|
||||
fun tryStopForeground(): Boolean {
|
||||
if (!isForeground) {
|
||||
// Nothing to do.
|
||||
return false
|
||||
}
|
||||
|
||||
logD("Stopping foreground state")
|
||||
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
isForeground = false
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* ForegroundServiceNotification.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.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/** Post this notification using [NotificationManagerCompat]. */
|
||||
fun post() {
|
||||
// This is safe to call without the POST_NOTIFICATIONS permission, as it's a foreground
|
||||
// notification.
|
||||
@Suppress("MissingPermission") notificationManager.notify(code, build())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
|
@ -68,9 +68,6 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
|||
binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) }
|
||||
binding.aboutProfile.setOnClickListener { requireContext().openInBrowser(LINK_PROFILE) }
|
||||
binding.aboutDonate.setOnClickListener { requireContext().openInBrowser(LINK_DONATE) }
|
||||
binding.aboutSupporterYrliet.setOnClickListener {
|
||||
requireContext().openInBrowser(LINK_YRLIET)
|
||||
}
|
||||
binding.aboutSupportersPromo.setOnClickListener {
|
||||
requireContext().openInBrowser(LINK_DONATE)
|
||||
}
|
||||
|
@ -100,6 +97,5 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
|||
const val LINK_LICENSES = "$LINK_WIKI/Licenses"
|
||||
const val LINK_PROFILE = "https://github.com/OxygenCobalt"
|
||||
const val LINK_DONATE = "https://github.com/sponsors/OxygenCobalt"
|
||||
const val LINK_YRLIET = "https://github.com/yrliet"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
|
||||
/**
|
||||
* A wrapper around [StateFlow] exposing a one-time consumable event.
|
||||
|
@ -166,7 +167,13 @@ suspend fun <E> SendChannel<E>.sendWithTimeout(element: E, timeout: Long = DEFAU
|
|||
try {
|
||||
withTimeout(timeout) { send(element) }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
throw TimeoutException("Timed out sending element $element to channel: $e")
|
||||
logE("Failed to send element to channel $e in ${timeout}ms.")
|
||||
if (BuildConfig.DEBUG) {
|
||||
throw TimeoutException("Timed out sending element to channel: $e")
|
||||
} else {
|
||||
logE(e.stackTraceToString())
|
||||
send(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -203,7 +210,13 @@ suspend fun <E> ReceiveChannel<E>.forEachWithTimeout(
|
|||
subsequent = true
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
throw TimeoutException("Timed out receiving element from channel: $e")
|
||||
logE("Failed to send element to channel $e in ${timeout}ms.")
|
||||
if (BuildConfig.DEBUG) {
|
||||
throw TimeoutException("Timed out sending element to channel: $e")
|
||||
} else {
|
||||
logE(e.stackTraceToString())
|
||||
handler()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,8 +31,8 @@ import android.widget.RemoteViews
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.service.PlaybackActions
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.system.PlaybackService
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
@ -339,7 +339,7 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
// by PlaybackService.
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_play_pause,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_PLAY_PAUSE))
|
||||
context.newBroadcastPendingIntent(PlaybackActions.ACTION_PLAY_PAUSE))
|
||||
|
||||
// Set up the play/pause button appearance. Like the Android 13 media controls, use
|
||||
// a circular FAB when paused, and a squircle FAB when playing. This does require us
|
||||
|
@ -380,10 +380,10 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
// by PlaybackService.
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_skip_prev,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_PREV))
|
||||
context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_PREV))
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_skip_next,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_NEXT))
|
||||
context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_NEXT))
|
||||
return this
|
||||
}
|
||||
|
||||
|
@ -405,10 +405,10 @@ class WidgetProvider : AppWidgetProvider() {
|
|||
// be recognized by PlaybackService.
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_repeat,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_INC_REPEAT_MODE))
|
||||
context.newBroadcastPendingIntent(PlaybackActions.ACTION_INC_REPEAT_MODE))
|
||||
setOnClickPendingIntent(
|
||||
R.id.widget_shuffle,
|
||||
context.newBroadcastPendingIntent(PlaybackService.ACTION_INVERT_SHUFFLE))
|
||||
context.newBroadcastPendingIntent(PlaybackActions.ACTION_INVERT_SHUFFLE))
|
||||
|
||||
// Set up the repeat/shuffle buttons. When working with RemoteViews, we will
|
||||
// need to hard-code different accent tinting configurations, as stateful drawables
|
||||
|
|
BIN
app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png
Normal file
After Width: | Height: | Size: 676 B |
BIN
app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png
Normal file
After Width: | Height: | Size: 531 B |
BIN
app/src/main/res/drawable-hdpi/ic_genre_bitmap_24.png
Normal file
After Width: | Height: | Size: 432 B |
BIN
app/src/main/res/drawable-hdpi/ic_playlist_bitmap_24.png
Normal file
After Width: | Height: | Size: 259 B |
BIN
app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png
Normal file
After Width: | Height: | Size: 291 B |
BIN
app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png
Normal file
After Width: | Height: | Size: 375 B |
BIN
app/src/main/res/drawable-mdpi/ic_artist_bitmap_24.png
Normal file
After Width: | Height: | Size: 313 B |
BIN
app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png
Normal file
After Width: | Height: | Size: 241 B |
BIN
app/src/main/res/drawable-mdpi/ic_playlist_bitmap_24.png
Normal file
After Width: | Height: | Size: 182 B |
BIN
app/src/main/res/drawable-mdpi/ic_song_bitmap_24.png
Normal file
After Width: | Height: | Size: 175 B |
Before Width: | Height: | Size: 133 KiB |
BIN
app/src/main/res/drawable-nodpi/ui_widget_preview.webp
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
app/src/main/res/drawable-xhdpi/ic_album_bitmap_24.png
Normal file
After Width: | Height: | Size: 894 B |
BIN
app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png
Normal file
After Width: | Height: | Size: 642 B |
BIN
app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png
Normal file
After Width: | Height: | Size: 422 B |
BIN
app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png
Normal file
After Width: | Height: | Size: 328 B |
BIN
app/src/main/res/drawable-xhdpi/ic_song_bitmap_24.png
Normal file
After Width: | Height: | Size: 326 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_artist_bitmap_24.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_genre_bitmap_24.png
Normal file
After Width: | Height: | Size: 812 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_playlist_bitmap_24.png
Normal file
After Width: | Height: | Size: 559 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_song_bitmap_24.png
Normal file
After Width: | Height: | Size: 560 B |
BIN
app/src/main/res/drawable-xxxhdpi/ic_album_bitmap_24.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_genre_bitmap_24.png
Normal file
After Width: | Height: | Size: 784 B |
BIN
app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png
Normal file
After Width: | Height: | Size: 672 B |
BIN
app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png
Normal file
After Width: | Height: | Size: 687 B |
5
app/src/main/res/drawable/ui_selection_bg.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/sel_selection_bg" />
|
||||
<item android:drawable="@drawable/ui_item_ripple" />
|
||||
</layer-list>
|
|
@ -224,18 +224,6 @@
|
|||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/about_supporter_yrliet"
|
||||
style="@style/Widget.Auxio.TextView.Icon.Clickable"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/sup_yrliet"
|
||||
app:drawableStartCompat="@drawable/ic_person_24"
|
||||
app:drawableTint="?attr/colorControlNormal"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/about_licenses" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/about_supporters_promo"
|
||||
style="@style/Widget.Auxio.TextView.Icon.Clickable"
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
android:id="@+id/interact_body"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/ui_item_ripple">
|
||||
android:background="@drawable/ui_selection_bg">
|
||||
|
||||
<org.oxycblt.auxio.image.CoverView
|
||||
android:id="@+id/song_album_cover"
|
||||
|
|
Before Width: | Height: | Size: 4.7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 3 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 6.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
After Width: | Height: | Size: 10 KiB |
|
@ -16,7 +16,7 @@
|
|||
<string name="set_separators_comma">Коска (,)</string>
|
||||
<string name="set_separators_plus">Плюс (+)</string>
|
||||
<string name="set_separators_and">Амперсанд (&)</string>
|
||||
<string name="set_dirs_mode_include">Уключэнні</string>
|
||||
<string name="set_dirs_mode_include">Ўключыць</string>
|
||||
<string name="set_ui">Афармленне</string>
|
||||
<string name="lbl_retry">Паўтарыць</string>
|
||||
<string name="lng_search_library">Пошук у вашай бібліятэцы…</string>
|
||||
|
@ -27,7 +27,7 @@
|
|||
<string name="lbl_grant">Выдаць</string>
|
||||
<string name="lbl_songs">Песні</string>
|
||||
<string name="set_ui_desc">Змяніце тэму і колеры праграмы</string>
|
||||
<string name="lbl_all_songs">Усе песні</string>
|
||||
<string name="lbl_all_songs">Ўсе песні</string>
|
||||
<string name="lbl_albums">Альбомы</string>
|
||||
<string name="lbl_album">Альбом</string>
|
||||
<string name="lbl_album_live">Жывы альбом</string>
|
||||
|
@ -58,7 +58,7 @@
|
|||
<string name="lbl_genre">Жанр</string>
|
||||
<string name="lbl_search">Пошук</string>
|
||||
<string name="lbl_filter">Фільтр</string>
|
||||
<string name="lbl_filter_all">Усе</string>
|
||||
<string name="lbl_filter_all">Ўсе</string>
|
||||
<string name="lbl_name">Назва</string>
|
||||
<string name="lbl_genres">Жанры</string>
|
||||
<string name="lbl_sort">Сартаваць</string>
|
||||
|
@ -84,7 +84,7 @@
|
|||
<string name="lbl_album_details">Перайсці да альбома</string>
|
||||
<string name="lbl_artist_details">Перайсці да выканаўцы</string>
|
||||
<string name="lbl_song_detail">Праглядзіце ўласцівасці</string>
|
||||
<string name="lbl_props">Уласцівасці песні</string>
|
||||
<string name="lbl_props">Ўласцівасці песні</string>
|
||||
<string name="lbl_format">Фармат</string>
|
||||
<string name="lbl_shuffle_shortcut_long">Перамяшаць усё</string>
|
||||
<string name="lbl_bitrate">Бітрэйт</string>
|
||||
|
@ -121,8 +121,8 @@
|
|||
<string name="set_dirs_list">Тэчкі</string>
|
||||
<string name="set_dirs_mode">Рэжым</string>
|
||||
<string name="set_dirs_mode_exclude">Выключэнні</string>
|
||||
<string name="set_dirs_mode_exclude_desc">Музыка <b>не</b> будзе загружана з папак, якія вы дадасце.</string>
|
||||
<string name="set_dirs_mode_include_desc">Музыка будзе загружацца <b>толькі</b> з папак, якія вы дадасце.</string>
|
||||
<string name="set_dirs_mode_exclude_desc">Музыка <b>не</b> будзе загружана з выбраных тэчак.</string>
|
||||
<string name="set_dirs_mode_include_desc">Музыка будзе загружана <b>толькі</b> з выбраных тэчак.</string>
|
||||
<string name="set_rescan">Перасканаваць музыку</string>
|
||||
<string name="set_reindex">Абнавіць музыку</string>
|
||||
<string name="set_reindex_desc">Перазагрузіце музычную бібліятэку, выкарыстоўваючы па магчымасці кэшаваныя тэгі</string>
|
||||
|
@ -148,7 +148,7 @@
|
|||
<string name="desc_skip_prev">Перайсці да апошняй песні</string>
|
||||
<string name="desc_change_repeat">Змяніць рэжым паўтору</string>
|
||||
<string name="desc_auxio_icon">Значок Auxio</string>
|
||||
<string name="desc_shuffle">Уключыце або выключыце перамешванне</string>
|
||||
<string name="desc_shuffle">Ўключыце або выключыце перамешванне</string>
|
||||
<string name="desc_remove_song">Выдаліць гэтую песню з чаргі</string>
|
||||
<string name="desc_shuffle_all">Перамяшаць усе песні</string>
|
||||
<string name="desc_exit">Спыніць прайграванне</string>
|
||||
|
@ -221,11 +221,11 @@
|
|||
<string name="cdc_aac">Прасунуты аўдыё кодэк (AAC)</string>
|
||||
<string name="fmt_list">%1$s, %2$s</string>
|
||||
<string name="cdc_flac">Свабодны аўдыё кодэк без страты якасці (FLAC)</string>
|
||||
<string name="set_exclude_non_music">Уключыць не-музыку</string>
|
||||
<string name="set_round_mode_desc">Уключыць закругленыя вуглы на дадатковых элементах інтэрфейсу (патрабуецца закругленне вокладак альбомаў)</string>
|
||||
<string name="set_exclude_non_music">Выключыць іншыя гукавыя файлы</string>
|
||||
<string name="set_round_mode_desc">Ўключыць закругленыя вуглы на дадатковых элементах інтэрфейсу (патрабуецца закругленне вокладак альбомаў)</string>
|
||||
<string name="set_personalize_desc">Наладзьце элементы кіравання і паводзіны карыстацкага інтэрфейсу</string>
|
||||
<string name="set_display">Экран</string>
|
||||
<string name="set_lib_tabs">Укладкі бібліятэкі</string>
|
||||
<string name="set_lib_tabs">Ўкладкі бібліятэкі</string>
|
||||
<string name="set_lib_tabs_desc">Змяніць бачнасць і парадак укладак бібліятэкі</string>
|
||||
<string name="set_action_mode_next">Перайсці да наступнага</string>
|
||||
<string name="set_action_mode_repeat">Рэжым паўтору</string>
|
||||
|
@ -240,7 +240,7 @@
|
|||
<string name="set_play_song_from_artist">Гуляць ад выканаўцы</string>
|
||||
<string name="set_play_song_from_genre">Гуляць з жанру</string>
|
||||
<string name="set_keep_shuffle">Запамінаць перамешванне</string>
|
||||
<string name="set_keep_shuffle_desc">Уключайце перамешванне падчас прайгравання новай песні</string>
|
||||
<string name="set_keep_shuffle_desc">Ўключайце перамешванне падчас прайгравання новай песні</string>
|
||||
<string name="set_content">Кантэнт</string>
|
||||
<string name="set_music">Музыка</string>
|
||||
<string name="set_observing">Аўтаматычная перазагрузка</string>
|
||||
|
|