Merge pull request #806 from OxygenCobalt/3.5.0

Version 3.5.0
This commit is contained in:
Alexander Capehart 2024-06-20 22:47:59 -06:00 committed by GitHub
commit 9cc5582483
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
124 changed files with 3743 additions and 2328 deletions

1
.github/FUNDING.yml vendored
View file

@ -1 +1,2 @@
github: [OxygenCobalt] github: [OxygenCobalt]
custom: ["https://paypal.me/oxycblt"]

View file

@ -57,6 +57,13 @@ body:
placeholder: OnePlus 7T (LineageOS) placeholder: OnePlus 7T (LineageOS)
validations: validations:
required: true 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 - type: textarea
id: logs id: logs
attributes: attributes:

View file

@ -2,9 +2,9 @@ name: Android CI
on: on:
push: push:
branches: [ "dev" ] branches: []
pull_request: pull_request:
branches: [ "dev" ] branches: []
jobs: jobs:
build: build:
@ -14,7 +14,7 @@ jobs:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Clone submodules - name: Clone submodules
run: git submodule update --init --recursive run: git submodule update --init --recursive --remote
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v3
with: with:

View file

@ -1,6 +1,43 @@
# Changelog # 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 #### What's Improved
- Added back option disable ReplayGain for poorly tagged libraries - Added back option disable ReplayGain for poorly tagged libraries

View file

@ -21,8 +21,8 @@ android {
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "3.4.3" versionName "3.5.0"
versionCode 44 versionCode 46
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
@ -101,7 +101,7 @@ dependencies {
implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "androidx.viewpager2:viewpager2:1.0.0"
// Lifecycle // 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:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@ -126,6 +126,7 @@ dependencies {
// --- THIRD PARTY --- // --- THIRD PARTY ---
// Exoplayer (Vendored) // Exoplayer (Vendored)
implementation project(":media-lib-session")
implementation project(":media-lib-exoplayer") implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg") implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
@ -154,7 +155,7 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2" testImplementation "junit:junit:4.13.2"
testImplementation "io.mockk:mockk:1.13.7" 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' testImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

View file

@ -37,6 +37,12 @@
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
tools:ignore="UnusedAttribute"> 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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@ -77,33 +83,28 @@
</intent-filter> </intent-filter>
</activity> </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 handling music playback, system components, and state saving.
--> -->
<service <service
android:name=".playback.system.PlaybackService" android:name=".AuxioService"
android:foregroundServiceType="mediaPlayback" android:foregroundServiceType="mediaPlayback"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:exported="false" android:exported="true"
android:roundIcon="@mipmap/ic_launcher" /> 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. Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
See the class for more info. See the class for more info.
--> -->
<receiver <receiver
android:name=".playback.system.MediaButtonReceiver" android:name=".playback.service.MediaButtonReceiver"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" /> <action android:name="android.intent.action.MEDIA_BUTTON" />
@ -133,5 +134,6 @@
android:name="android.appwidget.provider" android:name="android.appwidget.provider"
android:resource="@xml/widget_info" /> android:resource="@xml/widget_info" />
</receiver> </receiver>
</application> </application>
</manifest> </manifest>

View 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
}
}

View file

@ -133,4 +133,7 @@ object IntegerTable {
const val PLAY_SONG_FROM_PLAYLIST = 0xA123 const val PLAY_SONG_FROM_PLAYLIST = 0xA123
/** PlaySong.ByItself */ /** PlaySong.ByItself */
const val PLAY_SONG_BY_ITSELF = 0xA124 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
} }

View file

@ -29,10 +29,8 @@ import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.databinding.ActivityMainBinding import org.oxycblt.auxio.databinding.ActivityMainBinding
import org.oxycblt.auxio.music.system.IndexerService
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -71,8 +69,9 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
startService(Intent(this, IndexerService::class.java)) startService(
startService(Intent(this, PlaybackService::class.java)) Intent(this, AuxioService::class.java)
.putExtra(AuxioService.INTENT_KEY_NATIVE_START, true))
if (!startIntentAction(intent)) { if (!startIntentAction(intent)) {
// No intent action to do, just restore the previously saved state. // No intent action to do, just restore the previously saved state.

View file

@ -94,7 +94,7 @@ constructor(
target target
.onConfigRequest( .onConfigRequest(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(listOf(song)) .data(listOf(song.cover))
// Use ORIGINAL sizing, as we are not loading into any View-like component. // Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL)) .size(Size.ORIGINAL))
.target( .target(

View file

@ -48,6 +48,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R 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.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation import org.oxycblt.auxio.image.extractor.SquareCropTransformation
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -101,14 +102,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val indicatorMatrixSrc = RectF() private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = 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 { init {
// Obtain some StyledImageView attributes to use later when theming the custom view. // Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable") @SuppressLint("CustomViewStyleable")
@ -342,8 +335,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param song The [Song] to bind to the view. * @param song The [Song] to bind to the view.
*/ */
fun bind(song: Song) = fun bind(song: Song) =
bind( bindImpl(
listOf(song), listOf(song.cover),
context.getString(R.string.desc_album_cover, song.album.name), context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24) 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. * @param album The [Album] to bind to the view.
*/ */
fun bind(album: Album) = fun bind(album: Album) =
bind( bindImpl(
album.songs, album.cover.all,
context.getString(R.string.desc_album_cover, album.name), context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24) 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. * @param artist The [Artist] to bind to the view.
*/ */
fun bind(artist: Artist) = fun bind(artist: Artist) =
bind( bindImpl(
artist.songs, artist.cover.all,
context.getString(R.string.desc_artist_image, artist.name), context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24) 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. * @param genre The [Genre] to bind to the view.
*/ */
fun bind(genre: Genre) = fun bind(genre: Genre) =
bind( bindImpl(
genre.songs, genre.cover.all,
context.getString(R.string.desc_genre_image, genre.name), context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24) R.drawable.ic_genre_24)
@ -386,8 +379,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param playlist the [Playlist] to bind. * @param playlist the [Playlist] to bind.
*/ */
fun bind(playlist: Playlist) = fun bind(playlist: Playlist) =
bind( bindImpl(
playlist.songs, playlist.cover?.all ?: emptyList(),
context.getString(R.string.desc_playlist_image, playlist.name), context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24) 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 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. * @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 = val request =
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(songs) .data(covers)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes)) .error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
.target(image) .target(image)
@ -417,7 +413,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
CoilUtils.dispose(image) CoilUtils.dispose(image)
imageLoader.enqueue(request.build()) imageLoader.enqueue(request.build())
contentDescription = desc contentDescription = desc
currentCover = Cover(songs, desc, errorRes)
} }
/** /**

View file

@ -24,25 +24,23 @@ import coil.key.Keyer
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.Song
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) : class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
Keyer<Collection<Song>> { override fun key(data: Collection<Cover>, options: Options) =
override fun key(data: Collection<Song>, options: Options) = "${data.map { it.key }.hashCode()}"
"${coverExtractor.computeCoverOrdering(data).hashCode()}"
} }
class SongCoverFetcher class CoverFetcher
private constructor( private constructor(
private val songs: Collection<Song>, private val covers: Collection<Cover>,
private val size: Size, private val size: Size,
private val coverExtractor: CoverExtractor, private val coverExtractor: CoverExtractor,
) : Fetcher { ) : 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) : class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Collection<Song>> { Fetcher.Factory<Collection<Cover>> {
override fun create(data: Collection<Song>, options: Options, imageLoader: ImageLoader) = override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
SongCoverFetcher(data, options.size, coverExtractor) CoverFetcher(data, options.size, coverExtractor)
} }
} }

View 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))
}
}

View file

@ -27,6 +27,7 @@ import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.exoplayer.MetadataRetriever import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame import androidx.media3.extractor.metadata.flac.PictureFrame
@ -50,8 +51,6 @@ import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings 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.music.Song
import org.oxycblt.auxio.util.logE 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. * 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. * @param size The [Size] of the image to load.
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult] * @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 * will be returned of a mosaic composed of four album covers ordered by
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned. * [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
*/ */
suspend fun extract(songs: Collection<Song>, size: Size): FetchResult? { suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
val albums = computeCoverOrdering(songs)
val streams = mutableListOf<InputStream>() val streams = mutableListOf<InputStream>()
for (album in albums) { for (cover in covers) {
openCoverInputStream(album)?.let(streams::add) openCoverInputStream(cover)?.let(streams::add)
// We don't immediately check for mosaic feasibility from album count alone, as that // 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 // does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use. // definitely have image data to use.
@ -108,71 +106,7 @@ constructor(
dataSource = DataSource.DISK) dataSource = DataSource.DISK)
} }
/** fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
* 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
}
var stream: ByteArrayInputStream? = null var stream: ByteArrayInputStream? = null
for (i in 0 until metadata.length()) { for (i in 0 until metadata.length()) {
@ -204,10 +138,62 @@ constructor(
return stream 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 // Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(album.coverUri.mediaStore) context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
} }
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */ /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */

View 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)
}

View file

@ -35,14 +35,14 @@ class ExtractorModule {
@Provides @Provides
fun imageLoader( fun imageLoader(
@ApplicationContext context: Context, @ApplicationContext context: Context,
songKeyer: SongKeyer, keyer: CoverKeyer,
songFactory: SongCoverFetcher.Factory factory: CoverFetcher.Factory
) = ) =
ImageLoader.Builder(context) ImageLoader.Builder(context)
.components { .components {
// Add fetchers for Music components to make them usable with ImageRequest // Add fetchers for Music components to make them usable with ImageRequest
add(songKeyer) add(keyer)
add(songFactory) add(factory)
} }
// Use our own crossfade with error drawable support // Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory()) .transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -25,6 +25,10 @@ import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView 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.R
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
@ -53,6 +57,27 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
0 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( final override fun onChildDraw(
c: Canvas, c: Canvas,
recyclerView: RecyclerView, recyclerView: RecyclerView,
@ -150,4 +175,9 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
/** The drawable of the [body] background that can be elevated. */ /** The drawable of the [body] background that can be elevated. */
val background: Drawable val background: Drawable
} }
companion object {
const val MINIMUM_INITIAL_DRAG_VELOCITY = 10
const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25
}
} }

View file

@ -27,7 +27,8 @@ import java.util.UUID
import kotlin.math.max import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize 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.list.Item
import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
@ -246,6 +247,8 @@ interface Song : Music {
* audio file in a way that is scoped-storage-safe. * audio file in a way that is scoped-storage-safe.
*/ */
val uri: Uri 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 * The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file. * instead for accessing the audio file.
@ -293,11 +296,8 @@ interface Album : MusicParent {
* [ReleaseType.Album]. * [ReleaseType.Album].
*/ */
val releaseType: ReleaseType val releaseType: ReleaseType
/** /** Cover information from the template song used for the album. */
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the val cover: ParentCover
* cost of image quality.
*/
val coverUri: CoverUri
/** The duration of all songs in the album, in milliseconds. */ /** The duration of all songs in the album, in milliseconds. */
val durationMs: Long val durationMs: Long
/** The earliest date a song in this album was added, as a unix epoch timestamp. */ /** The earliest date a song in this album was added, as a unix epoch timestamp. */
@ -326,6 +326,8 @@ interface Artist : MusicParent {
* songs. * songs.
*/ */
val durationMs: Long? val durationMs: Long?
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: ParentCover
/** The [Genre]s of this artist. */ /** The [Genre]s of this artist. */
val genres: List<Genre> val genres: List<Genre>
} }
@ -340,6 +342,8 @@ interface Genre : MusicParent {
val artists: Collection<Artist> val artists: Collection<Artist>
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long 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> override val songs: List<Song>
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: ParentCover?
} }
/** /**

View file

@ -206,7 +206,7 @@ interface MusicRepository {
/** A persistent worker that can load music in the background. */ /** A persistent worker that can load music in the background. */
interface IndexingWorker { interface IndexingWorker {
/** A [Context] required to read device storage */ /** A [Context] required to read device storage */
val context: Context val workerContext: Context
/** The [CoroutineScope] to perform coroutine music loading work on. */ /** The [CoroutineScope] to perform coroutine music loading work on. */
val scope: CoroutineScope val scope: CoroutineScope
@ -343,7 +343,7 @@ constructor(
} }
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) = 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) { private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
try { try {

View file

@ -164,7 +164,10 @@ constructor(
} }
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch 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()) { if (songs.isEmpty()) {
logE("No songs found") logE("No songs found")

View file

@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped import org.oxycblt.auxio.music.metadata.splitEscaped
@Database(entities = [CachedSong::class], version = 42, exportSchema = false) @Database(entities = [CachedSong::class], version = 46, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao abstract fun cachedSongsDao(): CachedSongsDao
} }
@ -80,6 +80,8 @@ data class CachedSong(
var subtitle: String? = null, var subtitle: String? = null,
/** @see RawSong.date */ /** @see RawSong.date */
var date: Date? = null, var date: Date? = null,
/** @see RawSong.coverPerceptualHash */
var coverPerceptualHash: String? = null,
/** @see RawSong.albumMusicBrainzId */ /** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null, var albumMusicBrainzId: String? = null,
/** @see RawSong.albumName */ /** @see RawSong.albumName */
@ -119,6 +121,8 @@ data class CachedSong(
rawSong.subtitle = subtitle rawSong.subtitle = subtitle
rawSong.date = date rawSong.date = date
rawSong.coverPerceptualHash = coverPerceptualHash
rawSong.albumMusicBrainzId = albumMusicBrainzId rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName rawSong.albumName = albumName
rawSong.albumSortName = albumSortName rawSong.albumSortName = albumSortName
@ -167,6 +171,7 @@ data class CachedSong(
disc = rawSong.disc, disc = rawSong.disc,
subtitle = rawSong.subtitle, subtitle = rawSong.subtitle,
date = rawSong.date, date = rawSong.date,
coverPerceptualHash = rawSong.coverPerceptualHash,
albumMusicBrainzId = rawSong.albumMusicBrainzId, albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName, albumSortName = rawSong.albumSortName,

View file

@ -19,7 +19,8 @@
package org.oxycblt.auxio.music.device package org.oxycblt.auxio.music.device
import org.oxycblt.auxio.R 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.list.sort.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -28,8 +29,9 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.MimeType 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.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.Date
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.Name
@ -112,6 +114,20 @@ class SongImpl(
override val genres: List<Genre> override val genres: List<Genre>
get() = _genres 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 * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
* [Album]. * [Album].
@ -291,9 +307,9 @@ class AlbumImpl(
override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName) override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
override val dates: Date.Range? override val dates: Date.Range?
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) 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 durationMs: Long
override val dateAdded: Long override val dateAdded: Long
override val cover: ParentCover
private val _artists = mutableListOf<ArtistImpl>() private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist> override val artists: List<Artist>
@ -337,6 +353,8 @@ class AlbumImpl(
durationMs = totalDuration durationMs = totalDuration
dateAdded = earliestDateAdded dateAdded = earliestDateAdded
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawAlbum.hashCode() hashCode = 31 * hashCode + rawAlbum.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode() hashCode = 31 * hashCode + songs.hashCode()
@ -419,6 +437,7 @@ class ArtistImpl(
override val explicitAlbums: Set<Album> override val explicitAlbums: Set<Album>
override val implicitAlbums: Set<Album> override val implicitAlbums: Set<Album>
override val durationMs: Long? override val durationMs: Long?
override val cover: ParentCover
override lateinit var genres: List<Genre> override lateinit var genres: List<Genre>
@ -451,6 +470,14 @@ class ArtistImpl(
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true } implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
durationMs = songs.sumOf { it.durationMs }.positiveOrNull() 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 + rawArtist.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode() hashCode = 31 * hashCode + songs.hashCode()
@ -528,6 +555,7 @@ class GenreImpl(
override val songs: Set<Song> override val songs: Set<Song>
override val artists: Set<Artist> override val artists: Set<Artist>
override val durationMs: Long override val durationMs: Long
override val cover: ParentCover
private var hashCode = uid.hashCode() private var hashCode = uid.hashCode()
@ -545,6 +573,8 @@ class GenreImpl(
artists = distinctArtists artists = distinctArtists
durationMs = totalDuration durationMs = totalDuration
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawGenre.hashCode() hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode() hashCode = 31 * hashCode + songs.hashCode()

View file

@ -67,6 +67,8 @@ data class RawSong(
var subtitle: String? = null, var subtitle: String? = null,
/** @see Song.date */ /** @see Song.date */
var date: Date? = null, var date: Date? = null,
/** @see Song.cover */
var coverPerceptualHash: String? = null,
/** @see RawAlbum.mediaStoreId */ /** @see RawAlbum.mediaStoreId */
var albumMediaStoreId: Long? = null, var albumMediaStoreId: Long? = null,
/** @see RawAlbum.musicBrainzId */ /** @see RawAlbum.musicBrainzId */

View file

@ -76,7 +76,9 @@ data class ExportConfig(val absolute: Boolean, val windowsPaths: Boolean)
* @see ExternalPlaylistManager * @see ExternalPlaylistManager
* @see M3U * @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 class ExternalPlaylistManagerImpl
@Inject @Inject

View file

@ -29,9 +29,12 @@ import javax.inject.Inject
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.fs.Components import org.oxycblt.auxio.music.fs.Components
import org.oxycblt.auxio.music.fs.Path 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.metadata.correctWhitespace
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* Minimal M3U file format implementation. * 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? { override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
val volumes = volumeManager.getVolumes()
val reader = BufferedReader(InputStreamReader(stream)) val reader = BufferedReader(InputStreamReader(stream))
val paths = mutableListOf<Path>() val paths = mutableListOf<PossiblePaths>()
var name: String? = null var name: String? = null
consumeFile@ while (true) { 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 // 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 // based on the programs that generated it. I more or less have to consider any possible
// programs will generate. // interpretation as valid.
val components = val interpretations = interpretPath(path)
when { val possibilities =
path.startsWith('/') -> { interpretations.flatMap { expandInterpretation(it, workingDirectory, volumes) }
// 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)
}
}
paths.add(Path(workingDirectory.volume, components)) paths.add(possibilities)
} }
return if (paths.isNotEmpty()) { 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( override fun write(
playlist: Playlist, playlist: Playlist,
outputStream: OutputStream, outputStream: OutputStream,

View file

@ -158,6 +158,8 @@ value class Components private constructor(val components: List<String>) {
return components == other.components.take(components.size) return components == other.components.take(components.size)
} }
fun containing(other: Components) = Components(other.components.drop(components.size))
companion object { companion object {
/** /**
* Parses a path string into a [Components] instance by the unix path separator (/). * Parses a path string into a [Components] instance by the unix path separator (/).

View file

@ -102,7 +102,14 @@ fun Long.toAudioUri() =
* @return An external storage image [Uri]. May not exist. * @return An external storage image [Uri]. May not exist.
* @see ContentUris.withAppendedId * @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 --- // --- STORAGEMANAGER UTILITIES ---
// Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles // Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles

View file

@ -37,9 +37,9 @@ import org.oxycblt.auxio.util.positiveOrNull
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Date private constructor(private val tokens: List<Int>) : Comparable<Date> { class Date private constructor(private val tokens: List<Int>) : Comparable<Date> {
private val year = tokens[0] val year = tokens[0]
private val month = tokens.getOrNull(1) val month = tokens.getOrNull(1)
private val day = tokens.getOrNull(2) val day = tokens.getOrNull(2)
private val hour = tokens.getOrNull(3) private val hour = tokens.getOrNull(3)
private val minute = tokens.getOrNull(4) private val minute = tokens.getOrNull(4)
private val second = tokens.getOrNull(5) private val second = tokens.getOrNull(5)

View file

@ -25,6 +25,8 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray import androidx.media3.exoplayer.source.TrackGroupArray
import java.util.concurrent.Future import java.util.concurrent.Future
import javax.inject.Inject 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.device.RawSong
import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Date
@ -60,7 +62,10 @@ interface TagWorker {
class TagWorkerFactoryImpl class TagWorkerFactoryImpl
@Inject @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 = override fun create(rawSong: RawSong): TagWorker =
// Note that we do not leverage future callbacks. This is because errors in the // 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 // (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( MetadataRetriever.retrieveMetadata(
mediaSourceFactory, mediaSourceFactory,
MediaItem.fromUri( MediaItem.fromUri(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))) requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())),
coverExtractor)
} }
private class TagWorkerImpl( private class TagWorkerImpl(
private val rawSong: RawSong, private val rawSong: RawSong,
private val future: Future<TrackGroupArray> private val future: Future<TrackGroupArray>,
private val coverExtractor: CoverExtractor
) : TagWorker { ) : TagWorker {
override fun poll(): RawSong? { override fun poll(): RawSong? {
if (!future.isDone) { if (!future.isDone) {
@ -98,6 +105,25 @@ private class TagWorkerImpl(
populateWithId3v2(textTags.id3v2) populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis) 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 // 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. // should be using the base gain already. Uncomment if that's not the case.
// if (format.sampleMimeType == MimeTypes.AUDIO_OPUS // if (format.sampleMimeType == MimeTypes.AUDIO_OPUS
@ -127,7 +153,9 @@ private class TagWorkerImpl(
private fun populateWithId3v2(textFrames: Map<String, List<String>>) { private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
// Song // 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["TIT2"]?.let { rawSong.name = it.first() }
textFrames["TSOT"]?.let { rawSong.sortName = it.first() } textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
@ -157,7 +185,9 @@ private class TagWorkerImpl(
?.let { rawSong.date = it } ?.let { rawSong.date = it }
// Album // 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["TALB"]?.let { rawSong.albumName = it.first() }
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() } textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"] (textFrames["TXXX:musicbrainz album type"]
@ -167,7 +197,9 @@ private class TagWorkerImpl(
?.let { rawSong.releaseTypes = it } ?.let { rawSong.releaseTypes = it }
// Artist // 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:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
(textFrames["TXXX:artistssort"] (textFrames["TXXX:artistssort"]
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
@ -175,9 +207,9 @@ private class TagWorkerImpl(
?.let { rawSong.artistSortNames = it } ?.let { rawSong.artistSortNames = it }
// Album artist // Album artist
textFrames["TXXX:musicbrainz album artist id"]?.let { (textFrames["TXXX:musicbrainz album artist id"]
rawSong.albumArtistMusicBrainzIds = it ?: textFrames["TXXX:musicbrainz_albumartistid"])
} ?.let { rawSong.albumArtistMusicBrainzIds = it }
(textFrames["TXXX:albumartists"] (textFrames["TXXX:albumartists"]
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
?: textFrames["TPE2"]) ?: textFrames["TPE2"])
@ -248,7 +280,9 @@ private class TagWorkerImpl(
private fun populateWithVorbis(comments: Map<String, List<String>>) { private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song // 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["title"]?.let { rawSong.name = it.first() }
comments["titlesort"]?.let { rawSong.sortName = it.first() } comments["titlesort"]?.let { rawSong.sortName = it.first() }
@ -277,20 +311,28 @@ private class TagWorkerImpl(
?.let { rawSong.date = it } ?.let { rawSong.date = it }
// Album // 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["album"]?.let { rawSong.albumName = it.first() }
comments["albumsort"]?.let { rawSong.albumSortName = 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 // 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["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artistssort"] (comments["artistssort"]
?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"]) ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"])
?.let { rawSong.artistSortNames = it } ?.let { rawSong.artistSortNames = it }
// Album artist // Album artist
comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it } (comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let {
rawSong.albumArtistMusicBrainzIds = it
}
(comments["albumartists"] (comments["albumartists"]
?: comments["album_artists"] ?: comments["album artists"] ?: comments["album_artists"] ?: comments["album artists"]
?: comments["albumartist"]) ?: comments["albumartist"])
@ -347,6 +389,8 @@ private class TagWorkerImpl(
first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull() first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull()
private companion object { private companion object {
val COVER_KEY_SAMPLE = 32
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists") val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
val COMPILATION_RELEASE_TYPES = listOf("compilation") val COMPILATION_RELEASE_TYPES = listOf("compilation")

View file

@ -16,27 +16,68 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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.content.Context
import android.os.SystemClock import android.os.SystemClock
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.IndexingProgress import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.service.ForegroundServiceNotification
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent 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. * @param context [Context] required to create the notification.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class IndexingNotification(private val context: Context) : class IndexingNotification(private val context: Context) :
ForegroundServiceNotification(context, indexerChannel) { IndexerNotification(context, indexerChannel) {
private var lastUpdateTime = -1L private var lastUpdateTime = -1L
init { init {
@ -92,13 +133,12 @@ class IndexingNotification(private val context: Context) :
} }
/** /**
* A static [ForegroundServiceNotification] that signals to the user that the app is currently * A static [IndexerNotification] that signals to the user that the app is currently monitoring the
* monitoring the music library for changes. * music library for changes.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ObservingNotification(context: Context) : class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) {
ForegroundServiceNotification(context, indexerChannel) {
init { init {
setSmallIcon(R.drawable.ic_indexer_24) setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE) setCategory(NotificationCompat.CATEGORY_SERVICE)
@ -116,5 +156,5 @@ class ObservingNotification(context: Context) :
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val indexerChannel = private val indexerChannel =
ForegroundServiceNotification.ChannelInfo( IndexerNotification.ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.music.user package org.oxycblt.auxio.music.user
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
@ -46,6 +47,8 @@ private constructor(
override fun toString() = "Playlist(uid=$uid, name=$name)" 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]. * Clone the data in this instance to a new [PlaylistImpl] with the given [name].
* *

View file

@ -32,15 +32,16 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.persist.PersistenceRepository
import org.oxycblt.auxio.playback.state.DeferredPlayback 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.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.Progression
import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.playback.state.RepeatMode 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.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -59,8 +60,8 @@ constructor(
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings,
private val persistenceRepository: PersistenceRepository, private val persistenceRepository: PersistenceRepository,
private val commandFactory: PlaybackCommand.Factory,
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val musicRepository: MusicRepository,
) : ViewModel(), PlaybackStateManager.Listener, PlaybackSettings.Listener { ) : ViewModel(), PlaybackStateManager.Listener, PlaybackSettings.Listener {
private var lastPositionJob: Job? = null private var lastPositionJob: Job? = null
@ -189,21 +190,21 @@ constructor(
fun play(song: Song, with: PlaySong) { fun play(song: Song, with: PlaySong) {
logD("Playing $song with $with") logD("Playing $song with $with")
playWithImpl(song, with, isImplicitlyShuffled()) playWithImpl(song, with, ShuffleMode.IMPLICIT)
} }
fun playExplicit(song: Song, with: PlaySong) { fun playExplicit(song: Song, with: PlaySong) {
playWithImpl(song, with, false) playWithImpl(song, with, ShuffleMode.OFF)
} }
fun shuffleExplicit(song: Song, with: PlaySong) { fun shuffleExplicit(song: Song, with: PlaySong) {
playWithImpl(song, with, true) playWithImpl(song, with, ShuffleMode.ON)
} }
/** Shuffle all songs in the music library. */ /** Shuffle all songs in the music library. */
fun shuffleAll() { fun shuffleAll() {
logD("Shuffling all songs") 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. * be prompted on what artist to play. Defaults to null.
*/ */
fun playFromArtist(song: Song, artist: Artist? = 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. * be prompted on what artist to play. Defaults to null.
*/ */
fun playFromGenre(song: Song, genre: Genre? = 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, shuffle: ShuffleMode) {
private fun playWithImpl(song: Song, with: PlaySong, shuffled: Boolean) {
when (with) { when (with) {
is PlaySong.FromAll -> playFromAllImpl(song, shuffled) is PlaySong.FromAll -> playFromAllImpl(song, shuffle)
is PlaySong.FromAlbum -> playFromAlbumImpl(song, shuffled) is PlaySong.FromAlbum -> playFromAlbumImpl(song, shuffle)
is PlaySong.FromArtist -> playFromArtistImpl(song, with.which, shuffled) is PlaySong.FromArtist -> playFromArtistImpl(song, with.which, shuffle)
is PlaySong.FromGenre -> playFromGenreImpl(song, with.which, shuffled) is PlaySong.FromGenre -> playFromGenreImpl(song, with.which, shuffle)
is PlaySong.FromPlaylist -> playFromPlaylistImpl(song, with.which, shuffled) is PlaySong.FromPlaylist -> playFromPlaylistImpl(song, with.which, shuffle)
is PlaySong.ByItself -> playItselfImpl(song, shuffled) is PlaySong.ByItself -> playItselfImpl(song, shuffle)
} }
} }
private fun playFromAllImpl(song: Song?, shuffled: Boolean) { private fun playItselfImpl(song: Song, shuffle: ShuffleMode) {
playImpl(song, null, shuffled) playbackManager.play(
requireNotNull(commandFactory.song(song, shuffle)) {
"Invalid playback parameters [$song $shuffle]"
})
} }
private fun playFromAlbumImpl(song: Song, shuffled: Boolean) { private fun playFromAllImpl(song: Song?, shuffle: ShuffleMode) {
playImpl(song, song.album, shuffled) val params =
if (song != null) {
commandFactory.songFromAll(song, shuffle)
} else {
commandFactory.all(shuffle)
}
playImpl(params)
} }
private fun playFromArtistImpl(song: Song, artist: Artist?, shuffled: Boolean) { private fun playFromAlbumImpl(song: Song, shuffle: ShuffleMode) {
if (artist != null) { logD("Playing $song from album")
logD("Playing $song from $artist") playImpl(commandFactory.songFromAlbum(song, shuffle))
playImpl(song, artist, shuffled) }
} else if (song.artists.size == 1) {
logD("$song has one artist, playing from it") private fun playFromArtistImpl(song: Song, artist: Artist?, shuffle: ShuffleMode) {
playImpl(song, song.artists[0], shuffled) val params = commandFactory.songFromArtist(song, artist, shuffle)
} else { if (params != null) {
logD("$song has multiple artists, showing choice dialog") playbackManager.play(params)
startPlaybackDecision(PlaybackDecision.PlayFromArtist(song)) 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) { private fun playFromGenreImpl(song: Song, genre: Genre?, shuffle: ShuffleMode) {
if (genre != null) { val params = commandFactory.songFromGenre(song, genre, shuffle)
logD("Playing $song from $genre") if (params != null) {
playImpl(song, genre, shuffled) playbackManager.play(params)
} else if (song.genres.size == 1) { return
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))
} }
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") logD("Playing $song from $playlist")
playImpl(song, playlist, shuffled) playImpl(commandFactory.songFromPlaylist(song, playlist, shuffle))
}
private fun playItselfImpl(song: Song, shuffled: Boolean) {
playImpl(song, listOf(song), shuffled)
} }
private fun startPlaybackDecision(decision: PlaybackDecision) { private fun startPlaybackDecision(decision: PlaybackDecision) {
@ -300,7 +306,7 @@ constructor(
*/ */
fun play(album: Album) { fun play(album: Album) {
logD("Playing $album") logD("Playing $album")
playImpl(null, album, false) playImpl(commandFactory.album(album, ShuffleMode.OFF))
} }
/** /**
@ -310,7 +316,7 @@ constructor(
*/ */
fun shuffle(album: Album) { fun shuffle(album: Album) {
logD("Shuffling $album") logD("Shuffling $album")
playImpl(null, album, true) playImpl(commandFactory.album(album, ShuffleMode.ON))
} }
/** /**
@ -320,7 +326,7 @@ constructor(
*/ */
fun play(artist: Artist) { fun play(artist: Artist) {
logD("Playing $artist") logD("Playing $artist")
playImpl(null, artist, false) playImpl(commandFactory.artist(artist, ShuffleMode.OFF))
} }
/** /**
@ -330,7 +336,7 @@ constructor(
*/ */
fun shuffle(artist: Artist) { fun shuffle(artist: Artist) {
logD("Shuffling $artist") logD("Shuffling $artist")
playImpl(null, artist, true) playImpl(commandFactory.artist(artist, ShuffleMode.ON))
} }
/** /**
@ -340,7 +346,7 @@ constructor(
*/ */
fun play(genre: Genre) { fun play(genre: Genre) {
logD("Playing $genre") logD("Playing $genre")
playImpl(null, genre, false) playImpl(commandFactory.genre(genre, ShuffleMode.OFF))
} }
/** /**
@ -350,7 +356,7 @@ constructor(
*/ */
fun shuffle(genre: Genre) { fun shuffle(genre: Genre) {
logD("Shuffling $genre") logD("Shuffling $genre")
playImpl(null, genre, true) playImpl(commandFactory.genre(genre, ShuffleMode.ON))
} }
/** /**
@ -360,7 +366,7 @@ constructor(
*/ */
fun play(playlist: Playlist) { fun play(playlist: Playlist) {
logD("Playing $playlist") logD("Playing $playlist")
playImpl(null, playlist, false) playImpl(commandFactory.playlist(playlist, ShuffleMode.OFF))
} }
/** /**
@ -370,7 +376,7 @@ constructor(
*/ */
fun shuffle(playlist: Playlist) { fun shuffle(playlist: Playlist) {
logD("Shuffling $playlist") logD("Shuffling $playlist")
playImpl(null, playlist, true) playImpl(commandFactory.playlist(playlist, ShuffleMode.ON))
} }
/** /**
@ -380,7 +386,7 @@ constructor(
*/ */
fun play(songs: List<Song>) { fun play(songs: List<Song>) {
logD("Playing ${songs.size} songs") 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>) { fun shuffle(songs: List<Song>) {
logD("Shuffling ${songs.size} songs") 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) { private fun playImpl(command: PlaybackCommand?) {
check(song == null || queue.contains(song)) { "Song to play not in queue" } playbackManager.play(requireNotNull(command) { "Invalid playback parameters" })
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)
} }
/** /**
@ -617,49 +606,6 @@ constructor(
} }
_openPanel.put(panel) _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)
}
}
} }
/** /**

View file

@ -57,7 +57,7 @@ constructor(
flush() flush()
} }
init { fun attach() {
playbackManager.addListener(this) playbackManager.addListener(this)
playbackSettings.registerListener(this) playbackSettings.registerListener(this)
} }

View file

@ -16,11 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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.common.C
import androidx.media3.exoplayer.source.ShuffleOrder import androidx.media3.exoplayer.source.ShuffleOrder
import java.util.*
/** /**
* A ShuffleOrder that fixes the poorly defined default implementation of cloneAndInsert. Whereas * A ShuffleOrder that fixes the poorly defined default implementation of cloneAndInsert. Whereas

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver import android.content.BroadcastReceiver

View file

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

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
@ -25,11 +25,13 @@ import android.content.Intent
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.AuxioService
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD 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) * @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 // wrong action at the wrong time will result in the app crashing, and there is
// nothing I can do about it. // nothing I can do about it.
logD("Delivering media button intent $intent") logD("Delivering media button intent $intent")
intent.component = ComponentName(context, PlaybackService::class.java) intent.component = ComponentName(context, AuxioService::class.java)
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
} }

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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 android.content.Context
import androidx.media3.datasource.ContentDataSource import androidx.media3.datasource.ContentDataSource

View file

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

View file

@ -51,15 +51,8 @@ interface PlaybackStateHolder {
/** The current audio session ID of the audio player. */ /** The current audio session ID of the audio player. */
val audioSessionId: Int val audioSessionId: Int
/** /** Applies a completely new playback state to the holder. */
* Applies a completely new playback state to the holder. fun newPlayback(command: PlaybackCommand)
*
* @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)
/** /**
* Update the playing state of the audio player. * Update the playing state of the audio player.
@ -154,6 +147,9 @@ interface PlaybackStateHolder {
*/ */
fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) 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. */ /** Reset this instance to an empty state. */
fun reset(ack: StateAck.NewPlayback) fun reset(ack: StateAck.NewPlayback)
} }
@ -202,6 +198,8 @@ sealed interface StateAck {
/** @see PlaybackStateHolder.repeatMode */ /** @see PlaybackStateHolder.repeatMode */
data object RepeatModeChanged : StateAck data object RepeatModeChanged : StateAck
data object SessionEnded : StateAck
} }
/** /**

View file

@ -111,13 +111,9 @@ interface PlaybackStateManager {
/** /**
* Start new playback. * Start new playback.
* *
* @param song A particular [Song] to play, or null to play the first [Song] in the new queue. * @param command The parameters to start playback with.
* @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.
*/ */
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 * 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) fun seekTo(positionMs: Long)
/** Rewind to the beginning of the currently playing [Song]. */ fun endSession()
fun rewind() = seekTo(0)
/** /**
* Converts the current state of this instance into a [SavedState]. * Converts the current state of this instance into a [SavedState].
@ -317,6 +312,8 @@ interface PlaybackStateManager {
* @param repeatMode The new [RepeatMode]. * @param repeatMode The new [RepeatMode].
*/ */
fun onRepeatModeChanged(repeatMode: RepeatMode) {} fun onRepeatModeChanged(repeatMode: RepeatMode) {}
fun onSessionEnded() {}
} }
/** /**
@ -441,12 +438,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
// --- PLAYING FUNCTIONS --- // --- PLAYING FUNCTIONS ---
@Synchronized @Synchronized
override fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean) { override fun play(command: PlaybackCommand) {
val stateHolder = stateHolder ?: return 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 // Played something, so we are initialized now
isInitialized = true isInitialized = true
stateHolder.newPlayback(queue, song, parent, shuffled) stateHolder.newPlayback(command)
} }
// --- QUEUE FUNCTIONS --- // --- QUEUE FUNCTIONS ---
@ -476,7 +473,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
override fun playNext(songs: List<Song>) { override fun playNext(songs: List<Song>) {
if (currentSong == null) { if (currentSong == null) {
logD("Nothing playing, short-circuiting to new playback") logD("Nothing playing, short-circuiting to new playback")
play(songs[0], null, songs, false) play(QueueCommand(songs))
} else { } else {
val stateHolder = stateHolder ?: return val stateHolder = stateHolder ?: return
logD("Adding ${songs.size} songs to start of queue") logD("Adding ${songs.size} songs to start of queue")
@ -488,7 +485,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
override fun addToQueue(songs: List<Song>) { override fun addToQueue(songs: List<Song>) {
if (currentSong == null) { if (currentSong == null) {
logD("Nothing playing, short-circuiting to new playback") logD("Nothing playing, short-circuiting to new playback")
play(songs[0], null, songs, false) play(QueueCommand(songs))
} else { } else {
val stateHolder = stateHolder ?: return val stateHolder = stateHolder ?: return
logD("Adding ${songs.size} songs to end of queue") 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 @Synchronized
override fun moveQueueItem(src: Int, dst: Int) { override fun moveQueueItem(src: Int, dst: Int) {
val stateHolder = stateHolder ?: return val stateHolder = stateHolder ?: return
@ -562,6 +565,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
stateHolder.seekTo(positionMs) stateHolder.seekTo(positionMs)
} }
@Synchronized
override fun endSession() {
val stateHolder = stateHolder ?: return
logD("Ending session")
stateHolder.endSession()
}
@Synchronized @Synchronized
override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) { override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) {
if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) { if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
@ -688,6 +698,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
) )
listeners.forEach { it.onRepeatModeChanged(stateMirror.repeatMode) } listeners.forEach { it.onRepeatModeChanged(stateMirror.repeatMode) }
} }
is StateAck.SessionEnded -> {
listeners.forEach { it.onSessionEnded() }
}
} }
} }
@ -782,15 +795,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
index index
}) })
// Valid state where something needs to be played, direct the stateholder to apply stateHolder.applySavedState(savedState.parent, rawQueue, StateAck.NewPlayback)
// this new state. stateHolder.seekTo(savedState.positionMs)
val oldStateMirror = stateMirror stateHolder.repeatMode(savedState.repeatMode)
if (oldStateMirror.rawQueue != rawQueue) {
logD("Queue changed, must reload player")
stateHolder.playing(false)
stateHolder.applySavedState(parent, rawQueue, StateAck.NewPlayback)
stateHolder.seekTo(savedState.positionMs)
}
isInitialized = true isInitialized = true
} }

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2024 Auxio Project
* CoverUri.kt is part of Auxio. * PlaybackStateModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -16,17 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * 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
/** @Module
* Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading @InstallIn(SingletonComponent::class)
* images. interface PlaybackStateModule {
* @Binds fun playbackCommandFactory(factory: PlaybackCommandFactoryImpl): PlaybackCommand.Factory
* @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)

View file

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

View file

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

View file

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

View file

@ -56,11 +56,11 @@ interface SearchEngine {
* @param playlists A list of [Playlist], null if empty. * @param playlists A list of [Playlist], null if empty.
*/ */
data class Items( data class Items(
val songs: Collection<Song>?, val songs: Collection<Song>? = null,
val albums: Collection<Album>?, val albums: Collection<Album>? = null,
val artists: Collection<Artist>?, val artists: Collection<Artist>? = null,
val genres: Collection<Genre>?, val genres: Collection<Genre>? = null,
val playlists: Collection<Playlist>? val playlists: Collection<Playlist>? = null
) )
} }

View file

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

View file

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

View file

@ -68,9 +68,6 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) } binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) }
binding.aboutProfile.setOnClickListener { requireContext().openInBrowser(LINK_PROFILE) } binding.aboutProfile.setOnClickListener { requireContext().openInBrowser(LINK_PROFILE) }
binding.aboutDonate.setOnClickListener { requireContext().openInBrowser(LINK_DONATE) } binding.aboutDonate.setOnClickListener { requireContext().openInBrowser(LINK_DONATE) }
binding.aboutSupporterYrliet.setOnClickListener {
requireContext().openInBrowser(LINK_YRLIET)
}
binding.aboutSupportersPromo.setOnClickListener { binding.aboutSupportersPromo.setOnClickListener {
requireContext().openInBrowser(LINK_DONATE) requireContext().openInBrowser(LINK_DONATE)
} }
@ -100,6 +97,5 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
const val LINK_LICENSES = "$LINK_WIKI/Licenses" const val LINK_LICENSES = "$LINK_WIKI/Licenses"
const val LINK_PROFILE = "https://github.com/OxygenCobalt" const val LINK_PROFILE = "https://github.com/OxygenCobalt"
const val LINK_DONATE = "https://github.com/sponsors/OxygenCobalt" const val LINK_DONATE = "https://github.com/sponsors/OxygenCobalt"
const val LINK_YRLIET = "https://github.com/yrliet"
} }
} }

View file

@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.oxycblt.auxio.BuildConfig
/** /**
* A wrapper around [StateFlow] exposing a one-time consumable event. * 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 { try {
withTimeout(timeout) { send(element) } withTimeout(timeout) { send(element) }
} catch (e: TimeoutCancellationException) { } 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 subsequent = true
} }
} catch (e: TimeoutCancellationException) { } 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()
}
} }
} }
} }

View file

@ -31,8 +31,8 @@ import android.widget.RemoteViews
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.resolveNames 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.state.RepeatMode
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -339,7 +339,7 @@ class WidgetProvider : AppWidgetProvider() {
// by PlaybackService. // by PlaybackService.
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_play_pause, 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 // 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 // a circular FAB when paused, and a squircle FAB when playing. This does require us
@ -380,10 +380,10 @@ class WidgetProvider : AppWidgetProvider() {
// by PlaybackService. // by PlaybackService.
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_skip_prev, R.id.widget_skip_prev,
context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_PREV)) context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_PREV))
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_skip_next, R.id.widget_skip_next,
context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_NEXT)) context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_NEXT))
return this return this
} }
@ -405,10 +405,10 @@ class WidgetProvider : AppWidgetProvider() {
// be recognized by PlaybackService. // be recognized by PlaybackService.
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_repeat, R.id.widget_repeat,
context.newBroadcastPendingIntent(PlaybackService.ACTION_INC_REPEAT_MODE)) context.newBroadcastPendingIntent(PlaybackActions.ACTION_INC_REPEAT_MODE))
setOnClickPendingIntent( setOnClickPendingIntent(
R.id.widget_shuffle, 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 // Set up the repeat/shuffle buttons. When working with RemoteViews, we will
// need to hard-code different accent tinting configurations, as stateful drawables // need to hard-code different accent tinting configurations, as stateful drawables

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

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

View file

@ -224,18 +224,6 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="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 <TextView
android:id="@+id/about_supporters_promo" android:id="@+id/about_supporters_promo"
style="@style/Widget.Auxio.TextView.Icon.Clickable" style="@style/Widget.Auxio.TextView.Icon.Clickable"

View file

@ -32,7 +32,7 @@
android:id="@+id/interact_body" android:id="@+id/interact_body"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/ui_item_ripple"> android:background="@drawable/ui_selection_bg">
<org.oxycblt.auxio.image.CoverView <org.oxycblt.auxio.image.CoverView
android:id="@+id/song_album_cover" android:id="@+id/song_album_cover"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -16,7 +16,7 @@
<string name="set_separators_comma">Коска (,)</string> <string name="set_separators_comma">Коска (,)</string>
<string name="set_separators_plus">Плюс (+)</string> <string name="set_separators_plus">Плюс (+)</string>
<string name="set_separators_and">Амперсанд (&amp;)</string> <string name="set_separators_and">Амперсанд (&amp;)</string>
<string name="set_dirs_mode_include">Уключэнні</string> <string name="set_dirs_mode_include">Ўключыць</string>
<string name="set_ui">Афармленне</string> <string name="set_ui">Афармленне</string>
<string name="lbl_retry">Паўтарыць</string> <string name="lbl_retry">Паўтарыць</string>
<string name="lng_search_library">Пошук у вашай бібліятэцы…</string> <string name="lng_search_library">Пошук у вашай бібліятэцы…</string>
@ -27,7 +27,7 @@
<string name="lbl_grant">Выдаць</string> <string name="lbl_grant">Выдаць</string>
<string name="lbl_songs">Песні</string> <string name="lbl_songs">Песні</string>
<string name="set_ui_desc">Змяніце тэму і колеры праграмы</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_albums">Альбомы</string>
<string name="lbl_album">Альбом</string> <string name="lbl_album">Альбом</string>
<string name="lbl_album_live">Жывы альбом</string> <string name="lbl_album_live">Жывы альбом</string>
@ -58,7 +58,7 @@
<string name="lbl_genre">Жанр</string> <string name="lbl_genre">Жанр</string>
<string name="lbl_search">Пошук</string> <string name="lbl_search">Пошук</string>
<string name="lbl_filter">Фільтр</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_name">Назва</string>
<string name="lbl_genres">Жанры</string> <string name="lbl_genres">Жанры</string>
<string name="lbl_sort">Сартаваць</string> <string name="lbl_sort">Сартаваць</string>
@ -84,7 +84,7 @@
<string name="lbl_album_details">Перайсці да альбома</string> <string name="lbl_album_details">Перайсці да альбома</string>
<string name="lbl_artist_details">Перайсці да выканаўцы</string> <string name="lbl_artist_details">Перайсці да выканаўцы</string>
<string name="lbl_song_detail">Праглядзіце ўласцівасці</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_format">Фармат</string>
<string name="lbl_shuffle_shortcut_long">Перамяшаць усё</string> <string name="lbl_shuffle_shortcut_long">Перамяшаць усё</string>
<string name="lbl_bitrate">Бітрэйт</string> <string name="lbl_bitrate">Бітрэйт</string>
@ -121,8 +121,8 @@
<string name="set_dirs_list">Тэчкі</string> <string name="set_dirs_list">Тэчкі</string>
<string name="set_dirs_mode">Рэжым</string> <string name="set_dirs_mode">Рэжым</string>
<string name="set_dirs_mode_exclude">Выключэнні</string> <string name="set_dirs_mode_exclude">Выключэнні</string>
<string name="set_dirs_mode_exclude_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_dirs_mode_include_desc">Музыка будзе загружана <b>толькі</b> з выбраных тэчак.</string>
<string name="set_rescan">Перасканаваць музыку</string> <string name="set_rescan">Перасканаваць музыку</string>
<string name="set_reindex">Абнавіць музыку</string> <string name="set_reindex">Абнавіць музыку</string>
<string name="set_reindex_desc">Перазагрузіце музычную бібліятэку, выкарыстоўваючы па магчымасці кэшаваныя тэгі</string> <string name="set_reindex_desc">Перазагрузіце музычную бібліятэку, выкарыстоўваючы па магчымасці кэшаваныя тэгі</string>
@ -148,7 +148,7 @@
<string name="desc_skip_prev">Перайсці да апошняй песні</string> <string name="desc_skip_prev">Перайсці да апошняй песні</string>
<string name="desc_change_repeat">Змяніць рэжым паўтору</string> <string name="desc_change_repeat">Змяніць рэжым паўтору</string>
<string name="desc_auxio_icon">Значок Auxio</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_remove_song">Выдаліць гэтую песню з чаргі</string>
<string name="desc_shuffle_all">Перамяшаць усе песні</string> <string name="desc_shuffle_all">Перамяшаць усе песні</string>
<string name="desc_exit">Спыніць прайграванне</string> <string name="desc_exit">Спыніць прайграванне</string>
@ -221,11 +221,11 @@
<string name="cdc_aac">Прасунуты аўдыё кодэк (AAC)</string> <string name="cdc_aac">Прасунуты аўдыё кодэк (AAC)</string>
<string name="fmt_list">%1$s, %2$s</string> <string name="fmt_list">%1$s, %2$s</string>
<string name="cdc_flac">Свабодны аўдыё кодэк без страты якасці (FLAC)</string> <string name="cdc_flac">Свабодны аўдыё кодэк без страты якасці (FLAC)</string>
<string name="set_exclude_non_music">Уключыць не-музыку</string> <string name="set_exclude_non_music">Выключыць іншыя гукавыя файлы</string>
<string name="set_round_mode_desc">Уключыць закругленыя вуглы на дадатковых элементах інтэрфейсу (патрабуецца закругленне вокладак альбомаў)</string> <string name="set_round_mode_desc">Ўключыць закругленыя вуглы на дадатковых элементах інтэрфейсу (патрабуецца закругленне вокладак альбомаў)</string>
<string name="set_personalize_desc">Наладзьце элементы кіравання і паводзіны карыстацкага інтэрфейсу</string> <string name="set_personalize_desc">Наладзьце элементы кіравання і паводзіны карыстацкага інтэрфейсу</string>
<string name="set_display">Экран</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_lib_tabs_desc">Змяніць бачнасць і парадак укладак бібліятэкі</string>
<string name="set_action_mode_next">Перайсці да наступнага</string> <string name="set_action_mode_next">Перайсці да наступнага</string>
<string name="set_action_mode_repeat">Рэжым паўтору</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_artist">Гуляць ад выканаўцы</string>
<string name="set_play_song_from_genre">Гуляць з жанру</string> <string name="set_play_song_from_genre">Гуляць з жанру</string>
<string name="set_keep_shuffle">Запамінаць перамешванне</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_content">Кантэнт</string>
<string name="set_music">Музыка</string> <string name="set_music">Музыка</string>
<string name="set_observing">Аўтаматычная перазагрузка</string> <string name="set_observing">Аўтаматычная перазагрузка</string>

Some files were not shown because too many files have changed in this diff Show more