diff --git a/CHANGELOG.md b/CHANGELOG.md
index a04afeb75..b7f65701a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,26 @@
# Changelog
+## 3.4.0
+
+#### What's New
+- Gapless playback is now used whenever possible
+- Added "Remember pause" setting that makes remain paused when skipping
+or editing queue
+- Added 1x4 and 1x3 widget forms
+
+#### What's Fixed
+- Increased music timeout to 60 seconds to accomodate large cover arts
+on slow storage drives
+- Fixed app repeatedly crashing when automatic theme was on
+
+#### What's Improved
+- The playback state is now saved more often, improving persistence
+- The queue is now fully circular when repeat all is enabled
+
+#### What's Changed
+- You can no longer save, restore, or clear the playback state
+- The playback session now ends if you swipe away the app while it's paused
+
## 3.3.3
#### What's Fixed
diff --git a/README.md b/README.md
index be40f6388..084ea5a5a 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
Auxio
A simple, rational music player for android.
-
-
+
+
@@ -51,6 +51,7 @@ precise/original dates, sort tags, and more
- SD Card-aware folder management
- Reliable playlisting functionality
- Playback state persistence
+- Automatic gapless playback
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
- External equalizer support (ex. Wavelet)
- Edge-to-edge
@@ -65,19 +66,24 @@ precise/original dates, sort tags, and more
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background
+- Notifcations (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
## Donate
-You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even App Itself!
+You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself!
-**$16/month supporters:**
+$16/month supporters:
-*Be the first to have their profile picture and username added here!*
+
+
yrliet
+
-**$8/month supporters:**
+$8/month supporters:
-
+
+
+
## Building
diff --git a/app/build.gradle b/app/build.gradle
index 915f58a3e..af0bf21bb 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -21,8 +21,8 @@ android {
defaultConfig {
applicationId namespace
- versionName "3.3.3"
- versionCode 40
+ versionName "3.4.0"
+ versionCode 41
minSdk 24
targetSdk 34
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ba4b27028..f7fa7b198 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,7 @@
+
diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
index c98d89cdd..3fa2ad852 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
@@ -31,7 +31,7 @@ import javax.inject.Inject
import org.oxycblt.auxio.databinding.ActivityMainBinding
import org.oxycblt.auxio.music.system.IndexerService
import org.oxycblt.auxio.playback.PlaybackViewModel
-import org.oxycblt.auxio.playback.state.InternalPlayer
+import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isNight
@@ -76,7 +76,7 @@ class MainActivity : AppCompatActivity() {
if (!startIntentAction(intent)) {
// No intent action to do, just restore the previously saved state.
- playbackModel.startAction(InternalPlayer.Action.RestoreState)
+ playbackModel.playDeferred(DeferredPlayback.RestoreState)
}
}
@@ -111,12 +111,12 @@ class MainActivity : AppCompatActivity() {
}
/**
- * Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action] that can be used
- * in the playback system.
+ * Transform an [Intent] given to [MainActivity] into a [DeferredPlayback] that can be used in
+ * the playback system.
*
* @param intent The (new) [Intent] given to this [MainActivity], or null if there is no intent.
- * @return true If the analogous [InternalPlayer.Action] to the given [Intent] was started,
- * false otherwise.
+ * @return true If the analogous [DeferredPlayback] to the given [Intent] was started, false
+ * otherwise.
*/
private fun startIntentAction(intent: Intent?): Boolean {
if (intent == null) {
@@ -137,15 +137,15 @@ class MainActivity : AppCompatActivity() {
val action =
when (intent.action) {
- Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
- Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll
+ Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false)
+ Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll
else -> {
logW("Unexpected intent ${intent.action}")
return false
}
}
logD("Translated intent to $action")
- playbackModel.startAction(action)
+ playbackModel.playDeferred(action)
return true
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
index f5c4c5bd0..1d5c5bdde 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
@@ -148,19 +148,6 @@ class HomeFragment :
// --- UI SETUP ---
- // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
- // it ourselves.
- binding.root.rootView.apply {
- post {
- findViewById(R.id.main_scrim).setOnTouchListener { _, event ->
- handleSpeedDialBoundaryTouch(event)
- }
- findViewById(R.id.sheet_scrim).setOnTouchListener { _, event ->
- handleSpeedDialBoundaryTouch(event)
- }
- }
- }
-
binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeNormalToolbar.apply {
setOnMenuItemClickListener(this@HomeFragment)
@@ -235,6 +222,23 @@ class HomeFragment :
collect(detailModel.toShow.flow, ::handleShow)
}
+ override fun onStart() {
+ super.onStart()
+
+ // Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
+ // it ourselves.
+ requireBinding().root.rootView.apply {
+ post {
+ findViewById(R.id.main_scrim).setOnTouchListener { _, event ->
+ handleSpeedDialBoundaryTouch(event)
+ }
+ findViewById(R.id.sheet_scrim).setOnTouchListener { _, event ->
+ handleSpeedDialBoundaryTouch(event)
+ }
+ }
+ }
+ }
+
override fun onSaveInstanceState(outState: Bundle) {
val transition = enterTransition
if (transition is MaterialSharedAxis) {
diff --git a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt
index 1a9b5174d..0db750c1a 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt
@@ -244,7 +244,7 @@ class ThemedSpeedDialView : SpeedDialView {
companion object {
private val VIEW_PROPERTY_BACKGROUND_TINT =
object : Property(Int::class.java, "backgroundTint") {
- override fun get(view: View): Int? = view.backgroundTintList!!.defaultColor
+ override fun get(view: View): Int = view.backgroundTintList!!.defaultColor
override fun set(view: View, value: Int?) {
view.backgroundTintList = ColorStateList.valueOf(value!!)
@@ -253,7 +253,7 @@ class ThemedSpeedDialView : SpeedDialView {
private val IMAGE_VIEW_PROPERTY_IMAGE_TINT =
object : Property(Int::class.java, "imageTint") {
- override fun get(view: ImageView): Int? = view.imageTintList!!.defaultColor
+ override fun get(view: ImageView): Int = view.imageTintList!!.defaultColor
override fun set(view: ImageView, value: Int?) {
view.imageTintList = ColorStateList.valueOf(value!!)
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
index 97ecaa7fd..459c8fcbb 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
@@ -42,6 +42,7 @@ import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.metadata.TagExtractor
import org.oxycblt.auxio.music.user.MutableUserLibrary
import org.oxycblt.auxio.music.user.UserLibrary
+import org.oxycblt.auxio.util.DEFAULT_TIMEOUT
import org.oxycblt.auxio.util.forEachWithTimeout
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
@@ -481,7 +482,7 @@ constructor(
val rawSongs = LinkedList()
// Use a longer timeout so that dependent components can timeout and throw errors that
// provide more context than if we timed out here.
- processedSongs.forEachWithTimeout(20000) {
+ processedSongs.forEachWithTimeout(DEFAULT_TIMEOUT * 2) {
rawSongs.add(it)
// Since discovery takes up the bulk of the music loading process, we switch to
// indicating a defined amount of loaded songs in comparison to the projected amount
@@ -489,7 +490,7 @@ constructor(
emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
}
- withTimeout(10000) {
+ withTimeout(DEFAULT_TIMEOUT) {
mediaStoreJob.await()
tagJob.await()
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
index e50b0c448..67b46e9ce 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
@@ -57,8 +57,10 @@ interface MusicSettings : Settings {
class MusicSettingsImpl
@Inject
-constructor(@ApplicationContext context: Context, val documentPathFactory: DocumentPathFactory) :
- Settings.Impl(context), MusicSettings {
+constructor(
+ @ApplicationContext context: Context,
+ private val documentPathFactory: DocumentPathFactory
+) : Settings.Impl(context), MusicSettings {
private val storageManager = context.getSystemServiceCompat(StorageManager::class)
override var musicDirs: MusicDirectories
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
index 6140261a9..4c45dfc84 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
@@ -368,7 +368,8 @@ sealed interface PlaylistDecision {
* @param songs The [Song]s to contain in the new [Playlist].
* @param template An existing playlist name that should be editable in the opened dialog. If
* null, a placeholder should be created and shown as a hint instead.
- * @param context The context in which this decision is being fulfilled.
+ * @param reason The reason why a new playlist is being created. For all intensive purposes, you
+ * do not need to specify this.
*/
data class New(val songs: List, val template: String?, val reason: Reason) :
PlaylistDecision {
diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt
index 3e487437a..c9a5d293a 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt
@@ -210,7 +210,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
}
/**
- * Set a new [currentPlaylisttoExport] from a [Playlist] [Music.UID].
+ * Set a new [currentPlaylistToExport] from a [Playlist] [Music.UID].
*
* @param playlistUid The [Music.UID] of the [Playlist] to export.
*/
diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt
index 9beedd79f..949d9282c 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryAdapter.kt
@@ -29,7 +29,7 @@ import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
- * [RecyclerView.Adapter] that manages a list of [Directory] instances.
+ * [RecyclerView.Adapter] that manages a list of [Path] music directory instances.
*
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
@@ -37,9 +37,7 @@ import org.oxycblt.auxio.util.logD
class DirectoryAdapter(private val listener: Listener) :
RecyclerView.Adapter() {
private val _dirs = mutableListOf()
- /**
- * The current list of [SystemPath]s, may not line up with [MusicDirectories] due to removals.
- */
+ /** The current list of [Path]s, may not line up with [MusicDirectories] due to removals. */
val dirs: List = _dirs
override fun getItemCount() = dirs.size
@@ -94,7 +92,7 @@ class DirectoryAdapter(private val listener: Listener) :
}
/**
- * A [RecyclerView.Recycler] that displays a [Directory]. Use [from] to create an instance.
+ * A [RecyclerView.Recycler] that displays a [Path]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt
index c85b21ad5..a4082cbf7 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirectories.kt
@@ -23,9 +23,10 @@ import org.oxycblt.auxio.music.fs.Path
/**
* Represents the configuration for specific directories to filter to/from when loading music.
*
- * @param dirs A list of [Directory] instances. How these are interpreted depends on [shouldInclude]
- * @param shouldInclude True if the library should only load from the [Directory] instances, false
- * if the library should not load from the [Directory] instances.
+ * @param dirs A list of directory [Path] instances. How these are interpreted depends on
+ * [shouldInclude]
+ * @param shouldInclude True if the library should only load from the [Path] instances, false if the
+ * library should not load from the [Path] instances.
* @author Alexander Capehart (OxygenCobalt)
*/
data class MusicDirectories(val dirs: List, val shouldInclude: Boolean)
diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt
index 90b5e2051..e0e989133 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt
@@ -90,7 +90,7 @@ interface MediaStoreExtractor {
* Create a framework-backed instance.
*
* @param context [Context] required.
- * @param volumeManager [VolumeManager] required.
+ * @param pathInterpreterFactory A [MediaStorePathInterpreter.Factory] to use.
* @return A new [MediaStoreExtractor] that will work best on the device's API level.
*/
fun from(
diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
index d3cad75c6..83f8d5f80 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt
@@ -35,7 +35,6 @@ 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.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.fs.contentResolverSafe
@@ -144,15 +143,11 @@ class IndexerService :
// the listener system of another.
playbackManager.toSavedState()?.let { savedState ->
playbackManager.applySavedState(
- PlaybackStateManager.SavedState(
- parent =
- savedState.parent?.let { musicRepository.find(it.uid) as? MusicParent },
- queueState =
- savedState.queueState.remap { song ->
- deviceLibrary.findSong(requireNotNull(song).uid)
- },
- positionMs = savedState.positionMs,
- repeatMode = savedState.repeatMode),
+ savedState.copy(
+ heap =
+ savedState.heap.map { song ->
+ song?.let { deviceLibrary.findSong(it.uid) }
+ }),
true)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt
index a270c5c07..9899477c3 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt
@@ -58,6 +58,8 @@ interface PlaybackSettings : Settings {
val rewindWithPrev: Boolean
/** Whether a song should pause after every repeat. */
val pauseOnRepeat: Boolean
+ /** Whether to maintain the play/pause state when skipping or editing the queue */
+ val rememberPause: Boolean
interface Listener {
/** Called when one of the ReplayGain configurations have changed. */
@@ -66,6 +68,8 @@ interface PlaybackSettings : Settings {
fun onNotificationActionChanged() {}
/** Called when [barAction] has changed. */
fun onBarActionChanged() {}
+ /** Called when [pauseOnRepeat] has changed. */
+ fun onPauseOnRepeatChanged() {}
}
}
@@ -127,6 +131,9 @@ class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Cont
override val pauseOnRepeat: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false)
+ override val rememberPause: Boolean
+ get() = sharedPreferences.getBoolean(getString(R.string.set_key_remember_pause), false)
+
override fun migrate() {
// MusicMode was converted to PlaySong in 3.2.0
fun Int.migrateMusicMode() =
@@ -187,6 +194,10 @@ class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Cont
logD("Dispatching bar action change")
listener.onBarActionChanged()
}
+ getString(R.string.set_key_repeat_pause) -> {
+ logD("Dispatching pause on repeat change")
+ listener.onPauseOnRepeatChanged()
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
index e497c96ad..483a36d99 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
@@ -36,9 +36,10 @@ import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.persist.PersistenceRepository
-import org.oxycblt.auxio.playback.queue.Queue
-import org.oxycblt.auxio.playback.state.InternalPlayer
+import org.oxycblt.auxio.playback.state.DeferredPlayback
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.Event
import org.oxycblt.auxio.util.MutableEvent
@@ -113,8 +114,7 @@ constructor(
get() = _playbackDecision
/**
- * The current audio session ID of the internal player. Null if no [InternalPlayer] is
- * available.
+ * The current audio session ID of the internal player. Null if no audio player is available.
*/
val currentAudioSessionId: Int?
get() = playbackManager.currentAudioSessionId
@@ -129,50 +129,55 @@ constructor(
playbackSettings.unregisterListener(this)
}
- override fun onIndexMoved(queue: Queue) {
+ override fun onIndexMoved(index: Int) {
logD("Index moved, updating current song")
- _song.value = queue.currentSong
+ _song.value = playbackManager.currentSong
}
- override fun onQueueChanged(queue: Queue, change: Queue.Change) {
+ override fun onQueueChanged(queue: List, index: Int, change: QueueChange) {
// Other types of queue changes preserve the current song.
- if (change.type == Queue.Change.Type.SONG) {
+ if (change.type == QueueChange.Type.SONG) {
logD("Queue changed, updating current song")
- _song.value = queue.currentSong
+ _song.value = playbackManager.currentSong
}
}
- override fun onQueueReordered(queue: Queue) {
+ override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) {
logD("Queue completely changed, updating current song")
- _isShuffled.value = queue.isShuffled
+ _isShuffled.value = isShuffled
}
- override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
+ override fun onNewPlayback(
+ parent: MusicParent?,
+ queue: List,
+ index: Int,
+ isShuffled: Boolean
+ ) {
logD("New playback started, updating playback information")
- _song.value = queue.currentSong
+ _song.value = playbackManager.currentSong
_parent.value = parent
- _isShuffled.value = queue.isShuffled
+ _isShuffled.value = isShuffled
}
- override fun onStateChanged(state: InternalPlayer.State) {
+ override fun onProgressionChanged(progression: Progression) {
logD("Player state changed, starting new position polling")
- _isPlaying.value = state.isPlaying
+ _isPlaying.value = progression.isPlaying
// Still need to update the position now due to co-routine launch delays
- _positionDs.value = state.calculateElapsedPositionMs().msToDs()
+ _positionDs.value = progression.calculateElapsedPositionMs().msToDs()
// Replace the previous position co-routine with a new one that uses the new
// state information.
lastPositionJob?.cancel()
lastPositionJob =
viewModelScope.launch {
while (true) {
- _positionDs.value = state.calculateElapsedPositionMs().msToDs()
+ _positionDs.value = progression.calculateElapsedPositionMs().msToDs()
// Wait a deci-second for the next position tick.
delay(100)
}
}
}
- override fun onRepeatChanged(repeatMode: RepeatMode) {
+ override fun onRepeatModeChanged(repeatMode: RepeatMode) {
_repeatMode.value = repeatMode
}
@@ -223,8 +228,7 @@ constructor(
playFromGenreImpl(song, genre, isImplicitlyShuffled())
}
- private fun isImplicitlyShuffled() =
- playbackManager.queue.isShuffled && playbackSettings.keepShuffle
+ private fun isImplicitlyShuffled() = playbackManager.isShuffled && playbackSettings.keepShuffle
private fun playWithImpl(song: Song, with: PlaySong, shuffled: Boolean) {
when (with) {
@@ -411,14 +415,14 @@ constructor(
}
/**
- * Start the given [InternalPlayer.Action] to be completed eventually. This can be used to
- * enqueue a playback action at startup to then occur when the music library is fully loaded.
+ * Start the given [DeferredPlayback] to be completed eventually. This can be used to enqueue a
+ * playback action at startup to then occur when the music library is fully loaded.
*
- * @param action The [InternalPlayer.Action] to perform eventually.
+ * @param action The [DeferredPlayback] to perform eventually.
*/
- fun startAction(action: InternalPlayer.Action) {
+ fun playDeferred(action: DeferredPlayback) {
logD("Starting action $action")
- playbackManager.startAction(action)
+ playbackManager.playDeferred(action)
}
// --- PLAYER FUNCTIONS ---
@@ -572,13 +576,13 @@ constructor(
/** Toggle [isPlaying] (i.e from playing to paused) */
fun togglePlaying() {
logD("Toggling playing state")
- playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
+ playbackManager.playing(!playbackManager.progression.isPlaying)
}
/** Toggle [isShuffled] (ex. from on to off) */
fun toggleShuffled() {
logD("Toggling shuffled state")
- playbackManager.reorder(!playbackManager.queue.isShuffled)
+ playbackManager.shuffled(!playbackManager.isShuffled)
}
/**
@@ -588,7 +592,7 @@ constructor(
*/
fun toggleRepeatMode() {
logD("Toggling repeat mode")
- playbackManager.repeatMode = playbackManager.repeatMode.increment()
+ playbackManager.repeatMode(playbackManager.repeatMode.increment())
}
// --- UI CONTROL ---
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt
index 82ba84ddb..adceb581f 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt
@@ -37,8 +37,8 @@ import org.oxycblt.auxio.playback.state.RepeatMode
* @author Alexander Capehart
*/
@Database(
- entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class],
- version = 32,
+ entities = [PlaybackState::class, QueueHeapItem::class, QueueShuffledMappingItem::class],
+ version = 38,
exportSchema = false)
@TypeConverters(Music.UID.TypeConverters::class)
abstract class PersistenceDatabase : RoomDatabase() {
@@ -109,15 +109,16 @@ interface QueueDao {
/**
* Get the previously persisted queue mapping.
*
- * @return A list of persisted [QueueMappingItem]s wrapping each heap item.
+ * @return A list of persisted [QueueShuffledMappingItem]s wrapping each heap item.
*/
- @Query("SELECT * FROM QueueMappingItem") suspend fun getMapping(): List
+ @Query("SELECT * FROM QueueShuffledMappingItem")
+ suspend fun getShuffledMapping(): List
/** Delete any previously persisted queue heap entries. */
@Query("DELETE FROM QueueHeapItem") suspend fun nukeHeap()
/** Delete any previously persisted queue mapping entries. */
- @Query("DELETE FROM QueueMappingItem") suspend fun nukeMapping()
+ @Query("DELETE FROM QueueShuffledMappingItem") suspend fun nukeShuffledMapping()
/**
* Insert new heap entries into the database.
@@ -129,10 +130,10 @@ interface QueueDao {
/**
* Insert new mapping entries into the database.
*
- * @param mapping The list of wrapped [QueueMappingItem] to insert.
+ * @param mapping The list of wrapped [QueueShuffledMappingItem] to insert.
*/
@Insert(onConflict = OnConflictStrategy.ABORT)
- suspend fun insertMapping(mapping: List)
+ suspend fun insertShuffledMapping(mapping: List)
}
// TODO: Figure out how to get RepeatMode to map to an int instead of a string
@@ -148,5 +149,4 @@ data class PlaybackState(
@Entity data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID)
-@Entity
-data class QueueMappingItem(@PrimaryKey val id: Int, val orderedIndex: Int, val shuffledIndex: Int)
+@Entity data class QueueShuffledMappingItem(@PrimaryKey val id: Int, val index: Int)
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt
index 00b1a8894..a291cc175 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt
@@ -21,7 +21,6 @@ package org.oxycblt.auxio.playback.persist
import javax.inject.Inject
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
-import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
@@ -54,46 +53,37 @@ constructor(
override suspend fun readState(): PlaybackStateManager.SavedState? {
val deviceLibrary = musicRepository.deviceLibrary ?: return null
val playbackState: PlaybackState
- val heap: List
- val mapping: List
+ val heapItems: List
+ val mappingItems: List
try {
playbackState = playbackStateDao.getState() ?: return null
- heap = queueDao.getHeap()
- mapping = queueDao.getMapping()
+ heapItems = queueDao.getHeap()
+ mappingItems = queueDao.getShuffledMapping()
} catch (e: Exception) {
logE("Unable read playback state")
logE(e.stackTraceToString())
return null
}
- val orderedMapping = mutableListOf()
- val shuffledMapping = mutableListOf()
- for (entry in mapping) {
- orderedMapping.add(entry.orderedIndex)
- shuffledMapping.add(entry.shuffledIndex)
- }
-
+ val heap = heapItems.map { deviceLibrary.findSong(it.uid) }
+ val shuffledMapping = mappingItems.map { it.index }
val parent = playbackState.parentUid?.let { musicRepository.find(it) as? MusicParent }
- logD("Successfully read playback state")
return PlaybackStateManager.SavedState(
- parent = parent,
- queueState =
- Queue.SavedState(
- heap.map { deviceLibrary.findSong(it.uid) },
- orderedMapping,
- shuffledMapping,
- playbackState.index,
- playbackState.songUid),
positionMs = playbackState.positionMs,
- repeatMode = playbackState.repeatMode)
+ repeatMode = playbackState.repeatMode,
+ parent = parent,
+ heap = heap,
+ shuffledMapping = shuffledMapping,
+ index = playbackState.index,
+ songUid = playbackState.songUid)
}
override suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean {
try {
playbackStateDao.nukeState()
queueDao.nukeHeap()
- queueDao.nukeMapping()
+ queueDao.nukeShuffledMapping()
} catch (e: Exception) {
logE("Unable to clear previous state")
logE(e.stackTraceToString())
@@ -106,29 +96,23 @@ constructor(
val playbackState =
PlaybackState(
id = 0,
- index = state.queueState.index,
+ index = state.index,
positionMs = state.positionMs,
repeatMode = state.repeatMode,
- songUid = state.queueState.songUid,
+ songUid = state.songUid,
parentUid = state.parent?.uid)
// Convert the remaining queue information do their database-specific counterparts.
val heap =
- state.queueState.heap.mapIndexed { i, song ->
- QueueHeapItem(i, requireNotNull(song).uid)
- }
+ state.heap.mapIndexed { i, song -> QueueHeapItem(i, requireNotNull(song).uid) }
- val mapping =
- state.queueState.orderedMapping.zip(state.queueState.shuffledMapping).mapIndexed {
- i,
- pair ->
- QueueMappingItem(i, pair.first, pair.second)
- }
+ val shuffledMapping =
+ state.shuffledMapping.mapIndexed { i, index -> QueueShuffledMappingItem(i, index) }
try {
playbackStateDao.insertState(playbackState)
queueDao.insertHeap(heap)
- queueDao.insertMapping(mapping)
+ queueDao.insertShuffledMapping(shuffledMapping)
} catch (e: Exception) {
logE("Unable to write new state")
logE(e.stackTraceToString())
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt
deleted file mode 100644
index bc421b846..000000000
--- a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt
+++ /dev/null
@@ -1,449 +0,0 @@
-/*
- * Copyright (c) 2023 Auxio Project
- * Queue.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 .
- */
-
-package org.oxycblt.auxio.playback.queue
-
-import kotlin.random.Random
-import kotlin.random.nextInt
-import org.oxycblt.auxio.list.adapter.UpdateInstructions
-import org.oxycblt.auxio.music.Music
-import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.util.logD
-
-/**
- * A heap-backed play queue.
- *
- * Whereas other queue implementations use a plain list, Auxio requires a more complicated data
- * structure in order to implement features such as gapless playback in ExoPlayer. This queue
- * implementation is instead based around an unorganized "heap" of [Song] instances, that are then
- * interpreted into different queues depending on the current playback configuration.
- *
- * In general, the implementation details don't need to be known for this data structure to be used,
- * except in special circumstances like [SavedState]. The functions exposed should be familiar for
- * any typical play queue.
- *
- * @author OxygenCobalt
- */
-interface Queue {
- /** The index of the currently playing [Song] in the current mapping. */
- val index: Int
- /** The currently playing [Song]. */
- val currentSong: Song?
- /** Whether this queue is shuffled. */
- val isShuffled: Boolean
- /**
- * Resolve this queue into a more conventional list of [Song]s.
- *
- * @return A list of [Song] corresponding to the current queue mapping.
- */
- fun resolve(): List
-
- /**
- * Represents the possible changes that can occur during certain queue mutation events.
- *
- * @param type The [Type] of the change to the internal queue state.
- * @param instructions The update done to the resolved queue list.
- */
- data class Change(val type: Type, val instructions: UpdateInstructions) {
- enum class Type {
- /** Only the mapping has changed. */
- MAPPING,
-
- /** The mapping has changed, and the index also changed to align with it. */
- INDEX,
-
- /**
- * The current song has changed, possibly alongside the mapping and index depending on
- * the context.
- */
- SONG
- }
- }
-
- /**
- * An immutable representation of the queue state.
- *
- * @param heap The heap of [Song]s that are/were used in the queue. This can be modified with
- * null values to represent [Song]s that were "lost" from the heap without having to change
- * other values.
- * @param orderedMapping The mapping of the [heap] to an ordered queue.
- * @param shuffledMapping The mapping of the [heap] to a shuffled queue.
- * @param index The index of the currently playing [Song] at the time of serialization.
- * @param songUid The [Music.UID] of the [Song] that was originally at [index].
- */
- class SavedState(
- val heap: List,
- val orderedMapping: List,
- val shuffledMapping: List,
- val index: Int,
- val songUid: Music.UID,
- ) {
- /**
- * Remaps the [heap] of this instance based on the given mapping function and copies it into
- * a new [SavedState].
- *
- * @param transform Code to remap the existing [Song] heap into a new [Song] heap. This
- * **MUST** be the same size as the original heap. [Song] instances that could not be
- * converted should be replaced with null in the new heap.
- * @throws IllegalStateException If the invariant specified by [transform] is violated.
- */
- inline fun remap(transform: (Song?) -> Song?) =
- SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid)
- }
-}
-
-class MutableQueue : Queue {
- @Volatile private var heap = mutableListOf()
- @Volatile private var orderedMapping = mutableListOf()
- @Volatile private var shuffledMapping = mutableListOf()
- @Volatile
- override var index = -1
- private set
-
- override val currentSong: Song?
- get() =
- shuffledMapping
- .ifEmpty { orderedMapping.ifEmpty { null } }
- ?.getOrNull(index)
- ?.let(heap::get)
-
- override val isShuffled: Boolean
- get() = shuffledMapping.isNotEmpty()
-
- override fun resolve() =
- if (currentSong != null) {
- shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } }
- } else {
- // Queue doesn't exist, return saner data.
- listOf()
- }
-
- /**
- * Go to a particular index in the queue.
- *
- * @param to The index of the [Song] to start playing, in the current queue mapping.
- * @return true if the queue jumped to that position, false otherwise.
- */
- fun goto(to: Int): Boolean {
- if (to !in orderedMapping.indices) {
- return false
- }
- index = to
- return true
- }
-
- /**
- * Start a new queue configuration.
- *
- * @param play The [Song] to play, or null to start from a random position.
- * @param queue The queue of [Song]s to play. Must contain [play]. This list will become the
- * heap internally.
- * @param shuffled Whether to shuffle the queue or not. This changes the interpretation of
- * [queue].
- */
- fun start(play: Song?, queue: List, shuffled: Boolean) {
- heap = queue.toMutableList()
- orderedMapping = MutableList(queue.size) { it }
- shuffledMapping = mutableListOf()
- index =
- play?.let(queue::indexOf) ?: if (shuffled) Random.Default.nextInt(queue.indices) else 0
- reorder(shuffled)
- check()
- }
-
- /**
- * Re-order the queue.
- *
- * @param shuffled Whether the queue should be shuffled or not.
- */
- fun reorder(shuffled: Boolean) {
- if (orderedMapping.isEmpty()) {
- // Nothing to do.
- return
- }
-
- logD("Reordering queue [shuffled=$shuffled]")
-
- if (shuffled) {
- val trueIndex =
- if (shuffledMapping.isNotEmpty()) {
- // Re-shuffling, song to preserve is in the shuffled mapping
- shuffledMapping[index]
- } else {
- // First shuffle, song to preserve is in the ordered mapping
- orderedMapping[index]
- }
-
- // Since we are re-shuffling existing songs, we use the previous mapping size
- // instead of the total queue size.
- shuffledMapping = orderedMapping.shuffled().toMutableList()
- shuffledMapping.add(0, shuffledMapping.removeAt(shuffledMapping.indexOf(trueIndex)))
- index = 0
- } else if (shuffledMapping.isNotEmpty()) {
- // Ordering queue, song to preserve is in the shuffled mapping.
- index = orderedMapping.indexOf(shuffledMapping[index])
- shuffledMapping = mutableListOf()
- }
- check()
- }
-
- /**
- * Add [Song]s to the "top" of the queue (right next to the currently playing song). Will start
- * playback if nothing is playing.
- *
- * @param songs The [Song]s to add.
- * @return A [Queue.Change] instance that reflects the changes made.
- */
- fun addToTop(songs: List): Queue.Change {
- logD("Adding ${songs.size} songs to the front of the queue")
- val insertAt = index + 1
- val heapIndices = songs.map(::addSongToHeap)
- if (shuffledMapping.isNotEmpty()) {
- // Add the new songs in front of the current index in the shuffled mapping and in front
- // of the analogous list song in the ordered mapping.
- logD("Must append songs to shuffled mapping")
- val orderedIndex = orderedMapping.indexOf(shuffledMapping[index])
- orderedMapping.addAll(orderedIndex + 1, heapIndices)
- shuffledMapping.addAll(insertAt, heapIndices)
- } else {
- // Add the new song in front of the current index in the ordered mapping.
- logD("Only appending songs to ordered mapping")
- orderedMapping.addAll(insertAt, heapIndices)
- }
- check()
- return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Add(insertAt, songs.size))
- }
-
- /**
- * Add [Song]s to the end of the queue. Will start playback if nothing is playing.
- *
- * @param songs The [Song]s to add.
- * @return A [Queue.Change] instance that reflects the changes made.
- */
- fun addToBottom(songs: List): Queue.Change {
- logD("Adding ${songs.size} songs to the back of the queue")
- val insertAt = orderedMapping.size
- val heapIndices = songs.map(::addSongToHeap)
- // Can simple append the new songs to the end of both mappings.
- orderedMapping.addAll(heapIndices)
- if (shuffledMapping.isNotEmpty()) {
- logD("Appending songs to shuffled mapping")
- shuffledMapping.addAll(heapIndices)
- }
- check()
- return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Add(insertAt, songs.size))
- }
-
- /**
- * Move a [Song] at the given position to a new position.
- *
- * @param src The position of the [Song] to move.
- * @param dst The destination position of the [Song].
- * @return A [Queue.Change] instance that reflects the changes made.
- */
- fun move(src: Int, dst: Int): Queue.Change {
- if (shuffledMapping.isNotEmpty()) {
- // Move songs only in the shuffled mapping. There is no sane analogous form of
- // this for the ordered mapping.
- shuffledMapping.add(dst, shuffledMapping.removeAt(src))
- } else {
- // Move songs in the ordered mapping.
- orderedMapping.add(dst, orderedMapping.removeAt(src))
- }
-
- val oldIndex = index
- when (index) {
- // We are moving the currently playing song, correct the index to it's new position.
- src -> {
- logD("Moving current song, shifting index")
- index = dst
- }
- // We have moved an song from behind the playing song to in front, shift back.
- in (src + 1)..dst -> {
- logD("Moving song from behind -> front, shift backwards")
- index -= 1
- }
- // We have moved an song from in front of the playing song to behind, shift forward.
- in dst until src -> {
- logD("Moving song from front -> behind, shift forward")
- index += 1
- }
- else -> {
- // Nothing to do.
- logD("Move preserved index")
- check()
- return Queue.Change(Queue.Change.Type.MAPPING, UpdateInstructions.Move(src, dst))
- }
- }
-
- logD("Move changed index: $oldIndex -> $index")
-
- check()
- return Queue.Change(Queue.Change.Type.INDEX, UpdateInstructions.Move(src, dst))
- }
-
- /**
- * Remove a [Song] at the given position.
- *
- * @param at The position of the [Song] to remove.
- * @return A [Queue.Change] instance that reflects the changes made.
- */
- fun remove(at: Int): Queue.Change {
- val lastIndex = orderedMapping.lastIndex
- if (shuffledMapping.isNotEmpty()) {
- // Remove the specified index in the shuffled mapping and the analogous song in the
- // ordered mapping.
- orderedMapping.removeAt(orderedMapping.indexOf(shuffledMapping[at]))
- shuffledMapping.removeAt(at)
- } else {
- // Remove the specified index in the shuffled mapping
- orderedMapping.removeAt(at)
- }
-
- // Note: We do not clear songs out from the heap, as that would require the backing data
- // of the player to be completely invalidated. It's generally easier to not remove the
- // song and retain player state consistency.
-
- val type =
- when {
- // We just removed the currently playing song.
- index == at -> {
- logD("Removed current song")
- if (lastIndex == index) {
- logD("Current song at end of queue, shift back")
- --index
- }
- Queue.Change.Type.SONG
- }
- // Index was ahead of removed song, shift back to preserve consistency.
- index > at -> {
- logD("Removed before current song, shift back")
- --index
- Queue.Change.Type.INDEX
- }
- // Nothing to do
- else -> {
- logD("Removal preserved index")
- Queue.Change.Type.MAPPING
- }
- }
- logD("Committing change of type $type")
- check()
- return Queue.Change(type, UpdateInstructions.Remove(at, 1))
- }
-
- /**
- * Convert the current state of this instance into a [Queue.SavedState].
- *
- * @return A new [Queue.SavedState] reflecting the exact state of the queue when called.
- */
- fun toSavedState() =
- currentSong?.let { song ->
- Queue.SavedState(
- heap.toList(), orderedMapping.toList(), shuffledMapping.toList(), index, song.uid)
- }
-
- /**
- * Update this instance from the given [Queue.SavedState].
- *
- * @param savedState A [Queue.SavedState] with a valid queue representation.
- */
- fun applySavedState(savedState: Queue.SavedState) {
- val adjustments = mutableListOf()
- var currentShift = 0
- for (song in savedState.heap) {
- if (song != null) {
- adjustments.add(currentShift)
- } else {
- adjustments.add(null)
- currentShift -= 1
- }
- }
-
- logD("Created adjustment mapping [max shift=$currentShift]")
-
- heap = savedState.heap.filterNotNull().toMutableList()
- orderedMapping =
- savedState.orderedMapping.mapNotNullTo(mutableListOf()) { heapIndex ->
- adjustments[heapIndex]?.let { heapIndex + it }
- }
- shuffledMapping =
- savedState.shuffledMapping.mapNotNullTo(mutableListOf()) { heapIndex ->
- adjustments[heapIndex]?.let { heapIndex + it }
- }
-
- // Make sure we re-align the index to point to the previously playing song.
- index = savedState.index
- while (currentSong?.uid != savedState.songUid && index > -1) {
- index--
- }
- logD("Corrected index: ${savedState.index} -> $index")
- check()
- }
-
- private fun addSongToHeap(song: Song): Int {
- // We want to first try to see if there are any "orphaned" songs in the queue
- // that we can re-use. This way, we can reduce the memory used up by songs that
- // were previously removed from the queue.
- val currentMapping = orderedMapping
- if (orderedMapping.isNotEmpty()) {
- // While we could iterate through the queue and then check the mapping, it's
- // faster if we first check the queue for all instances of this song, and then
- // do a exclusion of this set of indices with the current mapping in order to
- // obtain the orphaned songs.
- val orphanCandidates = mutableSetOf()
- for (entry in heap.withIndex()) {
- if (entry.value == song) {
- orphanCandidates.add(entry.index)
- }
- }
- logD("Found orphans: ${orphanCandidates.map { heap[it] }}")
- orphanCandidates.removeAll(currentMapping.toSet())
- if (orphanCandidates.isNotEmpty()) {
- val orphan = orphanCandidates.first()
- logD("Found an orphan that could be re-used: ${heap[orphan]}")
- // There are orphaned songs, return the first one we find.
- return orphan
- }
- }
- // Nothing to re-use, add this song to the queue
- logD("No orphan could be re-used")
- heap.add(song)
- return heap.lastIndex
- }
-
- private fun check() {
- check(!(heap.isEmpty() && (orderedMapping.isNotEmpty() || shuffledMapping.isNotEmpty()))) {
- "Queue inconsistency detected: Empty heap with non-empty mappings" +
- "[ordered: ${orderedMapping.size}, shuffled: ${shuffledMapping.size}]"
- }
-
- check(shuffledMapping.isEmpty() || orderedMapping.size == shuffledMapping.size) {
- "Queue inconsistency detected: Ordered mapping size ${orderedMapping.size} " +
- "!= Shuffled mapping size ${shuffledMapping.size}"
- }
-
- check(orderedMapping.all { it in heap.indices }) {
- "Queue inconsistency detected: Ordered mapping indices out of heap bounds"
- }
-
- check(shuffledMapping.all { it in heap.indices }) {
- "Queue inconsistency detected: Shuffled mapping indices out of heap bounds"
- }
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt
index 5b1edce73..1283fc909 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt
@@ -27,6 +27,7 @@ import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
+import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD
@@ -51,7 +52,7 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
val scrollTo: Event
get() = _scrollTo
- private val _index = MutableStateFlow(playbackManager.queue.index)
+ private val _index = MutableStateFlow(playbackManager.index)
/** The index of the currently playing song in the queue. */
val index: StateFlow
get() = _index
@@ -60,40 +61,45 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt
playbackManager.addListener(this)
}
- override fun onIndexMoved(queue: Queue) {
+ override fun onIndexMoved(index: Int) {
logD("Index moved, synchronizing and scrolling to new position")
- _scrollTo.put(queue.index)
- _index.value = queue.index
+ _scrollTo.put(index)
+ _index.value = index
}
- override fun onQueueChanged(queue: Queue, change: Queue.Change) {
+ override fun onQueueChanged(queue: List, index: Int, change: QueueChange) {
// Queue changed trivially due to item mo -> Diff queue, stay at current index.
logD("Updating queue display")
_queueInstructions.put(change.instructions)
- _queue.value = queue.resolve()
- if (change.type != Queue.Change.Type.MAPPING) {
+ _queue.value = queue
+ if (change.type != QueueChange.Type.MAPPING) {
// Index changed, make sure it remains updated without actually scrolling to it.
logD("Index changed with queue, synchronizing new position")
- _index.value = queue.index
+ _index.value = index
}
}
- override fun onQueueReordered(queue: Queue) {
+ override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) {
// Queue changed completely -> Replace queue, update index
logD("Queue changed completely, replacing queue and position")
_queueInstructions.put(UpdateInstructions.Replace(0))
- _scrollTo.put(queue.index)
- _queue.value = queue.resolve()
- _index.value = queue.index
+ _scrollTo.put(index)
+ _queue.value = queue
+ _index.value = index
}
- override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
+ override fun onNewPlayback(
+ parent: MusicParent?,
+ queue: List,
+ index: Int,
+ isShuffled: Boolean
+ ) {
// Entirely new queue -> Replace queue, update index
logD("New playback, replacing queue and position")
_queueInstructions.put(UpdateInstructions.Replace(0))
- _scrollTo.put(queue.index)
- _queue.value = queue.resolve()
- _index.value = queue.index
+ _scrollTo.put(index)
+ _queue.value = queue
+ _index.value = index
}
override fun onCleared() {
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt
index bd4ef874c..c94f32ae5 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt
@@ -30,8 +30,8 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackSettings
-import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.playback.state.PlaybackStateManager
+import org.oxycblt.auxio.playback.state.QueueChange
import org.oxycblt.auxio.util.logD
/**
@@ -70,26 +70,31 @@ constructor(
// --- OVERRIDES ---
- override fun onIndexMoved(queue: Queue) {
+ override fun onIndexMoved(index: Int) {
logD("Index moved, updating current song")
- applyReplayGain(queue.currentSong)
+ applyReplayGain(playbackManager.currentSong)
}
- override fun onQueueChanged(queue: Queue, change: Queue.Change) {
+ override fun onQueueChanged(queue: List, index: Int, change: QueueChange) {
// Other types of queue changes preserve the current song.
- if (change.type == Queue.Change.Type.SONG) {
- applyReplayGain(queue.currentSong)
+ if (change.type == QueueChange.Type.SONG) {
+ applyReplayGain(playbackManager.currentSong)
}
}
- override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
+ override fun onNewPlayback(
+ parent: MusicParent?,
+ queue: List,
+ index: Int,
+ isShuffled: Boolean
+ ) {
logD("New playback started, updating playback information")
- applyReplayGain(queue.currentSong)
+ applyReplayGain(playbackManager.currentSong)
}
override fun onReplayGainSettingsChanged() {
// ReplayGain config changed, we need to set it up again.
- applyReplayGain(playbackManager.queue.currentSong)
+ applyReplayGain(playbackManager.currentSong)
}
// --- REPLAYGAIN PARSING ---
@@ -131,7 +136,7 @@ constructor(
logD("Using dynamic strategy")
gain.album?.takeIf {
playbackManager.parent is Album &&
- playbackManager.queue.currentSong?.album == playbackManager.parent
+ playbackManager.currentSong?.album == playbackManager.parent
}
?: gain.track
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt
deleted file mode 100644
index 0980a1575..000000000
--- a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt
+++ /dev/null
@@ -1,183 +0,0 @@
-/*
- * Copyright (c) 2022 Auxio Project
- * InternalPlayer.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 .
- */
-
-package org.oxycblt.auxio.playback.state
-
-import android.net.Uri
-import android.os.SystemClock
-import android.support.v4.media.session.PlaybackStateCompat
-import org.oxycblt.auxio.music.Song
-
-/**
- * An interface for internal audio playback. This can be used to coordinate what occurs in the
- * background playback task.
- *
- * @author Alexander Capehart (OxygenCobalt)
- */
-interface InternalPlayer {
- /** The ID of the audio session started by this instance. */
- val audioSessionId: Int
-
- /** Whether the player should rewind before skipping back. */
- val shouldRewindWithPrev: Boolean
-
- /**
- * Load a new [Song] into the internal player.
- *
- * @param song The [Song] to load, or null if playback should stop entirely.
- * @param play Whether to start playing when the [Song] is loaded.
- */
- fun loadSong(song: Song?, play: Boolean)
-
- /**
- * Called when an [Action] has been queued and this [InternalPlayer] is available to handle it.
- *
- * @param action The [Action] to perform.
- * @return true if the action was handled, false otherwise.
- */
- fun performAction(action: Action): Boolean
-
- /**
- * Get a [State] corresponding to the current player state.
- *
- * @param durationMs The duration of the currently playing track, in milliseconds. Required
- * since the internal player cannot obtain an accurate duration itself.
- */
- fun getState(durationMs: Long): State
-
- /**
- * Seek to a given position in the internal player.
- *
- * @param positionMs The position to seek to, in milliseconds.
- */
- fun seekTo(positionMs: Long)
-
- /**
- * Set whether the player should play or not.
- *
- * @param isPlaying Whether to play or pause the current playback.
- */
- fun setPlaying(isPlaying: Boolean)
-
- /** Possible long-running background tasks handled by the background playback task. */
- sealed interface Action {
- /** Restore the previously saved playback state. */
- data object RestoreState : Action
-
- /**
- * Start shuffled playback of the entire music library. Analogous to the "Shuffle All"
- * shortcut.
- */
- data object ShuffleAll : Action
-
- /**
- * Start playing an audio file at the given [Uri].
- *
- * @param uri The [Uri] of the audio file to start playing.
- */
- data class Open(val uri: Uri) : Action
- }
-
- /**
- * A representation of the current state of audio playback. Use [from] to create an instance.
- */
- class State
- private constructor(
- /** Whether the player is actively playing audio or set to play audio in the future. */
- val isPlaying: Boolean,
- /** Whether the player is actively playing audio in this moment. */
- private val isAdvancing: Boolean,
- /** The position when this instance was created, in milliseconds. */
- private val initPositionMs: Long,
- /** The time this instance was created, as a unix epoch timestamp. */
- private val creationTime: Long
- ) {
- /**
- * Calculate the "real" playback position this instance contains, in milliseconds.
- *
- * @return If paused, the original position will be returned. Otherwise, it will be the
- * original position plus the time elapsed since this state was created.
- */
- fun calculateElapsedPositionMs() =
- if (isAdvancing) {
- initPositionMs + (SystemClock.elapsedRealtime() - creationTime)
- } else {
- // Not advancing due to buffering or some unrelated pausing, such as
- // a transient audio focus change.
- initPositionMs
- }
-
- /**
- * Load this instance into a [PlaybackStateCompat].
- *
- * @param builder The [PlaybackStateCompat.Builder] to mutate.
- * @return The same [PlaybackStateCompat.Builder] for easy chaining.
- */
- fun intoPlaybackState(builder: PlaybackStateCompat.Builder): PlaybackStateCompat.Builder =
- builder.setState(
- // State represents the user's preference, not the actual player state.
- // Doing this produces a better experience in the media control UI.
- if (isPlaying) {
- PlaybackStateCompat.STATE_PLAYING
- } else {
- PlaybackStateCompat.STATE_PAUSED
- },
- initPositionMs,
- if (isAdvancing) {
- 1f
- } else {
- // Not advancing, so don't move the position.
- 0f
- },
- creationTime)
-
- // Equality ignores the creation time to prevent functionally identical states
- // from being non-equal.
-
- override fun equals(other: Any?) =
- other is State &&
- isPlaying == other.isPlaying &&
- isAdvancing == other.isAdvancing &&
- initPositionMs == other.initPositionMs
-
- override fun hashCode(): Int {
- var result = isPlaying.hashCode()
- result = 31 * result + isAdvancing.hashCode()
- result = 31 * result + initPositionMs.hashCode()
- return result
- }
-
- companion object {
- /**
- * Create a new instance.
- *
- * @param isPlaying Whether the player is actively playing audio or set to play audio in
- * the future.
- * @param isAdvancing Whether the player is actively playing audio in this moment.
- * @param positionMs The current position of the player.
- */
- fun from(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
- State(
- isPlaying,
- // Minor sanity check: Make sure that advancing can't occur if already paused.
- isPlaying && isAdvancing,
- positionMs,
- SystemClock.elapsedRealtime())
- }
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt
new file mode 100644
index 000000000..2374a421f
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt
@@ -0,0 +1,384 @@
+/*
+ * Copyright (c) 2024 Auxio Project
+ * PlaybackStateHolder.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 .
+ */
+
+package org.oxycblt.auxio.playback.state
+
+import android.net.Uri
+import android.os.SystemClock
+import android.support.v4.media.session.PlaybackStateCompat
+import org.oxycblt.auxio.list.adapter.UpdateInstructions
+import org.oxycblt.auxio.music.MusicParent
+import org.oxycblt.auxio.music.Song
+
+/**
+ * The designated "source of truth" for the current playback state. Should only be used by
+ * [PlaybackStateManager], which mirrors a more refined version of the state held here.
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+interface PlaybackStateHolder {
+ /** The current [Progression] state of the audio player. */
+ val progression: Progression
+
+ /** The current [RepeatMode] of the audio player. */
+ val repeatMode: RepeatMode
+
+ /** The current [MusicParent] being played from. Null if playing from all songs. */
+ val parent: MusicParent?
+
+ /**
+ * Resolve the current queue state as a [RawQueue].
+ *
+ * @return The current queue state.
+ */
+ fun resolveQueue(): RawQueue
+
+ /** The current audio session ID of the audio player. */
+ val audioSessionId: Int
+
+ /**
+ * Applies a completely new playback state to the holder.
+ *
+ * @param queue The new queue to use.
+ * @param start The song to start playback from. Should be in the queue.
+ * @param parent The parent to play from.
+ * @param shuffled Whether the queue should be shuffled.
+ */
+ fun newPlayback(queue: List, start: Song?, parent: MusicParent?, shuffled: Boolean)
+
+ /**
+ * Update the playing state of the audio player.
+ *
+ * @param playing Whether the player should be playing audio.
+ */
+ fun playing(playing: Boolean)
+
+ /**
+ * Seek to a position in the current song.
+ *
+ * @param positionMs The position to seek to, in milliseconds.
+ */
+ fun seekTo(positionMs: Long)
+
+ /**
+ * Update the repeat mode of the audio player.
+ *
+ * @param repeatMode The new repeat mode.
+ */
+ fun repeatMode(repeatMode: RepeatMode)
+
+ /** Go to the next song in the queue. */
+ fun next()
+
+ /** Go to the previous song in the queue. */
+ fun prev()
+
+ /**
+ * Go to a specific index in the queue.
+ *
+ * @param index The index to go to. Should be in the queue.
+ */
+ fun goto(index: Int)
+
+ /**
+ * Add songs to the currently playing item in the queue.
+ *
+ * @param songs The songs to add.
+ * @param ack The [StateAck] to return to [PlaybackStateManager].
+ */
+ fun playNext(songs: List, ack: StateAck.PlayNext)
+
+ /**
+ * Add songs to the end of the queue.
+ *
+ * @param songs The songs to add.
+ * @param ack The [StateAck] to return to [PlaybackStateManager].
+ */
+ fun addToQueue(songs: List, ack: StateAck.AddToQueue)
+
+ /**
+ * Move a song in the queue to a new position.
+ *
+ * @param from The index of the song to move.
+ * @param to The index to move the song to.
+ * @param ack The [StateAck] to return to [PlaybackStateManager].
+ */
+ fun move(from: Int, to: Int, ack: StateAck.Move)
+
+ /**
+ * Remove a song from the queue.
+ *
+ * @param at The index of the song to remove.
+ * @param ack The [StateAck] to return to [PlaybackStateManager].
+ * @return The [Song] that was removed.
+ */
+ fun remove(at: Int, ack: StateAck.Remove)
+
+ /**
+ * Reorder the queue.
+ *
+ * @param shuffled Whether the queue should be shuffled.
+ */
+ fun shuffled(shuffled: Boolean)
+
+ /**
+ * Handle a deferred playback action.
+ *
+ * @param action The action to handle.
+ * @return Whether the action could be handled, or if it should be deferred for later.
+ */
+ fun handleDeferred(action: DeferredPlayback): Boolean
+
+ /**
+ * Override the current held state with a saved state.
+ *
+ * @param parent The parent to play from.
+ * @param rawQueue The queue to use.
+ * @param ack The [StateAck] to return to [PlaybackStateManager]. If null, do not return any
+ * ack.
+ */
+ fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?)
+}
+
+/**
+ * An acknowledgement that the state of the [PlaybackStateHolder] has changed. This is sent back to
+ * [PlaybackStateManager] once an operation in [PlaybackStateHolder] has completed so that the new
+ * state can be mirrored to the rest of the application.
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+sealed interface StateAck {
+ /**
+ * @see PlaybackStateHolder.next
+ * @see PlaybackStateHolder.prev
+ * @see PlaybackStateHolder.goto
+ */
+ data object IndexMoved : StateAck
+
+ /** @see PlaybackStateHolder.playNext */
+ data class PlayNext(val at: Int, val size: Int) : StateAck
+
+ /** @see PlaybackStateHolder.addToQueue */
+ data class AddToQueue(val at: Int, val size: Int) : StateAck
+
+ /** @see PlaybackStateHolder.move */
+ data class Move(val from: Int, val to: Int) : StateAck
+
+ /** @see PlaybackStateHolder.remove */
+ data class Remove(val index: Int) : StateAck
+
+ /** @see PlaybackStateHolder.shuffled */
+ data object QueueReordered : StateAck
+
+ /**
+ * @see PlaybackStateHolder.newPlayback
+ * @see PlaybackStateHolder.applySavedState
+ */
+ data object NewPlayback : StateAck
+
+ /**
+ * @see PlaybackStateHolder.playing
+ * @see PlaybackStateHolder.seekTo
+ */
+ data object ProgressionChanged : StateAck
+
+ /** @see PlaybackStateHolder.repeatMode */
+ data object RepeatModeChanged : StateAck
+}
+
+/**
+ * The queue as it is represented in the audio player held by [PlaybackStateHolder]. This should not
+ * be used as anything but a container. Use the provided fields to obtain saner queue information.
+ *
+ * @param heap The ordered list of all [Song]s in the queue.
+ * @param shuffledMapping A list of indices that remap the songs in [heap] into a shuffled queue.
+ * Empty if the queue is not shuffled.
+ * @param heapIndex The index of the current song in [heap]. Note that if shuffled, this will be a
+ * nonsensical value that cannot be used to obtain next and last songs without first resolving the
+ * queue.
+ */
+data class RawQueue(
+ val heap: List,
+ val shuffledMapping: List,
+ val heapIndex: Int,
+) {
+ /** Whether the queue is currently shuffled. */
+ val isShuffled = shuffledMapping.isNotEmpty()
+
+ /**
+ * Resolve and return the exact [Song] sequence in the queue.
+ *
+ * @return The [Song]s in the queue, in order.
+ */
+ fun resolveSongs() =
+ if (isShuffled) {
+ shuffledMapping.map { heap[it] }
+ } else {
+ heap
+ }
+
+ /**
+ * Resolve and return the current index of the queue.
+ *
+ * @return The current index of the queue.
+ */
+ fun resolveIndex() =
+ if (isShuffled) {
+ shuffledMapping.indexOf(heapIndex)
+ } else {
+ heapIndex
+ }
+
+ companion object {
+ /** Create a blank instance. */
+ fun nil() = RawQueue(emptyList(), emptyList(), -1)
+ }
+}
+
+/**
+ * Represents the possible changes that can occur during certain queue mutation events.
+ *
+ * @param type The [Type] of the change to the internal queue state.
+ * @param instructions The update done to the resolved queue list.
+ */
+data class QueueChange(val type: Type, val instructions: UpdateInstructions) {
+ enum class Type {
+ /** Only the mapping has changed. */
+ MAPPING,
+
+ /** The mapping has changed, and the index also changed to align with it. */
+ INDEX,
+
+ /**
+ * The current song has changed, possibly alongside the mapping and index depending on the
+ * context.
+ */
+ SONG
+ }
+}
+
+/** Possible long-running background tasks handled by the background playback task. */
+sealed interface DeferredPlayback {
+ /** Restore the previously saved playback state. */
+ data object RestoreState : DeferredPlayback
+
+ /**
+ * Start shuffled playback of the entire music library. Analogous to the "Shuffle All" shortcut.
+ */
+ data object ShuffleAll : DeferredPlayback
+
+ /**
+ * Start playing an audio file at the given [Uri].
+ *
+ * @param uri The [Uri] of the audio file to start playing.
+ */
+ data class Open(val uri: Uri) : DeferredPlayback
+}
+
+/** A representation of the current state of audio playback. Use [from] to create an instance. */
+class Progression
+private constructor(
+ /** Whether the player is actively playing audio or set to play audio in the future. */
+ val isPlaying: Boolean,
+ /** Whether the player is actively playing audio in this moment. */
+ private val isAdvancing: Boolean,
+ /** The position when this instance was created, in milliseconds. */
+ private val initPositionMs: Long,
+ /** The time this instance was created, as a unix epoch timestamp. */
+ private val creationTime: Long
+) {
+ /**
+ * Calculate the "real" playback position this instance contains, in milliseconds.
+ *
+ * @return If paused, the original position will be returned. Otherwise, it will be the original
+ * position plus the time elapsed since this state was created.
+ */
+ fun calculateElapsedPositionMs() =
+ if (isAdvancing) {
+ initPositionMs + (SystemClock.elapsedRealtime() - creationTime)
+ } else {
+ // Not advancing due to buffering or some unrelated pausing, such as
+ // a transient audio focus change.
+ initPositionMs
+ }
+
+ /**
+ * Load this instance into a [PlaybackStateCompat].
+ *
+ * @param builder The [PlaybackStateCompat.Builder] to mutate.
+ * @return The same [PlaybackStateCompat.Builder] for easy chaining.
+ */
+ fun intoPlaybackState(builder: PlaybackStateCompat.Builder): PlaybackStateCompat.Builder =
+ builder.setState(
+ // State represents the user's preference, not the actual player state.
+ // Doing this produces a better experience in the media control UI.
+ if (isPlaying) {
+ PlaybackStateCompat.STATE_PLAYING
+ } else {
+ PlaybackStateCompat.STATE_PAUSED
+ },
+ initPositionMs,
+ if (isAdvancing) {
+ 1f
+ } else {
+ // Not advancing, so don't move the position.
+ 0f
+ },
+ creationTime)
+
+ // Equality ignores the creation time to prevent functionally identical states
+ // from being non-equal.
+
+ override fun equals(other: Any?) =
+ other is Progression &&
+ isPlaying == other.isPlaying &&
+ isAdvancing == other.isAdvancing &&
+ initPositionMs == other.initPositionMs
+
+ override fun hashCode(): Int {
+ var result = isPlaying.hashCode()
+ result = 31 * result + isAdvancing.hashCode()
+ result = 31 * result + initPositionMs.hashCode()
+ return result
+ }
+
+ companion object {
+ /**
+ * Create a new instance.
+ *
+ * @param isPlaying Whether the player is actively playing audio or set to play audio in the
+ * future.
+ * @param isAdvancing Whether the player is actively playing audio in this moment.
+ * @param positionMs The current position of the player.
+ */
+ fun from(isPlaying: Boolean, isAdvancing: Boolean, positionMs: Long) =
+ Progression(
+ isPlaying,
+ // Minor sanity check: Make sure that advancing can't occur if already paused.
+ isPlaying && isAdvancing,
+ positionMs,
+ SystemClock.elapsedRealtime())
+
+ fun nil() =
+ Progression(
+ isPlaying = false,
+ isAdvancing = false,
+ initPositionMs = 0,
+ creationTime = SystemClock.elapsedRealtime())
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
index 870d7a84e..0ebaf669f 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
@@ -20,10 +20,10 @@ package org.oxycblt.auxio.playback.state
import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig
+import org.oxycblt.auxio.list.adapter.UpdateInstructions
+import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.playback.queue.MutableQueue
-import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@@ -40,19 +40,32 @@ import org.oxycblt.auxio.util.logW
* PlaybackService.
*
* Internal consumers should usually use [Listener], however the component that manages the player
- * itself should instead use [InternalPlayer].
+ * itself should instead use [PlaybackStateHolder].
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface PlaybackStateManager {
- /** The current [Queue]. */
- val queue: Queue
- /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
+ /** The current [Progression] of the audio player */
+ val progression: Progression
+
+ /** The current [RepeatMode]. */
+ val repeatMode: RepeatMode
+
+ /** The current [MusicParent] being played from */
val parent: MusicParent?
- /** The current [InternalPlayer] state. */
- val playerState: InternalPlayer.State
- /** The current [RepeatMode] */
- var repeatMode: RepeatMode
+
+ /** The current [Song] being played. Null if nothing is playing. */
+ val currentSong: Song?
+
+ /** The current queue of [Song]s. */
+ val queue: List
+
+ /** The index of the currently playing [Song] in the queue. */
+ val index: Int
+
+ /** Whether the queue is shuffled or not. */
+ val isShuffled: Boolean
+
/** The audio session ID of the internal player. Null if no internal player exists. */
val currentAudioSessionId: Int?
@@ -75,23 +88,25 @@ interface PlaybackStateManager {
fun removeListener(listener: Listener)
/**
- * Register an [InternalPlayer] for this instance. This instance will handle translating the
- * current playback state into audio playback. There can be only one [InternalPlayer] at a time.
- * Will invoke [InternalPlayer] methods to initialize the instance with the current state.
+ * Register an [PlaybackStateHolder] for this instance. This instance will handle translating
+ * the current playback state into audio playback. There can be only one [PlaybackStateHolder]
+ * at a time. Will invoke [PlaybackStateHolder] methods to initialize the instance with the
+ * current state.
*
- * @param internalPlayer The [InternalPlayer] to register. Will do nothing if already
+ * @param stateHolder The [PlaybackStateHolder] to register. Will do nothing if already
* registered.
*/
- fun registerInternalPlayer(internalPlayer: InternalPlayer)
+ fun registerStateHolder(stateHolder: PlaybackStateHolder)
/**
- * Unregister the [InternalPlayer] from this instance, prevent it from receiving any further
- * commands.
+ * Unregister the [PlaybackStateHolder] from this instance, prevent it from receiving any
+ * further commands.
*
- * @param internalPlayer The [InternalPlayer] to unregister. Must be the current
- * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
+ * @param stateHolder The [PlaybackStateHolder] to unregister. Must be the current
+ * [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder]
+ * implementation.
*/
- fun unregisterInternalPlayer(internalPlayer: InternalPlayer)
+ fun unregisterStateHolder(stateHolder: PlaybackStateHolder)
/**
* Start new playback.
@@ -171,38 +186,49 @@ interface PlaybackStateManager {
*
* @param shuffled Whether to shuffle the queue or not.
*/
- fun reorder(shuffled: Boolean)
+ fun shuffled(shuffled: Boolean)
/**
- * Synchronize the state of this instance with the current [InternalPlayer].
+ * Acknowledges that an event has happened that modified the state held by the current
+ * [PlaybackStateHolder].
*
- * @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
- * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
+ * @param stateHolder The [PlaybackStateHolder] to synchronize with. Must be the current
+ * [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder]
+ * implementation.
+ * @param ack The [StateAck] to acknowledge.
*/
- fun synchronizeState(internalPlayer: InternalPlayer)
+ fun ack(stateHolder: PlaybackStateHolder, ack: StateAck)
/**
- * Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually.
+ * Start a [DeferredPlayback] for the current [PlaybackStateHolder] to handle eventually.
*
- * @param action The [InternalPlayer.Action] to perform.
+ * @param action The [DeferredPlayback] to perform.
*/
- fun startAction(action: InternalPlayer.Action)
+ fun playDeferred(action: DeferredPlayback)
/**
- * Request that the pending [InternalPlayer.Action] (if any) be passed to the given
- * [InternalPlayer].
+ * Request that the pending [DeferredPlayback] (if any) be passed to the given
+ * [PlaybackStateHolder].
*
- * @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
- * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
+ * @param stateHolder The [PlaybackStateHolder] to synchronize with. Must be the current
+ * [PlaybackStateHolder]. Does nothing if invoked by another [PlaybackStateHolder]
+ * implementation.
*/
- fun requestAction(internalPlayer: InternalPlayer)
+ fun requestAction(stateHolder: PlaybackStateHolder)
/**
* Update whether playback is ongoing or not.
*
* @param isPlaying Whether playback is ongoing or not.
*/
- fun setPlaying(isPlaying: Boolean)
+ fun playing(isPlaying: Boolean)
+
+ /**
+ * Update the current [RepeatMode].
+ *
+ * @param repeatMode The new [RepeatMode].
+ */
+ fun repeatMode(repeatMode: RepeatMode)
/**
* Seek to the given position in the currently playing [Song].
@@ -240,100 +266,140 @@ interface PlaybackStateManager {
* Called when the position of the currently playing item has changed, changing the current
* [Song], but no other queue attribute has changed.
*
- * @param queue The new [Queue].
+ * @param index The new index of the currently playing [Song].
*/
- fun onIndexMoved(queue: Queue) {}
+ fun onIndexMoved(index: Int) {}
/**
- * Called when the [Queue] changed in a manner outlined by the given [Queue.Change].
+ * Called when the queue changed in a manner outlined by the given [DeferredPlayback].
*
- * @param queue The new [Queue].
- * @param change The type of [Queue.Change] that occurred.
+ * @param queue The songs of the new queue.
+ * @param index The new index of the currently playing [Song].
+ * @param change The [QueueChange] that occurred.
*/
- fun onQueueChanged(queue: Queue, change: Queue.Change) {}
+ fun onQueueChanged(queue: List, index: Int, change: QueueChange) {}
/**
- * Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but
- * the currently playing [Song] has not.
+ * Called when the queue has changed in a non-trivial manner (such as re-shuffling), but the
+ * currently playing [Song] has not.
*
- * @param queue The new [Queue].
+ * @param queue The songs of the new queue.
+ * @param index The new index of the currently playing [Song].
+ * @param isShuffled Whether the queue is shuffled or not.
*/
- fun onQueueReordered(queue: Queue) {}
+ fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) {}
/**
* Called when a new playback configuration was created.
*
- * @param queue The new [Queue].
- * @param parent The new [MusicParent] being played from, or null if playing from all songs.
+ * @param parent The [MusicParent] item currently being played from.
+ * @param queue The queue of [Song]s to play from.
+ * @param index The index of the currently playing [Song].
+ * @param isShuffled Whether the queue is shuffled or not.
*/
- fun onNewPlayback(queue: Queue, parent: MusicParent?) {}
+ fun onNewPlayback(
+ parent: MusicParent?,
+ queue: List,
+ index: Int,
+ isShuffled: Boolean
+ ) {}
/**
- * Called when the state of the [InternalPlayer] changes.
+ * Called when the state of the audio player changes.
*
- * @param state The new state of the [InternalPlayer].
+ * @param progression The new state of the audio player.
*/
- fun onStateChanged(state: InternalPlayer.State) {}
+ fun onProgressionChanged(progression: Progression) {}
/**
* Called when the [RepeatMode] changes.
*
* @param repeatMode The new [RepeatMode].
*/
- fun onRepeatChanged(repeatMode: RepeatMode) {}
+ fun onRepeatModeChanged(repeatMode: RepeatMode) {}
}
/**
* A condensed representation of the playback state that can be persisted.
*
* @param parent The [MusicParent] item currently being played from.
- * @param queueState The [Queue.SavedState]
* @param positionMs The current position in the currently played song, in ms
* @param repeatMode The current [RepeatMode].
*/
data class SavedState(
- val parent: MusicParent?,
- val queueState: Queue.SavedState,
val positionMs: Long,
val repeatMode: RepeatMode,
+ val parent: MusicParent?,
+ val heap: List,
+ val shuffledMapping: List,
+ val index: Int,
+ val songUid: Music.UID,
)
}
class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
+ private data class StateMirror(
+ val progression: Progression,
+ val repeatMode: RepeatMode,
+ val parent: MusicParent?,
+ val queue: List,
+ val index: Int,
+ val isShuffled: Boolean,
+ val rawQueue: RawQueue
+ )
+
private val listeners = mutableListOf()
- @Volatile private var internalPlayer: InternalPlayer? = null
- @Volatile private var pendingAction: InternalPlayer.Action? = null
+
+ @Volatile
+ private var stateMirror =
+ StateMirror(
+ progression = Progression.nil(),
+ repeatMode = RepeatMode.NONE,
+ parent = null,
+ queue = emptyList(),
+ index = -1,
+ isShuffled = false,
+ rawQueue = RawQueue.nil())
+ @Volatile private var stateHolder: PlaybackStateHolder? = null
+ @Volatile private var pendingDeferredPlayback: DeferredPlayback? = null
@Volatile private var isInitialized = false
- override val queue = MutableQueue()
- @Volatile
- override var parent: MusicParent? = null
- private set
+ override val progression
+ get() = stateMirror.progression
- @Volatile
- override var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
- private set
+ override val repeatMode
+ get() = stateMirror.repeatMode
- @Volatile
- override var repeatMode = RepeatMode.NONE
- set(value) {
- field = value
- notifyRepeatModeChanged()
- }
+ override val parent
+ get() = stateMirror.parent
+
+ override val currentSong
+ get() = stateMirror.queue.getOrNull(stateMirror.index)
+
+ override val queue
+ get() = stateMirror.queue
+
+ override val index
+ get() = stateMirror.index
+
+ override val isShuffled
+ get() = stateMirror.isShuffled
override val currentAudioSessionId: Int?
- get() = internalPlayer?.audioSessionId
+ get() = stateHolder?.audioSessionId
@Synchronized
override fun addListener(listener: PlaybackStateManager.Listener) {
logD("Adding $listener to listeners")
- if (isInitialized) {
- listener.onNewPlayback(queue, parent)
- listener.onRepeatChanged(repeatMode)
- listener.onStateChanged(playerState)
- }
-
listeners.add(listener)
+
+ if (isInitialized) {
+ logD("Sending initial state to $listener")
+ listener.onNewPlayback(
+ stateMirror.parent, stateMirror.queue, stateMirror.index, stateMirror.isShuffled)
+ listener.onProgressionChanged(stateMirror.progression)
+ listener.onRepeatModeChanged(stateMirror.repeatMode)
+ }
}
@Synchronized
@@ -345,208 +411,301 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
}
@Synchronized
- override fun registerInternalPlayer(internalPlayer: InternalPlayer) {
- if (this.internalPlayer != null) {
+ override fun registerStateHolder(stateHolder: PlaybackStateHolder) {
+ if (this.stateHolder != null) {
logW("Internal player is already registered")
return
}
- logD("Registering internal player $internalPlayer")
-
- if (isInitialized) {
- internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
- internalPlayer.seekTo(playerState.calculateElapsedPositionMs())
- // See if there's any action that has been queued.
- requestAction(internalPlayer)
- // Once initialized, try to synchronize with the player state it has created.
- synchronizeState(internalPlayer)
+ this.stateHolder = stateHolder
+ if (isInitialized && stateMirror.index > -1) {
+ stateHolder.applySavedState(stateMirror.parent, stateMirror.rawQueue, null)
+ stateHolder.seekTo(stateMirror.progression.calculateElapsedPositionMs())
+ stateHolder.playing(false)
+ pendingDeferredPlayback?.let(stateHolder::handleDeferred)
}
-
- this.internalPlayer = internalPlayer
}
@Synchronized
- override fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
- if (this.internalPlayer !== internalPlayer) {
+ override fun unregisterStateHolder(stateHolder: PlaybackStateHolder) {
+ if (this.stateHolder !== stateHolder) {
logW("Given internal player did not match current internal player")
return
}
- logD("Unregistering internal player $internalPlayer")
+ logD("Unregistering internal player $stateHolder")
- this.internalPlayer = null
+ this.stateHolder = null
}
// --- PLAYING FUNCTIONS ---
@Synchronized
override fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) {
- val internalPlayer = internalPlayer ?: return
+ val stateHolder = stateHolder ?: return
logD("Playing $song from $parent in ${queue.size}-song queue [shuffled=$shuffled]")
- // Set up parent and queue
- this.parent = parent
- this.queue.start(song, queue, shuffled)
- // Notify components of changes
- notifyNewPlayback()
- internalPlayer.loadSong(this.queue.currentSong, true)
// Played something, so we are initialized now
isInitialized = true
+ stateHolder.newPlayback(queue, song, parent, shuffled)
}
// --- QUEUE FUNCTIONS ---
@Synchronized
override fun next() {
- val internalPlayer = internalPlayer ?: return
- var play = true
- if (!queue.goto(queue.index + 1)) {
- queue.goto(0)
- play = repeatMode == RepeatMode.ALL
- logD("At end of queue, wrapping around to position 0 [play=$play]")
- } else {
- logD("Moving to next song")
- }
- notifyIndexMoved()
- internalPlayer.loadSong(queue.currentSong, play)
+ val stateHolder = stateHolder ?: return
+ logD("Going to next song")
+ stateHolder.next()
}
@Synchronized
override fun prev() {
- val internalPlayer = internalPlayer ?: return
- // If enabled, rewind before skipping back if the position is past 3 seconds [3000ms]
- if (internalPlayer.shouldRewindWithPrev) {
- logD("Rewinding current song")
- rewind()
- setPlaying(true)
- } else {
- logD("Moving to previous song")
- if (!queue.goto(queue.index - 1)) {
- queue.goto(0)
- }
- notifyIndexMoved()
- internalPlayer.loadSong(queue.currentSong, true)
- }
+ val stateHolder = stateHolder ?: return
+ logD("Going to previous song")
+ stateHolder.prev()
}
@Synchronized
override fun goto(index: Int) {
- val internalPlayer = internalPlayer ?: return
- if (queue.goto(index)) {
- logD("Moving to $index")
- notifyIndexMoved()
- internalPlayer.loadSong(queue.currentSong, true)
- } else {
- logW("$index was not in bounds, could not move to it")
- }
+ val stateHolder = stateHolder ?: return
+ logD("Going to index $index")
+ stateHolder.goto(index)
}
@Synchronized
override fun playNext(songs: List) {
- if (queue.currentSong == null) {
+ if (currentSong == null) {
logD("Nothing playing, short-circuiting to new playback")
play(songs[0], null, songs, false)
} else {
+ val stateHolder = stateHolder ?: return
logD("Adding ${songs.size} songs to start of queue")
- notifyQueueChanged(queue.addToTop(songs))
+ stateHolder.playNext(songs, StateAck.PlayNext(stateMirror.index + 1, songs.size))
}
}
@Synchronized
override fun addToQueue(songs: List) {
- if (queue.currentSong == null) {
+ if (currentSong == null) {
logD("Nothing playing, short-circuiting to new playback")
play(songs[0], null, songs, false)
} else {
+ val stateHolder = stateHolder ?: return
logD("Adding ${songs.size} songs to end of queue")
- notifyQueueChanged(queue.addToBottom(songs))
+ stateHolder.addToQueue(songs, StateAck.AddToQueue(stateMirror.index + 1, songs.size))
}
}
@Synchronized
override fun moveQueueItem(src: Int, dst: Int) {
+ val stateHolder = stateHolder ?: return
logD("Moving item $src to position $dst")
- notifyQueueChanged(queue.move(src, dst))
+ stateHolder.move(src, dst, StateAck.Move(src, dst))
}
@Synchronized
override fun removeQueueItem(at: Int) {
- val internalPlayer = internalPlayer ?: return
+ val stateHolder = stateHolder ?: return
logD("Removing item at $at")
- val change = queue.remove(at)
- if (change.type == Queue.Change.Type.SONG) {
- internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
- }
- notifyQueueChanged(change)
+ stateHolder.remove(at, StateAck.Remove(at))
}
@Synchronized
- override fun reorder(shuffled: Boolean) {
+ override fun shuffled(shuffled: Boolean) {
+ val stateHolder = stateHolder ?: return
logD("Reordering queue [shuffled=$shuffled]")
- queue.reorder(shuffled)
- notifyQueueReordered()
+ stateHolder.shuffled(shuffled)
}
// --- INTERNAL PLAYER FUNCTIONS ---
@Synchronized
- override fun synchronizeState(internalPlayer: InternalPlayer) {
- if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
- logW("Given internal player did not match current internal player")
- return
- }
-
- val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0)
- if (newState != playerState) {
- playerState = newState
- notifyStateChanged()
- }
- }
-
- @Synchronized
- override fun startAction(action: InternalPlayer.Action) {
- val internalPlayer = internalPlayer
- if (internalPlayer == null || !internalPlayer.performAction(action)) {
+ override fun playDeferred(action: DeferredPlayback) {
+ val stateHolder = stateHolder
+ if (stateHolder == null || !stateHolder.handleDeferred(action)) {
logD("Internal player not present or did not consume action, waiting")
- pendingAction = action
+ pendingDeferredPlayback = action
}
}
@Synchronized
- override fun requestAction(internalPlayer: InternalPlayer) {
- if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
+ override fun requestAction(stateHolder: PlaybackStateHolder) {
+ if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
logW("Given internal player did not match current internal player")
return
}
- if (pendingAction?.let(internalPlayer::performAction) == true) {
+ if (pendingDeferredPlayback?.let(stateHolder::handleDeferred) == true) {
logD("Pending action consumed")
- pendingAction = null
+ pendingDeferredPlayback = null
}
}
@Synchronized
- override fun setPlaying(isPlaying: Boolean) {
+ override fun playing(isPlaying: Boolean) {
+ val stateHolder = stateHolder ?: return
logD("Updating playing state to $isPlaying")
- internalPlayer?.setPlaying(isPlaying)
+ stateHolder.playing(isPlaying)
+ }
+
+ @Synchronized
+ override fun repeatMode(repeatMode: RepeatMode) {
+ val stateHolder = stateHolder ?: return
+ logD("Updating repeat mode to $repeatMode")
+ stateHolder.repeatMode(repeatMode)
}
@Synchronized
override fun seekTo(positionMs: Long) {
+ val stateHolder = stateHolder ?: return
logD("Seeking to ${positionMs}ms")
- internalPlayer?.seekTo(positionMs)
+ stateHolder.seekTo(positionMs)
+ }
+
+ @Synchronized
+ override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) {
+ if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) {
+ logW("Given internal player did not match current internal player")
+ return
+ }
+
+ when (ack) {
+ is StateAck.IndexMoved -> {
+ val rawQueue = stateHolder.resolveQueue()
+ stateMirror = stateMirror.copy(index = rawQueue.resolveIndex(), rawQueue = rawQueue)
+ listeners.forEach { it.onIndexMoved(stateMirror.index) }
+ }
+ is StateAck.PlayNext -> {
+ val rawQueue = stateHolder.resolveQueue()
+ val change =
+ QueueChange(QueueChange.Type.MAPPING, UpdateInstructions.Add(ack.at, ack.size))
+ stateMirror =
+ stateMirror.copy(
+ queue = rawQueue.resolveSongs(),
+ rawQueue = rawQueue,
+ )
+ listeners.forEach {
+ it.onQueueChanged(stateMirror.queue, stateMirror.index, change)
+ }
+ }
+ is StateAck.AddToQueue -> {
+ val rawQueue = stateHolder.resolveQueue()
+ val change =
+ QueueChange(QueueChange.Type.MAPPING, UpdateInstructions.Add(ack.at, ack.size))
+ stateMirror =
+ stateMirror.copy(
+ queue = rawQueue.resolveSongs(),
+ rawQueue = rawQueue,
+ )
+ listeners.forEach {
+ it.onQueueChanged(stateMirror.queue, stateMirror.index, change)
+ }
+ }
+ is StateAck.Move -> {
+ val rawQueue = stateHolder.resolveQueue()
+ val newIndex = rawQueue.resolveIndex()
+ val change =
+ QueueChange(
+ if (stateMirror.index != newIndex) QueueChange.Type.INDEX
+ else QueueChange.Type.MAPPING,
+ UpdateInstructions.Move(ack.from, ack.to))
+
+ stateMirror =
+ stateMirror.copy(
+ queue = rawQueue.resolveSongs(),
+ index = newIndex,
+ rawQueue = rawQueue,
+ )
+
+ listeners.forEach {
+ it.onQueueChanged(stateMirror.queue, stateMirror.index, change)
+ }
+ }
+ is StateAck.Remove -> {
+ val rawQueue = stateHolder.resolveQueue()
+ val newIndex = rawQueue.resolveIndex()
+ val change =
+ QueueChange(
+ when {
+ ack.index == stateMirror.index -> QueueChange.Type.SONG
+ stateMirror.index != newIndex -> QueueChange.Type.INDEX
+ else -> QueueChange.Type.MAPPING
+ },
+ UpdateInstructions.Remove(ack.index, 1))
+
+ stateMirror =
+ stateMirror.copy(
+ queue = rawQueue.resolveSongs(),
+ index = newIndex,
+ rawQueue = rawQueue,
+ )
+
+ listeners.forEach {
+ it.onQueueChanged(stateMirror.queue, stateMirror.index, change)
+ }
+ }
+ is StateAck.QueueReordered -> {
+ val rawQueue = stateHolder.resolveQueue()
+ stateMirror =
+ stateMirror.copy(
+ queue = rawQueue.resolveSongs(),
+ index = rawQueue.resolveIndex(),
+ isShuffled = rawQueue.isShuffled,
+ rawQueue = rawQueue)
+ listeners.forEach {
+ it.onQueueReordered(
+ stateMirror.queue, stateMirror.index, stateMirror.isShuffled)
+ }
+ }
+ is StateAck.NewPlayback -> {
+ val rawQueue = stateHolder.resolveQueue()
+ stateMirror =
+ stateMirror.copy(
+ parent = stateHolder.parent,
+ queue = rawQueue.resolveSongs(),
+ index = rawQueue.resolveIndex(),
+ isShuffled = rawQueue.isShuffled,
+ rawQueue = rawQueue)
+ listeners.forEach {
+ it.onNewPlayback(
+ stateMirror.parent,
+ stateMirror.queue,
+ stateMirror.index,
+ stateMirror.isShuffled)
+ }
+ }
+ is StateAck.ProgressionChanged -> {
+ stateMirror =
+ stateMirror.copy(
+ progression = stateHolder.progression,
+ )
+ listeners.forEach { it.onProgressionChanged(stateMirror.progression) }
+ }
+ is StateAck.RepeatModeChanged -> {
+ stateMirror =
+ stateMirror.copy(
+ repeatMode = stateHolder.repeatMode,
+ )
+ listeners.forEach { it.onRepeatModeChanged(stateMirror.repeatMode) }
+ }
+ }
}
// --- PERSISTENCE FUNCTIONS ---
@Synchronized
- override fun toSavedState() =
- queue.toSavedState()?.let {
- PlaybackStateManager.SavedState(
- parent = parent,
- queueState = it,
- positionMs = playerState.calculateElapsedPositionMs(),
- repeatMode = repeatMode)
- }
+ override fun toSavedState(): PlaybackStateManager.SavedState? {
+ val currentSong = currentSong ?: return null
+ return PlaybackStateManager.SavedState(
+ positionMs = stateMirror.progression.calculateElapsedPositionMs(),
+ repeatMode = stateMirror.repeatMode,
+ parent = stateMirror.parent,
+ heap = stateMirror.rawQueue.heap,
+ shuffledMapping = stateMirror.rawQueue.shuffledMapping,
+ index = stateMirror.index,
+ songUid = currentSong.uid,
+ )
+ }
@Synchronized
override fun applySavedState(
@@ -554,77 +713,84 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
destructive: Boolean
) {
if (isInitialized && !destructive) {
- logW("Already initialized, cannot apply saved state")
+ logW("Already initialized, cannot apply saved state")
return
}
- val internalPlayer = internalPlayer ?: return
- logD("Applying state $savedState")
- val lastSong = queue.currentSong
- parent = savedState.parent
- queue.applySavedState(savedState.queueState)
- repeatMode = savedState.repeatMode
- notifyNewPlayback()
-
- // Check if we need to reload the player with a new music file, or if we can just leave
- // it be. Specifically done so we don't pause on music updates that don't really change
- // what's playing (ex. playlist editing)
- if (lastSong != queue.currentSong) {
- logD("Song changed, must reload player")
- // Continuing playback while also possibly doing drastic state updates is
- // a bad idea, so pause.
- internalPlayer.loadSong(queue.currentSong, false)
- if (queue.currentSong != null) {
- logD("Seeking to saved position ${savedState.positionMs}ms")
- // Internal player may have reloaded the media item, re-seek to the previous
- // position
- seekTo(savedState.positionMs)
+ // The heap may not be the same if the song composition changed between state saves/reloads.
+ // This also means that we must modify the shuffled mapping as well, in what it points to
+ // and it's general composition.
+ val heap = mutableListOf()
+ val adjustments = mutableListOf()
+ var currentShift = 0
+ for (song in savedState.heap) {
+ if (song != null) {
+ heap.add(song)
+ adjustments.add(currentShift)
+ } else {
+ adjustments.add(null)
+ currentShift -= 1
}
}
+
+ logD("Created adjustment mapping [max shift=$currentShift]")
+
+ val shuffledMapping =
+ savedState.shuffledMapping.mapNotNullTo(mutableListOf()) { index ->
+ adjustments[index]?.let { index + it }
+ }
+
+ // Make sure we re-align the index to point to the previously playing song.
+ fun pointingAtSong(): Boolean {
+ val currentSong =
+ if (shuffledMapping.isNotEmpty()) {
+ shuffledMapping.getOrNull(savedState.index)?.let { heap.getOrNull(it) }
+ } else {
+ heap.getOrNull(savedState.index)
+ }
+
+ return currentSong?.uid == savedState.songUid
+ }
+
+ var index = savedState.index
+ while (!pointingAtSong() && index > -1) {
+ index--
+ }
+
+ logD("Corrected index: ${savedState.index} -> $index")
+
+ check(shuffledMapping.all { it in heap.indices }) {
+ "Queue inconsistency detected: Shuffled mapping indices out of heap bounds"
+ }
+
+ val rawQueue =
+ RawQueue(
+ heap = heap,
+ shuffledMapping = shuffledMapping,
+ heapIndex =
+ if (shuffledMapping.isNotEmpty()) {
+ shuffledMapping[savedState.index]
+ } else {
+ index
+ })
+
+ if (index > -1) {
+ // Valid state where something needs to be played, direct the stateholder to apply
+ // this new state.
+ val oldStateMirror = stateMirror
+ if (oldStateMirror.rawQueue != rawQueue) {
+ logD("Queue changed, must reload player")
+ stateHolder?.applySavedState(parent, rawQueue, StateAck.NewPlayback)
+ stateHolder?.playing(false)
+ }
+
+ if (oldStateMirror.progression.calculateElapsedPositionMs() != savedState.positionMs) {
+ logD("Seeking to saved position ${savedState.positionMs}ms")
+ stateHolder?.seekTo(savedState.positionMs)
+ stateHolder?.playing(false)
+ }
+ }
+
isInitialized = true
}
-
- // --- CALLBACKS ---
-
- private fun notifyIndexMoved() {
- logD("Dispatching index change")
- for (callback in listeners) {
- callback.onIndexMoved(queue)
- }
- }
-
- private fun notifyQueueChanged(change: Queue.Change) {
- logD("Dispatching queue change $change")
- for (callback in listeners) {
- callback.onQueueChanged(queue, change)
- }
- }
-
- private fun notifyQueueReordered() {
- logD("Dispatching queue reordering")
- for (callback in listeners) {
- callback.onQueueReordered(queue)
- }
- }
-
- private fun notifyNewPlayback() {
- logD("Dispatching new playback")
- for (callback in listeners) {
- callback.onNewPlayback(queue, parent)
- }
- }
-
- private fun notifyStateChanged() {
- logD("Dispatching player state change")
- for (callback in listeners) {
- callback.onStateChanged(playerState)
- }
- }
-
- private fun notifyRepeatModeChanged() {
- logD("Dispatching repeat mode change")
- for (callback in listeners) {
- callback.onRepeatChanged(repeatMode)
- }
- }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt
index 233e490c3..e09d9ba2a 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt
@@ -29,18 +29,10 @@ import java.util.*
*
* @author media3 team, Alexander Capehart (OxygenCobalt)
*/
-class BetterShuffleOrder
-private constructor(private val shuffled: IntArray, private val random: Random) : ShuffleOrder {
+class BetterShuffleOrder(private val shuffled: IntArray) : ShuffleOrder {
private val indexInShuffled: IntArray = IntArray(shuffled.size)
- /**
- * Creates an instance with a specified length.
- *
- * @param length The length of the shuffle order.
- */
- constructor(length: Int) : this(length, Random())
-
- constructor(length: Int, random: Random) : this(createShuffledList(length, random), random)
+ constructor(length: Int, startIndex: Int) : this(createShuffledList(length, startIndex))
init {
for (i in shuffled.indices) {
@@ -70,7 +62,12 @@ private constructor(private val shuffled: IntArray, private val random: Random)
return if (shuffled.isNotEmpty()) shuffled[0] else C.INDEX_UNSET
}
+ @Suppress("KotlinConstantConditions") // Bugged for this function
override fun cloneAndInsert(insertionIndex: Int, insertionCount: Int): ShuffleOrder {
+ if (shuffled.isEmpty()) {
+ return BetterShuffleOrder(insertionCount, -1)
+ }
+
val newShuffled = IntArray(shuffled.size + insertionCount)
val pivot = indexInShuffled[insertionIndex]
for (i in shuffled.indices) {
@@ -88,7 +85,7 @@ private constructor(private val shuffled: IntArray, private val random: Random)
for (i in 0 until insertionCount) {
newShuffled[pivot + i + 1] = insertionIndex + i + 1
}
- return BetterShuffleOrder(newShuffled, Random(random.nextLong()))
+ return BetterShuffleOrder(newShuffled)
}
override fun cloneAndRemove(indexFrom: Int, indexToExclusive: Int): ShuffleOrder {
@@ -104,21 +101,27 @@ private constructor(private val shuffled: IntArray, private val random: Random)
else shuffled[i]
}
}
- return BetterShuffleOrder(newShuffled, Random(random.nextLong()))
+ return BetterShuffleOrder(newShuffled)
}
override fun cloneAndClear(): ShuffleOrder {
- return BetterShuffleOrder(0, Random(random.nextLong()))
+ return BetterShuffleOrder(0, -1)
}
companion object {
- private fun createShuffledList(length: Int, random: Random): IntArray {
+ private fun createShuffledList(length: Int, startIndex: Int): IntArray {
val shuffled = IntArray(length)
for (i in 0 until length) {
- val swapIndex = random.nextInt(i + 1)
+ val swapIndex = (0..i).random()
shuffled[i] = shuffled[swapIndex]
shuffled[swapIndex] = i
}
+ if (startIndex != -1) {
+ val startIndexInShuffled = shuffled.indexOf(startIndex)
+ val temp = shuffled[0]
+ shuffled[0] = shuffled[startIndexInShuffled]
+ shuffled[startIndexInShuffled] = temp
+ }
return shuffled
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt
index 65192cf10..83b6bcbcd 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt
@@ -39,7 +39,7 @@ class MediaButtonReceiver : BroadcastReceiver() {
// TODO: Figure this out
override fun onReceive(context: Context, intent: Intent) {
- if (playbackManager.queue.currentSong != null) {
+ if (playbackManager.currentSong != null) {
// We have a song, so we can assume that the service will start a foreground state.
// At least, I hope. Again, *this is why we don't do this*. I cannot describe how
// stupid this is with the state of foreground services on modern android. One
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
index 1910b1a01..cbccf56ec 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
@@ -38,9 +38,9 @@ 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.queue.Queue
-import org.oxycblt.auxio.playback.state.InternalPlayer
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
@@ -117,28 +117,29 @@ constructor(
// --- PLAYBACKSTATEMANAGER OVERRIDES ---
- override fun onIndexMoved(queue: Queue) {
- updateMediaMetadata(queue.currentSong, playbackManager.parent)
+ override fun onIndexMoved(index: Int) {
+ updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
invalidateSessionState()
}
- override fun onQueueChanged(queue: Queue, change: Queue.Change) {
+ override fun onQueueChanged(queue: List, index: Int, change: QueueChange) {
updateQueue(queue)
when (change.type) {
// Nothing special to do with mapping changes.
- Queue.Change.Type.MAPPING -> {}
+ QueueChange.Type.MAPPING -> {}
// Index changed, ensure playback state's index changes.
- Queue.Change.Type.INDEX -> invalidateSessionState()
+ QueueChange.Type.INDEX -> invalidateSessionState()
// Song changed, ensure metadata changes.
- Queue.Change.Type.SONG -> updateMediaMetadata(queue.currentSong, playbackManager.parent)
+ QueueChange.Type.SONG ->
+ updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
}
}
- override fun onQueueReordered(queue: Queue) {
+ override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) {
updateQueue(queue)
invalidateSessionState()
mediaSession.setShuffleMode(
- if (queue.isShuffled) {
+ if (isShuffled) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
@@ -146,21 +147,26 @@ constructor(
invalidateSecondaryAction()
}
- override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
- updateMediaMetadata(queue.currentSong, parent)
+ override fun onNewPlayback(
+ parent: MusicParent?,
+ queue: List,
+ index: Int,
+ isShuffled: Boolean
+ ) {
+ updateMediaMetadata(playbackManager.currentSong, parent)
updateQueue(queue)
invalidateSessionState()
}
- override fun onStateChanged(state: InternalPlayer.State) {
+ override fun onProgressionChanged(progression: Progression) {
invalidateSessionState()
- notification.updatePlaying(playbackManager.playerState.isPlaying)
+ notification.updatePlaying(playbackManager.progression.isPlaying)
if (!bitmapProvider.isBusy) {
listener?.onPostNotification(notification)
}
}
- override fun onRepeatChanged(repeatMode: RepeatMode) {
+ override fun onRepeatModeChanged(repeatMode: RepeatMode) {
mediaSession.setRepeatMode(
when (repeatMode) {
RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE
@@ -175,7 +181,7 @@ constructor(
override fun onImageSettingsChanged() {
// Need to reload the metadata cover.
- updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent)
+ updateMediaMetadata(playbackManager.currentSong, playbackManager.parent)
}
override fun onNotificationActionChanged() {
@@ -211,11 +217,11 @@ constructor(
}
override fun onPlay() {
- playbackManager.setPlaying(true)
+ playbackManager.playing(true)
}
override fun onPause() {
- playbackManager.setPlaying(false)
+ playbackManager.playing(false)
}
override fun onSkipToNext() {
@@ -236,21 +242,21 @@ constructor(
override fun onRewind() {
playbackManager.rewind()
- playbackManager.setPlaying(true)
+ playbackManager.playing(true)
}
override fun onSetRepeatMode(repeatMode: Int) {
- playbackManager.repeatMode =
+ 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.reorder(
+ playbackManager.shuffled(
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL ||
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP)
}
@@ -354,9 +360,9 @@ constructor(
*
* @param queue The current queue to upload.
*/
- private fun updateQueue(queue: Queue) {
+ private fun updateQueue(queue: List) {
val queueItems =
- queue.resolve().mapIndexed { i, song ->
+ queue.mapIndexed { i, song ->
val description =
MediaDescriptionCompat.Builder()
// Media ID should not be the item index but rather the UID,
@@ -383,11 +389,11 @@ constructor(
val state =
// InternalPlayer.State handles position/state information.
- playbackManager.playerState
+ playbackManager.progression
.intoPlaybackState(PlaybackStateCompat.Builder())
.setActions(ACTIONS)
// Active queue ID corresponds to the indices we populated prior, use them here.
- .setActiveQueueItemId(playbackManager.queue.index.toLong())
+ .setActiveQueueItemId(playbackManager.index.toLong())
// Android 13+ relies on custom actions in the notification.
@@ -399,7 +405,7 @@ constructor(
PlaybackStateCompat.CustomAction.Builder(
PlaybackService.ACTION_INVERT_SHUFFLE,
context.getString(R.string.desc_shuffle),
- if (playbackManager.queue.isShuffled) {
+ if (playbackManager.isShuffled) {
R.drawable.ic_shuffle_on_24
} else {
R.drawable.ic_shuffle_off_24
@@ -435,7 +441,7 @@ constructor(
when (playbackSettings.notificationAction) {
ActionMode.SHUFFLE -> {
logD("Using shuffle notification action")
- notification.updateShuffled(playbackManager.queue.isShuffled)
+ notification.updateShuffled(playbackManager.isShuffled)
}
else -> {
logD("Using repeat mode notification action")
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
index 7ac1c66cf..ba54a1f18 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
@@ -44,18 +44,25 @@ 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.InternalPlayer
+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
@@ -82,7 +89,8 @@ import org.oxycblt.auxio.widgets.WidgetProvider
class PlaybackService :
Service(),
Player.Listener,
- InternalPlayer,
+ PlaybackStateHolder,
+ PlaybackSettings.Listener,
MediaSessionComponent.Listener,
MusicRepository.UpdateListener {
// Player components
@@ -111,6 +119,7 @@ class PlaybackService :
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 ---
@@ -148,9 +157,10 @@ class PlaybackService :
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.registerInternalPlayer(this)
+ playbackManager.registerStateHolder(this)
musicRepository.addUpdateListener(this)
mediaSessionComponent.registerListener(this)
+ playbackSettings.registerListener(this)
val intentFilter =
IntentFilter().apply {
@@ -181,7 +191,13 @@ class PlaybackService :
override fun onBind(intent: Intent): IBinder? = null
- // TODO: Implement task removal (Have to radically alter state saving to occur at runtime)
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ super.onTaskRemoved(rootIntent)
+ if (!playbackManager.progression.isPlaying) {
+ playbackManager.playing(false)
+ endSession()
+ }
+ }
override fun onDestroy() {
super.onDestroy()
@@ -189,9 +205,10 @@ class PlaybackService :
foregroundManager.release()
// Pause just in case this destruction was unexpected.
- playbackManager.setPlaying(false)
- playbackManager.unregisterInternalPlayer(this)
+ playbackManager.playing(false)
+ playbackManager.unregisterStateHolder(this)
musicRepository.removeUpdateListener(this)
+ playbackSettings.unregisterListener(this)
unregisterReceiver(systemReceiver)
serviceJob.cancel()
@@ -210,101 +227,327 @@ class PlaybackService :
logD("Service destroyed")
}
- // --- CONTROLLER OVERRIDES ---
+ // --- 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 val shouldRewindWithPrev: Boolean
- get() = playbackSettings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
-
- override fun getState(durationMs: Long) =
- InternalPlayer.State.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(durationMs))
-
- override fun loadSong(song: Song?, play: Boolean) {
- if (song == null) {
- // No song, stop playback and foreground state.
- logD("Nothing playing, stopping playback")
- // For some reason the player does not mark playWhenReady as false when stopped,
- // which then completely breaks any re-initialization if playback starts again.
- // So we manually set it to false here.
- player.playWhenReady = false
- player.stop()
- stopAndSave()
- return
+ override fun newPlayback(
+ queue: List,
+ 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))
}
-
- logD("Loading $song")
- player.setMediaItem(MediaItem.fromUri(song.uri))
+ val target =
+ startIndex ?: player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled)
+ player.seekTo(target, C.TIME_UNSET)
player.prepare()
- player.playWhenReady = play
+ 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) {
- logD("Seeking to ${positionMs}ms")
player.seekTo(positionMs)
+ // Dispatched later once all of the changes have been accumulated
+ // Deferred save is handled on position discontinuity
}
- override fun setPlaying(isPlaying: Boolean) {
- logD("Updating player state to $isPlaying")
- player.playWhenReady = isPlaying
+ 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, ack: StateAck.PlayNext) {
+ player.addMediaItems(player.nextMediaItemIndex, songs.map { it.toMediaItem() })
+ playbackManager.ack(this, ack)
+ deferSave()
+ }
+
+ override fun addToQueue(songs: List, 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) }
}
// --- 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.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
- 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
- }
- }
- // Any change to the analogous isPlaying, isAdvancing, or positionMs values require
- // us to synchronize with a new state.
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.synchronizeState(this)
- }
- }
-
- override fun onPlaybackStateChanged(state: Int) {
- if (state == Player.STATE_ENDED) {
- // Player ended, repeat the current track if we are configured to.
- if (playbackManager.repeatMode == RepeatMode.TRACK) {
- logD("Looping current track")
- playbackManager.rewind()
- // May be configured to pause when we repeat a track.
- if (playbackSettings.pauseOnRepeat) {
- logD("Pausing track on loop")
- playbackManager.setPlaying(false)
- }
- } else {
- logD("Track ended, moving to next track")
- playbackManager.next()
- }
+ playbackManager.ack(this, StateAck.ProgressionChanged)
}
}
@@ -316,6 +559,12 @@ class PlaybackService :
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.
@@ -324,71 +573,6 @@ class PlaybackService :
}
}
- // --- OTHER FUNCTIONS ---
-
- 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 stopAndSave() {
- // This session has ended, so we need to reset this flag for when the next session starts.
- hasPlayed = false
- if (foregroundManager.tryStopForeground()) {
- // Now that we have ended the foreground state (and thus music playback), we'll need
- // to save the current state as it's not long until this service (and likely the whole
- // app) is killed.
- logD("Saving playback state")
- saveScope.launch { persistenceRepository.saveState(playbackManager.toSavedState()) }
- }
- }
-
- override fun performAction(action: InternalPlayer.Action): Boolean {
- val deviceLibrary =
- musicRepository.deviceLibrary
- // No library, cannot do anything.
- ?: return false
-
- when (action) {
- // Restore state -> Start a new restoreState job
- is InternalPlayer.Action.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 InternalPlayer.Action.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 InternalPlayer.Action.Open -> {
- logD("Opening specified file")
- deviceLibrary.findSongForUri(application, action.uri)?.let { song ->
- playbackManager.play(
- song,
- null,
- listSettings.songSort.songs(deviceLibrary.songs),
- playbackManager.queue.isShuffled && playbackSettings.keepShuffle)
- }
- }
- }
-
- return true
- }
-
- // --- MEDIASESSIONCOMPONENT OVERRIDES ---
-
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
@@ -402,6 +586,104 @@ class PlaybackService :
}
}
+ // --- PLAYER MANAGEMENT ---
+
+ private fun updatePauseOnRepeat() {
+ player.pauseAtEndOfMediaItems =
+ playbackManager.repeatMode == RepeatMode.TRACK && playbackSettings.pauseOnRepeat
+ }
+
+ private fun ExoPlayer.unscrambleQueueIndices(): List {
+ val timeline = currentTimeline
+ if (timeline.isEmpty) {
+ return emptyList()
+ }
+ val queue = mutableListOf()
+
+ // 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.
@@ -437,15 +719,15 @@ class PlaybackService :
// --- AUXIO EVENTS ---
ACTION_PLAY_PAUSE -> {
logD("Received play event")
- playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
+ playbackManager.playing(!playbackManager.progression.isPlaying)
}
ACTION_INC_REPEAT_MODE -> {
logD("Received repeat mode event")
- playbackManager.repeatMode = playbackManager.repeatMode.increment()
+ playbackManager.repeatMode(playbackManager.repeatMode.increment())
}
ACTION_INVERT_SHUFFLE -> {
logD("Received shuffle event")
- playbackManager.reorder(!playbackManager.queue.isShuffled)
+ playbackManager.shuffled(!playbackManager.isShuffled)
}
ACTION_SKIP_PREV -> {
logD("Received skip previous event")
@@ -457,8 +739,8 @@ class PlaybackService :
}
ACTION_EXIT -> {
logD("Received exit event")
- playbackManager.setPlaying(false)
- stopAndSave()
+ playbackManager.playing(false)
+ endSession()
}
WidgetProvider.ACTION_WIDGET_UPDATE -> {
logD("Received widget update event")
@@ -472,28 +754,28 @@ class PlaybackService :
// 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.queue.currentSong != null &&
+ playbackManager.currentSong != null &&
initialHeadsetPlugEventHandled) {
logD("Device connected, resuming")
- playbackManager.setPlaying(true)
+ playbackManager.playing(true)
}
}
private fun pauseFromHeadsetPlug() {
- if (playbackManager.queue.currentSong != null) {
+ if (playbackManager.currentSong != null) {
logD("Device disconnected, pausing")
- playbackManager.setPlaying(false)
+ 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"
- private const val REWIND_THRESHOLD = 3000L
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
index a80bc446d..2ece33656 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
@@ -68,6 +68,9 @@ class AboutFragment : ViewBindingFragment() {
binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) }
binding.aboutProfile.setOnClickListener { requireContext().openInBrowser(LINK_PROFILE) }
binding.aboutDonate.setOnClickListener { requireContext().openInBrowser(LINK_DONATE) }
+ binding.aboutSupporterYrliet.setOnClickListener {
+ requireContext().openInBrowser(LINK_YRLIET)
+ }
binding.aboutSupportersPromo.setOnClickListener {
requireContext().openInBrowser(LINK_DONATE)
}
@@ -97,5 +100,6 @@ class AboutFragment : ViewBindingFragment() {
const val LINK_LICENSES = "$LINK_WIKI/Licenses"
const val LINK_PROFILE = "https://github.com/OxygenCobalt"
const val LINK_DONATE = "https://github.com/sponsors/OxygenCobalt"
+ const val LINK_YRLIET = "https://github.com/yrliet"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt
index 4149cc0f5..78961662f 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt
@@ -32,7 +32,6 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.ui.WrappedDialogPreference
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe
-import org.oxycblt.auxio.util.showToast
/**
* The [PreferenceFragmentCompat] that displays the root settings list.
@@ -84,41 +83,6 @@ class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) {
}
getString(R.string.set_key_reindex) -> musicModel.refresh()
getString(R.string.set_key_rescan) -> musicModel.rescan()
- getString(R.string.set_key_save_state) -> {
- playbackModel.savePlaybackState { saved ->
- // Use the nullable context, as we could try to show a toast when this
- // fragment is no longer attached.
- logD("Showing saving confirmation")
- if (saved) {
- context?.showToast(R.string.lbl_state_saved)
- } else {
- context?.showToast(R.string.err_did_not_save)
- }
- }
- }
- getString(R.string.set_key_wipe_state) -> {
- playbackModel.wipePlaybackState { wiped ->
- logD("Showing wipe confirmation")
- if (wiped) {
- // Use the nullable context, as we could try to show a toast when this
- // fragment is no longer attached.
- context?.showToast(R.string.lbl_state_wiped)
- } else {
- context?.showToast(R.string.err_did_not_wipe)
- }
- }
- }
- getString(R.string.set_key_restore_state) ->
- playbackModel.tryRestorePlaybackState { restored ->
- logD("Showing restore confirmation")
- if (restored) {
- // Use the nullable context, as we could try to show a toast when this
- // fragment is no longer attached.
- context?.showToast(R.string.lbl_state_restored)
- } else {
- context?.showToast(R.string.err_did_not_restore)
- }
- }
else -> return super.onPreferenceTreeClick(preference)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt
index 6e60eadf2..d723dd5e3 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt
@@ -152,6 +152,8 @@ private fun Fragment.launch(
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(state, block) }
}
+const val DEFAULT_TIMEOUT = 60000L
+
/**
* Wraps [SendChannel.send] with a specified timeout.
*
@@ -160,7 +162,7 @@ private fun Fragment.launch(
* @throws TimeoutException If the timeout is reached, provides context on what element
* specifically.
*/
-suspend fun SendChannel.sendWithTimeout(element: E, timeout: Long = 10000) {
+suspend fun SendChannel.sendWithTimeout(element: E, timeout: Long = DEFAULT_TIMEOUT) {
try {
withTimeout(timeout) { send(element) }
} catch (e: TimeoutCancellationException) {
@@ -179,7 +181,7 @@ suspend fun SendChannel.sendWithTimeout(element: E, timeout: Long = 10000
* specifically.
*/
suspend fun ReceiveChannel.forEachWithTimeout(
- timeout: Long = 10000,
+ timeout: Long = DEFAULT_TIMEOUT,
action: suspend (E) -> Unit
) {
var exhausted = false
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
index e6f86736a..09ac5e8f1 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
@@ -31,9 +31,9 @@ import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.playback.queue.Queue
-import org.oxycblt.auxio.playback.state.InternalPlayer
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.ui.UISettings
import org.oxycblt.auxio.util.getDimenPixels
@@ -64,7 +64,7 @@ constructor(
/** Update [WidgetProvider] with the current playback state. */
fun update() {
- val song = playbackManager.queue.currentSong
+ val song = playbackManager.currentSong
if (song == null) {
logD("No song, resetting widget")
widgetProvider.update(context, uiSettings, null)
@@ -72,9 +72,9 @@ constructor(
}
// Note: Store these values here so they remain consistent once the bitmap is loaded.
- val isPlaying = playbackManager.playerState.isPlaying
+ val isPlaying = playbackManager.progression.isPlaying
val repeatMode = playbackManager.repeatMode
- val isShuffled = playbackManager.queue.isShuffled
+ val isShuffled = playbackManager.isShuffled
logD("Updating widget with new playback state")
bitmapProvider.load(
@@ -136,15 +136,26 @@ constructor(
// --- CALLBACKS ---
// Respond to all major song or player changes that will affect the widget
- override fun onIndexMoved(queue: Queue) = update()
+ override fun onIndexMoved(index: Int) = update()
- override fun onQueueReordered(queue: Queue) = update()
+ override fun onQueueChanged(queue: List, index: Int, change: QueueChange) {
+ if (change.type == QueueChange.Type.SONG) {
+ update()
+ }
+ }
- override fun onNewPlayback(queue: Queue, parent: MusicParent?) = update()
+ override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) = update()
- override fun onStateChanged(state: InternalPlayer.State) = update()
+ override fun onNewPlayback(
+ parent: MusicParent?,
+ queue: List,
+ index: Int,
+ isShuffled: Boolean
+ ) = update()
- override fun onRepeatChanged(repeatMode: RepeatMode) = update()
+ override fun onProgressionChanged(progression: Progression) = update()
+
+ override fun onRepeatModeChanged(repeatMode: RepeatMode) = update()
// Respond to settings changes that will affect the widget
override fun onRoundModeChanged() = update()
@@ -154,11 +165,12 @@ constructor(
/**
* A condensed form of the playback state that is safe to use in AppWidgets.
*
- * @param song [Queue.currentSong]
+ * @param song [PlaybackStateManager.currentSong]
* @param cover A pre-loaded album cover [Bitmap] for [song].
- * @param isPlaying [PlaybackStateManager.playerState]
+ * @param cover A pre-loaded album cover [Bitmap] for [song], with rounded corners.
+ * @param isPlaying [PlaybackStateManager.progression]
* @param repeatMode [PlaybackStateManager.repeatMode]
- * @param isShuffled [Queue.isShuffled]
+ * @param isShuffled [PlaybackStateManager.isShuffled]
*/
data class PlaybackState(
val song: Song,
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
index 153b7bccf..7a3bc6c40 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
@@ -91,11 +91,14 @@ class WidgetProvider : AppWidgetProvider() {
// the widget elements, plus some leeway for text sizing.
val views =
mapOf(
- SizeF(180f, 100f) to newThinLayout(context, uiSettings, state),
- SizeF(180f, 152f) to newSmallLayout(context, uiSettings, state),
- SizeF(272f, 152f) to newWideLayout(context, uiSettings, state),
- SizeF(180f, 272f) to newMediumLayout(context, uiSettings, state),
- SizeF(272f, 272f) to newLargeLayout(context, uiSettings, state))
+ SizeF(180f, 48f) to newThinStickLayout(context, state),
+ SizeF(304f, 48f) to newWideStickLayout(context, state),
+ SizeF(180f, 100f) to newThinWaferLayout(context, uiSettings, state),
+ SizeF(304f, 100f) to newWideWaferLayout(context, uiSettings, state),
+ SizeF(180f, 152f) to newThinDockedLayout(context, uiSettings, state),
+ SizeF(304f, 152f) to newWideDockedLayout(context, uiSettings, state),
+ SizeF(180f, 272f) to newThinPaneLayout(context, uiSettings, state),
+ SizeF(304f, 272f) to newWidePaneLayout(context, uiSettings, state))
// Manually update AppWidgetManager with the new views.
val awm = AppWidgetManager.getInstance(context)
@@ -139,60 +142,78 @@ class WidgetProvider : AppWidgetProvider() {
private fun newDefaultLayout(context: Context) =
newRemoteViews(context, R.layout.widget_default)
- private fun newThinLayout(
+ private fun newThinStickLayout(context: Context, state: WidgetComponent.PlaybackState) =
+ newRemoteViews(context, R.layout.widget_stick_thin).setupTimelineControls(context, state)
+
+ private fun newWideStickLayout(context: Context, state: WidgetComponent.PlaybackState) =
+ newRemoteViews(context, R.layout.widget_stick_wide).setupFullControls(context, state)
+
+ private fun newThinWaferLayout(
context: Context,
uiSettings: UISettings,
state: WidgetComponent.PlaybackState
) =
- newRemoteViews(context, R.layout.widget_thin)
+ newRemoteViews(context, R.layout.widget_wafer_thin)
.setupBackground(
uiSettings,
)
- .setupPlaybackState(context, state)
+ .setupCover(context, state.takeIf { canDisplayWaferCover(uiSettings) })
.setupTimelineControls(context, state)
- private fun newSmallLayout(
+ private fun newWideWaferLayout(
context: Context,
uiSettings: UISettings,
state: WidgetComponent.PlaybackState
) =
- newRemoteViews(context, R.layout.widget_small)
+ newRemoteViews(context, R.layout.widget_wafer_wide)
+ .setupBackground(
+ uiSettings,
+ )
+ .setupCover(context, state.takeIf { canDisplayWaferCover(uiSettings) })
+ .setupFullControls(context, state)
+
+ private fun newThinDockedLayout(
+ context: Context,
+ uiSettings: UISettings,
+ state: WidgetComponent.PlaybackState
+ ) =
+ newRemoteViews(context, R.layout.widget_docked_thin)
.setupBar(
uiSettings,
)
.setupCover(context, state)
.setupTimelineControls(context, state)
- private fun newMediumLayout(
+ private fun newWideDockedLayout(
context: Context,
uiSettings: UISettings,
state: WidgetComponent.PlaybackState
) =
- newRemoteViews(context, R.layout.widget_medium)
- .setupBackground(
- uiSettings,
- )
- .setupPlaybackState(context, state)
- .setupTimelineControls(context, state)
-
- private fun newWideLayout(
- context: Context,
- uiSettings: UISettings,
- state: WidgetComponent.PlaybackState
- ) =
- newRemoteViews(context, R.layout.widget_wide)
+ newRemoteViews(context, R.layout.widget_docked_wide)
.setupBar(
uiSettings,
)
.setupCover(context, state)
.setupFullControls(context, state)
- private fun newLargeLayout(
+ private fun newThinPaneLayout(
context: Context,
uiSettings: UISettings,
state: WidgetComponent.PlaybackState
) =
- newRemoteViews(context, R.layout.widget_large)
+ newRemoteViews(context, R.layout.widget_pane_thin)
+ .setupBackground(
+ uiSettings,
+ )
+ .setupPlaybackState(context, state)
+ .setupTimelineControls(context, state)
+
+ private fun newWidePaneLayout(
+ context: Context,
+ uiSettings: UISettings,
+ state: WidgetComponent.PlaybackState
+ ) =
+ newRemoteViews(context, R.layout.widget_pane_wide)
.setupBackground(
uiSettings,
)
@@ -246,8 +267,14 @@ class WidgetProvider : AppWidgetProvider() {
*/
private fun RemoteViews.setupCover(
context: Context,
- state: WidgetComponent.PlaybackState
+ state: WidgetComponent.PlaybackState?
): RemoteViews {
+ if (state == null) {
+ setImageViewBitmap(R.id.widget_cover, null)
+ setContentDescription(R.id.widget_cover, null)
+ return this
+ }
+
if (state.cover != null) {
setImageViewBitmap(R.id.widget_cover, state.cover)
setContentDescription(
@@ -388,6 +415,18 @@ class WidgetProvider : AppWidgetProvider() {
return this
}
+ private fun useRoundedRemoteViews(uiSettings: UISettings) =
+ uiSettings.roundMode || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+
+ private fun canDisplayWaferCover(uiSettings: UISettings) =
+ // We cannot display album covers in the wafer-style widget when round mode is enabled
+ // below Android 12, as:
+ // - We cannot rely on system widget corner clipping, like on Android 12+
+ // - We cannot manually clip the widget ourselves due to broken clipToOutline support
+ // - We cannot determine the exact widget height that would allow us to clip the loaded
+ // image itself
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S || !uiSettings.roundMode
+
companion object {
/**
* Broadcast when [WidgetProvider] desires to update it's widget with new information.
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
index cd7151b13..799aa8a67 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt
@@ -28,7 +28,6 @@ import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.LayoutRes
import kotlin.math.sqrt
-import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isLandscape
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent
@@ -139,13 +138,3 @@ fun AppWidgetManager.updateAppWidgetCompat(
}
}
}
-
-/**
- * Returns whether rounded UI elements are appropriate for the widget, either based on the current
- * settings or if the widget has to fit in aesthetically with other widgets.
- *
- * @param [uiSettings] [UISettings] required to obtain round mode configuration.
- * @return true if to use round mode, false otherwise.
- */
-fun useRoundedRemoteViews(uiSettings: UISettings) =
- uiSettings.roundMode || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
diff --git a/app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml b/app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml
new file mode 100644
index 000000000..38fb204ce
--- /dev/null
+++ b/app/src/main/res/drawable-v31/ui_widget_rectangle_button_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_author_24.xml b/app/src/main/res/drawable/ic_person_24.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_author_24.xml
rename to app/src/main/res/drawable/ic_person_24.xml
diff --git a/app/src/main/res/drawable/ic_remote_default_cover_24.xml b/app/src/main/res/drawable/ic_remote_default_cover_24.xml
index fa860ad09..9a084874c 100644
--- a/app/src/main/res/drawable/ic_remote_default_cover_24.xml
+++ b/app/src/main/res/drawable/ic_remote_default_cover_24.xml
@@ -4,10 +4,17 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
-
+ android:pathData="M 2.6000008,9.3143836e-7 H 21.399999 c 1.4404,0 2.6,1.15959996856164 2.6,2.59999986856164 V 21.399999 c 0,1.4404 -1.1596,2.6 -2.6,2.6 H 2.6000008 c -1.4403999,0 -2.59999986856164,-1.1596 -2.59999986856164,-2.6 V 2.6000008 C 9.3143836e-7,1.1596009 1.1596009,9.3143836e-7 2.6000008,9.3143836e-7 Z" />
+
+
+
+
diff --git a/app/src/main/res/drawable/ui_widget_circle_button_bg.xml b/app/src/main/res/drawable/ui_widget_circle_button_bg.xml
new file mode 100644
index 000000000..75c293f4b
--- /dev/null
+++ b/app/src/main/res/drawable/ui_widget_circle_button_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml b/app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml
new file mode 100644
index 000000000..03b519850
--- /dev/null
+++ b/app/src/main/res/drawable/ui_widget_rectangle_button_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml
index 1088aff21..06211fab4 100644
--- a/app/src/main/res/layout/fragment_about.xml
+++ b/app/src/main/res/layout/fragment_about.xml
@@ -182,7 +182,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/lbl_author_name"
- app:drawableStartCompat="@drawable/ic_author_24"
+ app:drawableStartCompat="@drawable/ic_person_24"
app:drawableTint="?attr/colorControlNormal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@@ -224,6 +224,18 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
+
+
+ app:sdMainFabClosedSrc="@drawable/ic_add_24" />
diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml
index 0e85a7e51..a4c2804a9 100644
--- a/app/src/main/res/layout/fragment_main.xml
+++ b/app/src/main/res/layout/fragment_main.xml
@@ -18,7 +18,10 @@
app:navGraph="@navigation/inner"
tools:layout="@layout/fragment_home" />
-
+
-
+
diff --git a/app/src/main/res/layout/widget_small.xml b/app/src/main/res/layout/widget_docked_thin.xml
similarity index 100%
rename from app/src/main/res/layout/widget_small.xml
rename to app/src/main/res/layout/widget_docked_thin.xml
diff --git a/app/src/main/res/layout/widget_wide.xml b/app/src/main/res/layout/widget_docked_wide.xml
similarity index 100%
rename from app/src/main/res/layout/widget_wide.xml
rename to app/src/main/res/layout/widget_docked_wide.xml
diff --git a/app/src/main/res/layout/widget_medium.xml b/app/src/main/res/layout/widget_pane_thin.xml
similarity index 100%
rename from app/src/main/res/layout/widget_medium.xml
rename to app/src/main/res/layout/widget_pane_thin.xml
diff --git a/app/src/main/res/layout/widget_large.xml b/app/src/main/res/layout/widget_pane_wide.xml
similarity index 100%
rename from app/src/main/res/layout/widget_large.xml
rename to app/src/main/res/layout/widget_pane_wide.xml
diff --git a/app/src/main/res/layout/widget_stick_thin.xml b/app/src/main/res/layout/widget_stick_thin.xml
new file mode 100644
index 000000000..f4651eaf1
--- /dev/null
+++ b/app/src/main/res/layout/widget_stick_thin.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_stick_wide.xml b/app/src/main/res/layout/widget_stick_wide.xml
new file mode 100644
index 000000000..675b3f8cb
--- /dev/null
+++ b/app/src/main/res/layout/widget_stick_wide.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_thin.xml b/app/src/main/res/layout/widget_thin.xml
deleted file mode 100644
index a3201fc54..000000000
--- a/app/src/main/res/layout/widget_thin.xml
+++ /dev/null
@@ -1,106 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/widget_wafer_thin.xml b/app/src/main/res/layout/widget_wafer_thin.xml
new file mode 100644
index 000000000..fe7ec01dc
--- /dev/null
+++ b/app/src/main/res/layout/widget_wafer_thin.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_wafer_wide.xml b/app/src/main/res/layout/widget_wafer_wide.xml
new file mode 100644
index 000000000..f32d97b4b
--- /dev/null
+++ b/app/src/main/res/layout/widget_wafer_wide.xml
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml
index 3a7d1044b..45df86ad1 100644
--- a/app/src/main/res/values-be/strings.xml
+++ b/app/src/main/res/values-be/strings.xml
@@ -319,4 +319,13 @@
Плэйліст экспартаваны
Экспартаваць плэйліст
Немагчыма экспартаваць плэйліст ў гэты файл
+ Імпартаваць плэйліст
+ Рэгуляванне ReplayGain песні
+ Рэгуляванне ReplayGain альбома
+ Аўтар
+ Ахвярнасць
+ Прыхільнікі
+ Ахвяруйце на праект, каб ваша імя было дададзена тут!
+ Запамінаць паўзу
+ Пакідаць прайграванне/паўзу падчас пропуску або рэдагаванні чаргі
\ No newline at end of file
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index c62df4253..6d70c2fce 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -330,4 +330,13 @@
Import
Seznam skladeb importován
Seznam skladeb exportován
+ Importovat seznam skladeb
+ Úprava ReplayGain u stopy
+ Přispět
+ Podporovatelé
+ Autor
+ Úprava ReplayGain u alba
+ Přispějte na projekt a uvidíte zde své jméno!
+ Zůstat ve stavu přehrávání/pozastavení při přeskakování nebo úpravě fronty
+ Zapamatovat pozastavení
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 2714faddd..8ce66a7bb 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -311,4 +311,23 @@
Pfad
Wiedergabeliste konnte nicht aus dieser Datei importiert werden
Leere Wiedergabeliste
+ ReplayGain-Albenanpassung
+ ReplayGain-Trackanpassung
+ Autor
+ Spenden
+ Unterstützer
+ Spende für das Projekt, damit dein Name hier aufgenommen wird!
+ Wiedergabeliste importieren
+ Wiedergabeliste exportieren
+ Pfadstil
+ Abolut
+ Relativ
+ Windows-kompatible Pfade verwenden
+ Exportieren
+ Wiedergabeliste konnte nicht in diese Datei exportiert werden
+ Importieren
+ Wiedergabeliste importiert
+ Wiedergabeliste exportiert
+ Pause merken
+ Wiedergabe/Pause beim Springen oder Bearbeiten der Warteschlange beibehalten
\ No newline at end of file
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 08dfc0973..924e8f202 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -325,4 +325,13 @@
Lista de reproducción exportada
Exportar lista de reproducción
No se puede exportar la lista de reproducción a este archivo
+ Importar lista de reproducción
+ Ajuste de pista de ganancia de reproducción
+ Donar
+ Partidarios
+ Ajuste del álbum de ganancia de reproducción
+ Autor
+ ¡Haga una donación al proyecto para que agreguen su nombre aquí!
+ Recordar la pausa
+ Permanecer en reproducción/pausa al saltar o editar la cola
\ No newline at end of file
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 95271d9ff..89a526160 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -272,4 +272,31 @@
Lisää
Kopioitu
Ilmoita virheestä
+ Valinta
+ Tuotu soittolista
+ Tekijä
+ Lahjoita
+ Tukijat
+ Soittolista tuotu
+ Soittolista viety
+ Lahjoita projektille saadaksesi nimesi näkyviin tähän!
+ Ei albumeja
+ Tyhjä soittolista
+ Tuo soittolista
+ Polku
+ Tuo
+ Vie
+ Vie soittolista
+ Järjestys
+ Suunta
+ Polun tyyli
+ Absoluuttinen
+ Suhteellinen
+ Käytä Windows-yhteensopivia polkuja
+ Mukautettu ilmoituksen toiminto
+ ReplayGain-kappalesäätö
+ Muista keskeytys
+ ReplayGain-albumisäätö
+ Soittolistan tuonti tästä tiedostosta ei onnistu
+ Soittolistan vienti tähän tiedostoon ei onnistu
\ No newline at end of file
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 87d236503..f40159028 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -44,7 +44,7 @@
- %d एल्बम
- - %d एल्बम
+ - %d एल्बमस
नाम
शैली
@@ -58,7 +58,7 @@
तिथि जोड़ी गई
गाने लोड हो रहे है
गाने लोड हो रहे है
- एंड्रॉयड के लिए एक सीधा साधा, विवेकशील गाने बजाने वाला ऐप।
+ एंड्रॉयड के लिए एक सीधा साधा,विवेकशील गाने बजाने वाला ऐप।
नई प्लेलिस्ट
अगला चलाएं
लायब्रेरी टैब्स
@@ -320,4 +320,13 @@
प्लेलिस्ट एक्सपोर्ट की गई
प्लेलिस्ट एक्सपोर्ट करें
प्लेलिस्ट को इस फ़ाइल में एक्सपोर्ट करने में असमर्थ
+ प्लेलिस्ट इम्पोर्ट करें
+ रीप्लेगेन ट्रैक एडजस्टमेंट
+ रीप्लेगेन एल्बम एडजस्टमेंट
+ समर्थक
+ लेखक
+ दान करें
+ अपना नाम यहां जुड़वाने के लिए परियोजना में दान करें!
+ विराम याद रखें
+ कतार छोड़ते या संपादित करते समय चलता/रोका रखिए
\ No newline at end of file
diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml
index cadb7a570..73c4b3fff 100644
--- a/app/src/main/res/values-hr/strings.xml
+++ b/app/src/main/res/values-hr/strings.xml
@@ -71,11 +71,11 @@
Zvuk
Slušalice: odmah reproduciraj
Uvijek pokreni reprodukciju kada su slušalice povezane (možda neće raditi na svim uređajima)
- Strategija pojačanja
+ ReplayGain strategija
Preferiraj zvučni zapis
Preferiraj album
Ako se reproducira album, preferiraj album
- Pretpojačalo pojačanja
+ ReplayGain pretpojačalo
Pretpojačalo je tijekom reprodukcije primijenjeno postojećoj prilagodbi
Prilagođavanje s oznakama
Prilagođavanje bez oznaka
@@ -193,10 +193,10 @@
Zapamti miješanje glazbe
Vrati prethodno spremljeno stanje reprodukcije (ako postoji)
Reproduciraj iz albuma
- Pauziraj čim se pjesma ponovi
+ Pauziraj pri ponavljanju pjesme
Premotaj prije vraćanja na prethodnu pjesmu
Reproduciraj ili pauziraj
- Pauziraj na ponavljanje
+ Pauziraj pri ponavljanju
Sadržaj
Spremi stanje reprodukcije
Vrati stanje reprodukcije
@@ -249,7 +249,7 @@
Wiki
%1$s, %2$s
Resetiraj
- ReplayGain izjednačavanje glasnoće
+ ReplayGain
Mape
Silazno
Promijenite temu i boje aplikacije
@@ -301,9 +301,28 @@
Kopirano
Nema albuma
Uvezen popis pjesama
- Staza
+ Putanja
Demo snimka
Demo snimke
- Nije bilo moguće uvesti popis pjesama iz ove datoteke
+ Nije moguće uvesti popis pjesama iz ove datoteke
Prazan popis pjesama
+ Autor
+ Doniraj
+ Podržavatelji
+ Doniraj projektu za dodavanje tvog imena ovdje!
+ Uvezi popis pjesama
+ Nije moguće izvesti popis pjesama u ovu datoteku
+ Uvezi
+ Izvezi
+ Izvezi popis pjesama
+ Stil putanje
+ Absolutno
+ Relativno
+ Koristi Windows kompatibilne putanje
+ Popis pjesama je uvezen
+ Popis pjesama je izvezen
+ Podešavanje ReplayGain pjesme
+ Podešavanje ReplayGain albuma
+ Zapamti pauzu
+ Nastavi reprodukciju/pauziranje prilikom preskakanja ili uređivanja slijeda
\ No newline at end of file
diff --git a/app/src/main/res/values-ia/strings.xml b/app/src/main/res/values-ia/strings.xml
new file mode 100644
index 000000000..c7aec119c
--- /dev/null
+++ b/app/src/main/res/values-ia/strings.xml
@@ -0,0 +1,79 @@
+
+
+ Cargante le musica
+ Retentar
+ Plus
+ Conceder
+ Cantos
+ Canto
+ Tote le cantos
+ Albumes
+ Album
+ EP
+ Album remix
+ EPs
+ EP de remix
+ Compilationes
+ Compilation
+ Compilatom de remix
+ Demo
+ Demos
+ Artista
+ Artistas
+ Lista de reproduction
+ Listas de reproduction
+ Nove lista de reproduction
+ Lista de reproduction vacue
+ Importar
+ Exportar
+ Renominar
+ Renominar lista de reproduction
+ Deler
+ Deler le lista de reproduction?
+ Modificar
+ Toto
+ Filtrar
+ Nomine
+ Duration
+ Numero de cantos
+ Tracia
+ Aleatori
+ Ordinar
+ Cauda
+ Ordinar per
+ Reproduction in curso
+ Reproducer
+ Reproducer sequente
+ Adder al cauda
+ Adder
+ Version
+ Codice fonte
+ Wiki
+ Licentias
+ Singles
+ Single
+ Single remix
+ Mixtapes
+ Mixtape
+ Exportar le lista de reproduction
+ Anno
+ Vider
+ Direction
+ Equalisator
+ Adder al lista de reproduction
+ Vider le proprietates
+ Compartir
+ Proprietates del canto
+ Percurso
+ Formato
+ Dimension
+ Taxa de monstra
+ OK
+ Cancellar
+ Salveguardar
+ Reinitialisar
+ Stylo de percurso
+ Usar percursos compatibile con Windows
+ Stato salveguardate
+ A proposito de
+
\ No newline at end of file
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index df16c74a2..e39afd69e 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -2,7 +2,7 @@
מוזיקה נטענת
מוזיקה נטענת
- לנסות שוב
+ נסה שוב
ספריית המוזיקה שלך נסרקת
כל השירים
אלבומים
@@ -158,7 +158,7 @@
המגבר מוחל על ההתאמה הקיימת בזמן השמעה
רשימת השמעה חדשה
הוספה לרשימת השמעה
- לתת
+ הענק
רשימת השמעה (פלייליסט)
רשימות השמעה
מחיקה
@@ -299,4 +299,22 @@
%1$s, %2$s
ליים
%s נערך
+ אין אלבומים
+ מחלט
+ יבא
+ השתמש בנתיבים המותאמים למערכת חלונות
+ יבא רשימת השמעה
+ נתיב
+ יצא
+ רשימת השמעה מיובאת
+ דמו
+ יחסי
+ רשימת השמעה יובאה
+ דמו
+ אין יכולת לייבא רשימת השמעה מהקובץ הנ”ל
+ רשימת השמעה ריקה
+ צורת נתיב
+ רשימת השמעה יוצאה
+ יצא רשימת השמעה
+ אין יכולת לייצא רשימת השמעה מהקובץ הנ”ל
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 7efc46d01..9d7f7f28b 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -47,7 +47,7 @@
버전
소스 코드
라이선스
- Alexander Capehart가 개발
+ Alexander Capehart
라이브러리 통계
설정
@@ -309,4 +309,19 @@
데모
데모
빈 재생 목록
+ 재생 목록을 가져왔습니다.
+ 개발자
+ 재생 목록 가져오기
+ 후원
+ 서포터
+ 여기에 이름을 올리고 싶으시면 프로젝트를 후원해 주세요!
+ 재생 목록을 가져왔습니다.
+ 재생 목록을 내보냈습니다.
+ 절대
+ 상대
+ Windows 호환 경로 사용
+ 가져오기
+ 내보내기
+ 재생 목록 내보내기
+ 경로 스타일
\ No newline at end of file
diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml
index 3fa2e9b91..15c4f41fd 100644
--- a/app/src/main/res/values-lt/strings.xml
+++ b/app/src/main/res/values-lt/strings.xml
@@ -4,10 +4,10 @@
Visos dainos
Paieška
Filtruoti
- Visos
+ Visi
Rūšiavimas
Pavadinimas
- Metai
+ Data
Trukmė
Dainos skaičius
Diskas
@@ -21,10 +21,10 @@
Peržiūrėti ypatybes
Dydis
Bitų srautas
- Mėginių ėmimo dažnis
+ Skaitmeninimo dažnis
Automatinis
- Šviesus
- Tamsus
+ Šviesi
+ Tamsi
Spalvų schema
Juodoji tema
Atlikėjai
@@ -34,7 +34,7 @@
Groti
Licencijos
Maišyti
- Pridėtas į eilę
+ Pridėta į eilę
Dainų ypatybės
Išsaugoti
Apie
@@ -44,21 +44,21 @@
Versija
Nustatymai
Tema
- Naudoti grynai juodą tamsią temą
+ Naudoti grynai juodą tamsią temą.
Paprastas, racionalus Android muzikos grotuvas.
- Muzikos pakraunimas
+ Muzikos pakrovimas
Peržiūrėk ir valdyk muzikos grojimą
Žanrai
Pakartoti
Suteikti
Kraunama muzika
- Kraunamas tavo muzikos biblioteka…
+ Kraunama tavo muzikos biblioteka…
Bibliotekos statistika
Rožinis
Albumas
Mini albumas
Singlas
- Atlikėjas (-a)
+ Atlikėjas
Nežinomas žanras
Nėra datos
Raudona
@@ -82,7 +82,7 @@
ReplayGain strategija
Singlai
Gerai
- Įjungti suapvalintų kampų papildomiems UI elementams (reikia, kad albumo viršeliai būtų suapvalinti)
+ Įjungti suapvalintų kampų papildomiems UI elementams (reikia, kad albumo viršeliai būtų suapvalinti).
Garso takelis
Garso takeliai
Garsas
@@ -120,56 +120,56 @@
Gyvai albumas
Remikso albumas
Gyvai
- Visada pradėti groti, kai ausinės yra prijungtos (gali neveikti visuose įrenginiuose)
+ Visada pradėti groti, kai ausinės yra prijungtos (gali neveikti visuose įrenginiuose).
Ogg garsas
- Sukūrė Alexanderis Capehartas (angl. Alexander Capehart)
- Pageidauti takelį
- Jokių aplankų
- Šis aplankas nepalaikomas
+ Aleksandras Keiphartas (angl. Alexander Capehart)
+ Pageidauti takeliui
+ Nėra aplankų
+ Šis aplankas nepalaikomas.
Groti arba pristabdyti
Praleisti į kitą dainą
Praleisti į paskutinę dainą
Mikstapas
Mikstapai
Bibliotekos skirtukai
- Keisti bibliotekos skirtukų matomumą ir tvarką
+ Keisti bibliotekos skirtukų matomumą ir tvarką.
Pageidauti albumui
Pageidauti albumui, jei vienas groja
- Jokią programą nerasta, kuri galėtų atlikti šią užduotį
+ Programėlę nerasta, kuri galėtų atlikti šią užduotį.
Auxio piktograma
Perkelti šią dainą
Perkelti šį skirtuką
- Muzikos įkrovimas nepavyko
- Auxio reikia leidimo skaityti tavo muzikos biblioteką
+ Muzikos pakrovimas nepavyko.
+ Auxio reikia leidimo skaityti tavo muzikos biblioteką.
Diskas %d
+%.1f dB
-%.1f dB
Bendra trukmė: %s
- Gyvas singlas
+ Gyvai singlas
Remikso singlas
- Kompiliacijos
- Kompiliacija
+ Rinkiniai
+ Rinkinys
Prisiminti maišymą
- Palikti maišymą įjungtą, kai groja nauja daina
+ Palikti maišymą įjungtą, kai groja nauja daina.
Persukti prieš praleistant atgal
- Persukti atgal prieš praleistant į ankstesnę dainą
- Pauzė ant kartojamo
+ Persukti atgal prieš praleistant į ankstesnę dainą.
+ Pauzė ant kartojimo
Kai grojant iš bibliotekos
Kai grojant iš elemento detalių
Pašalinti aplanką
Žanras
- Ieškoti savo bibliotekoje…
+ Ieškok savo bibliotekoje…
Ekvalaizeris
Režimas
- Automatinis įkrovimas
- Jokios muzikos nerasta
+ Automatinis perkrauvimas
+ Muzikos nerasta.
Sustabdyti grojimą
Nėra takelio
Praleisti į kitą
Automatinis ausinių grojimas
Kartojimo režimas
Atidaryti eilę
- Išvalyti paieškos paraišką
+ Išvalyti paieškos užklausą
Muzika nebus kraunama iš pridėtų aplankų, kurių tu pridėsi.
Įtraukti
Pašalinti šią dainą
@@ -181,39 +181,39 @@
Neįtraukti
Muzika bus kraunama iš aplankų, kurių tu pridėsi.
%d Hz
- Perkrauti muzikos biblioteką, kai ji pasikeičia (reikia nuolatinio pranešimo)
+ Perkrauti muzikos biblioteką, kai ji pasikeičia (reikia nuolatinio pranešimo).
Pakrautos dainos: %d
Pakrautos žanros: %d
Pakrauti albumai: %d
Pakrauti atlikėjai: %d
Kraunama tavo muzikos biblioteka… (%1$d/%2$d)
Maišyti visas dainas
- Personalizuotas
- Įspėjimas: keičiant išankstinį stiprintuvą į didelę teigiamą vertę, kai kuriuose garso takeliuose gali atsirasti tarpų.
+ Suasmeninti
+ Įspėjimas: keičiant išankstinį stiprintuvą į didelę teigiamą reikšmę, kai kuriuose garso takeliuose gali atsirasti tarpų.
Albumo viršelis %s
Atlikėjo vaizdas %s
Nėra grojančio muzikos
- Sustabdyti, kai daina kartojasi
+ Sustabdyti, kai daina kartojasi.
Turinys
Muzikos aplankai
Atnaujinti muziką
- Perkrauti muzikos biblioteką, naudojant talpyklos žymes, kai įmanoma
+ Perkrauti muzikos biblioteką, naudojant talpyklos žymes, kai įmanoma.
Pasirinktinis grojimo juostos veiksmas
- Nepavyko atkurti būsenos
+ Nepavyksta atkurti būsenos.
ReplayGain išankstinis stiprintuvas
Išsaugoti grojimo būseną
- Tvarkyti, kur muzika turėtų būti įkeliama iš
+ Tvarkyti, kur muzika turėtų būti kraunama iš.
Žanro vaizdas %s
Įjungti maišymą arba išjungti
- Takelis %d
+ %d takelis
Keisti kartojimo režimą
Indigos
%d kbps
DJ miksai
DJ miksas
- Gyvai kompiliacija
- Remikso kompiliacija
- Išvalyti anksčiau išsaugotą grojimo būseną (jei yra)
+ Gyvai rinkinys
+ Remikso rinkinys
+ Išvalyti anksčiau išsaugotą grojimo būseną (jei yra).
Daugiareikšmiai separatoriai
Pasvirasis brūkšnys (/)
Pliusas (+)
@@ -221,56 +221,56 @@
Albumų viršeliai
Išjungta
Greitis
- Išsaugoti dabartinę grojimo būseną dabar
+ Išsaugoti dabartinę grojimo būseną dabar.
Išvalyti grojimo būseną
- Konfigūruoti simbolius, kurie nurodo kelias žymių reikšmes
+ Konfigūruoti simbolius, kurie nurodo kelias žymių reikšmes.
Kablelis (,)
- Reguliavimas be žymų
- Įspėjimas: naudojant šį nustatymą, kai kurios žymos gali būti neteisingai interpretuojamos kaip turinčios kelias reikšmes. Tai galima išspręsti prieš nepageidaujamus skiriamuosius ženklus su agalinių brūkšniu (\\).
+ Koregavimas be žymių
+ Įspėjimas: naudojant šį nustatymą, kai kurios žymes gali būti neteisingai interpretuojamos kaip turinčios kelias reikšmes. Tai galima išspręsti prieš nepageidaujamus skiriamuosius ženklus su agalinių brūkšniu (\\).
Kabliataškis (;)
Aukštos kokybės
Atkurti grojimo būseną
Neįtraukti nemuzikinių
- Ignoruoti garso failus, kurie nėra muzika, pvz., tinklalaides
- Išankstinis stiprintuvas taikomas esamam reguliavimui grojimo metu
- Reguliavimas su žymėmis
- Atkurti anksčiau išsaugotą grojimo būseną (jei yra)
+ Ignoruoti garso failus, kurie nėra muzika, tokius kaip tinklalaides.
+ Išankstinis stiprintuvas taikomas esamam koregavimui grojimo metu.
+ Koregavimas su žymėmis
+ Atkurti anksčiau išsaugotą grojimo būseną (jei yra).
Slėpti bendradarbius
- Rodyti tik tuos atlikėjus, kurie yra tiesiogiai įrašyti į albumą (geriausiai veikia gerai pažymėtose bibliotekose)
- Nepavyko išvalyti būsenos
- Nepavyko išsaugoti būsenos
+ Rodyti tik tuos atlikėjus, kurie yra tiesiogiai įtraukti į albumą (geriausiai veikia gerai pažymėtose bibliotekose).
+ Nepavyksta išvalyti būsenos.
+ Nepavyksta išsaugoti būsenos.
- - %d atlikėjas (-a)
+ - %d atlikėjas
- %d atlikėjai
- - %d atlikėjų
- %d atlikėjų
+ - %d atlikėjų
Perskenuoti muziką
- Išvalyti žymių talpyklą ir pilnai perkrauti muzikos biblioteką (lėčiau, bet labiau išbaigta)
+ Išvalyti žymių talpyklą ir pilnai perkrauti muzikos biblioteką (lėčiau, bet labiau užbaigta).
%d pasirinkta
Groti iš žanro
Viki
%1$s, %2$s
- Nustatyti iš naujo
+ Atkurti
Biblioteka
Elgesys
- Pakeisk programos temą ir spalvas
- Valdyk, kaip muzika ir vaizdai įkeliami
- Konfigūruok garso ir grojimo elgesį
- Pritaikyk UI valdiklius ir elgseną
+ Pakeisk programėlės temą ir spalvas.
+ Valdyk, kaip muzika ir vaizdai kraunami.
+ Konfigūruok garso ir grojimo elgesį.
+ Pritaikyk naudotojo sąsajos valdiklius ir elgseną.
Muzika
Vaizdai
Grojimas
ReplayGain
- Aplankalai
+ Aplankai
Pastovumas
Mažėjantis
- Teisingai surūšiuoti pavadinimus, kurie prasideda skaičiais arba žodžiais, tokiais kaip „the“ (geriausiai veikia su anglų kalbos muzika)
+ Teisingai surūšiuoti pavadinimus, kurie prasideda skaičiais arba žodžiais, tokiais kaip „the“ (geriausiai veikia su anglų kalbos muzika).
Išmanusis rūšiavimas
Grojaraštis
Grojaraščiai
Grojaraščio vaizdas %s
- Sukurti naują grojaraštį
+ Kurti naują grojaraštį
Naujas grojaraštis
Pridėti į grojaraštį
Pridėta į grojaraštį
@@ -283,7 +283,7 @@
Redaguoti
Bendrinti
Pervadintas grojaraštis
- Grojaraštis %d
+ %d grojaraštis
Sukurtas grojaraštis
Ištrintas grojaraštis
Nėra disko
@@ -291,7 +291,7 @@
Pasirodo
Daina
Peržiūrėti
- Apkarpyti visus albumų viršelius iki 1:1 kraštinių koeficiento
+ Apkarpyti visus albumų viršelius iki 1:1 kraštinių koeficiento.
Priversti kvadratinių albumų viršelius
Groti dainą pačią
Rūšiuoti pagal
@@ -305,4 +305,27 @@
Nėra albumų
Demo
Demos
+ Importuotas grojaraštis
+ Importuotas grojaraštis
+ Eksportuotas grojaraštis
+ Nepavyksta eksportuoti grojaraščio į šį failą
+ ReplayGain takelio koregavimas
+ ReplayGain albumo koregavimas
+ Autorius
+ Aukoti
+ Palaikytojai
+ Paaukok projektui, kad tavo vardas būtų pridėtas čia!
+ Nepavyksta importuoti grojaraščio iš šio failo.
+ Tuščias grojaraštis
+ Importuoti grojaraštį
+ Kelias
+ Importuoti
+ Eksportuoti
+ Eksportuoti grojaraštį
+ Kelio stilius
+ Absoliutinis
+ Santykinis
+ Naudoti Windows suderinamus kelius
+ Prisiminti pauzę
+ Išlieka grojimas ir (arba) pristabdomas, kai praleidžiama arba redaguojama eilė.
\ No newline at end of file
diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml
index c720d353b..9e23fb78b 100644
--- a/app/src/main/res/values-pa/strings.xml
+++ b/app/src/main/res/values-pa/strings.xml
@@ -313,4 +313,13 @@
ਪਲੇਲਿਸਟ ਨਿਰਯਾਤ ਕੀਤੀ ਗਈ
ਪਲੇਲਿਸਟ ਨਿਰਯਾਤ ਕਰੋ
ਪਲੇਲਿਸਟ ਨੂੰ ਇਸ ਫ਼ਾਈਲ ਵਿੱਚ ਨਿਰਯਾਤ ਕਰਨ ਵਿੱਚ ਅਸਮਰੱਥ
+ ਪਲੇਲਿਸਟ ਇੰਪੋਰਟ ਕਰੋ
+ ਰੀਪਲੇਅਗੇਨ ਟ੍ਰੈਕ ਐਡਜਸਟਮੈਂਟ
+ ਰੀਪਲੇਗੇਨ ਐਲਬਮ ਐਡਜਸਟਮੈਂਟ
+ ਲੇਖਕ
+ ਦਾਨ ਕਰੋ
+ ਸਮਰਥਕ
+ ਆਪਣਾ ਨਾਮ ਇੱਥੇ ਜੋੜਨ ਲਈ ਪ੍ਰੋਜੈਕਟ ਨੂੰ ਦਾਨ ਕਰੋ!
+ ਕਤਾਰ ਨੂੰ ਛੱਡਣ ਜਾਂ ਸੰਪਾਦਿਤ ਕਰਨ ਵੇਲੇ ਚਲਾਉਂਦੇ/ਰੋਕੇ ਰਹੋ
+ ਵਿਰਾਮ ਯਾਦ ਰੱਖੋ
\ No newline at end of file
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index a0f817cae..56ac63584 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -294,4 +294,30 @@
Renomear playlist
Aparece em
Apagar %s\? Esta ação não pode ser desfeita.
+ Demo
+ Playlist importada
+ Visualizar
+ Playlist importada
+ Playlist exportada
+ Incapaz de importar uma playlist deste arquivo
+ Incapaz de exportar a playlist para este arquivo
+ Demos
+ Autor
+ Doar
+ Apoiadores
+ Doe para o projeto para ter o seu nome adicionado aqui!
+ Lembrar pausa
+ Manter reproduzindo/pausado quando ao pular ou editar a fila
+ Playlist vazia
+ Importar playlist
+ Caminho
+ Seleção
+ Exportar
+ Exportar playlist
+ Direção
+ Estilo de caminho
+ Absoluto
+ Relativo
+ Importar
+ Usar caminhos compatíveis com Windows
\ No newline at end of file
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 1011bf65d..d6d307c19 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -37,7 +37,7 @@
Esquema de cores
Áudio
Personalizar
- Memorizar musica misturada
+ Memorizar música misturada
Nenhuma música encontrada
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index e55370394..604fc6980 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -189,4 +189,98 @@
Coperți de album
Adaugă către listă de redare
Direcție
+ Fără dată
+ Playlist gol
+ Atenție: Folosirea acestei setări poate rezulta în unele taguri interpretate greșit ca având mai multe valori.
+\nPoți rezolva asta punând un backslash (\\) înaintea caracterelor de separare nedorite.
+ Pornește mereu redarea când niște căști sunt conectate (s-ar putea să nu meargă pe toate dispozitivele)
+ Re-scanează muzica
+ Șterge memoria cache cu taguri și reîncarcă biblioteca de muzică de tot (mai încet, dar mai complet)
+ Restaurează starea redării
+ Cântece încărcate %d
+ Amestecă toate cântecele
+ Bleu
+ Nu se redă muzică
+ Ștergi %s? Nu te poți răzgândi după aceea.
+ Artiști încărcați: %d
+ Playlist importat
+ Dinamic
+
+ - %d artist
+ - %d artiști
+ - %d de artiști
+
+ Arată doar artiști care sunt creditați direct pe albun (Funcționează mai bine pe bibloteci cu taguri puse bine)
+ Dosarul ăsta nu e suportat
+ Crează un nou playlist
+ Copertă album
+ Bibliotecă
+ Slash (/)
+ Deschide lista de așteptare
+ Salvează starea redării acum
+ Uită starea redării
+ Imagine gen pentru %s
+ Imagine playlist pentru %s
+ Artist necunoscut
+ Nu s-a pututu restaura starea
+ Nu s-a putut salva starea
+ Vezi mai mult
+ Configurează caracterele care denotă mai multe valori de taguri
+ Foldere cu muzică
+ Foldere
+ Exclude
+ Muzica nu va fi încărcată din dosarele pe care le adaugi aici.
+ Fără cântece
+ Imagine artist pentru %s
+ Playlist importat
+ Autor
+ Donează
+ Playlist exportat
+ Donează proiectului ca să ai numele adăugat aici!
+ Muzica va fi încărcată doar din folderele pe care le adaugi aici.
+ Redă automat la conectarea căștilor
+ N-a fost găsită nicio aplicație care poate face asta
+ Se editează %s
+ Discul %d
+ Playlist %d
+ Genuri încărcate: %d
+ Redare
+ Pauză la repetare
+ Configurează comportamentul sunetului și redării
+ Salvează starea redării
+ Fără track
+ Configurează de unde se încarcă muzica
+ Reîncarcă muzica
+ Copertă album pentru %s
+ Gen necunoscut
+ Fără disc
+ Muzică
+ Mov închis
+ Ține minte pauza
+ Ține minte pauza atunci când dai skip printre cântece
+ Elimină dosarul
+ Imagine selecție
+ Indigo
+ %d Selectate
+ Albume încărcate %d
+ Importă playlist
+ Include
+ Reîncarcă biblioteca cu muzică, folosind taguri din memoria cache
+ Informații despre eroare
+ Copiat
+ Raportează
+ Redă cântecul fără să facă parte din nicio listă
+ Pune pauză atunci când un cântec se repetă
+ Mod
+ Încărcarea muzicii a eșuat
+ Auxio are nevoie de permisiune ca să-ți acceseze biblioteca de muzică
+ Mută acest cântec
+ Niciun dosar
+ Pornește sau oprește amestecarea
+ Oprește redarea
+ Elimină acest cântec
+ Exportă
+ Exportă playlistul
+ Importă
+ Nu se poate importa un playlist din acest fișier
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index e0a785ced..80fd5c6b3 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -328,4 +328,13 @@
Плейлист экспортирован
Экспортировать плейлист
Невозможно экспортировать плейлист в этот файл
+ Импортировать плейлист
+ Подстройка ReplayGain альбома
+ Подстройка ReplayGain песни
+ Автор
+ Пожертвовать
+ Сторонники
+ Сделайте пожертвование проекту, чтобы ваше имя было добавлено сюда!
+ Оставлять воспроизведение/паузу во время пропуска или редактирования очереди
+ Запоминать паузу
\ No newline at end of file
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 5485d4eb9..8043ac526 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -1,29 +1,29 @@
Försök igen
- Musik laddar
- Laddar musik
+ Läser in musik
+ Läser in musik
Alla spår
Album
- Albumet
- Remix-album
+ Album
+ Remixskiva
EP
EP
- Live-EP
+ Live EP
Remix-EP
Singlar
Remix-singel
Sammanställning
Remix-sammanställning
Ljudspår
- Ljudspår
+ Soundtrack
Blandband
- DJ-mixar
+ DJ-Mixar
Live
Remixar
Framträder på
- Konstnär
- Konstnärer
+ Artist
+ Artister
Genrer
Spellista
Spellistor
@@ -47,11 +47,11 @@
Kö
Spela nästa
Lägg till spellista
- Gå till konstnär
+ Gå till artist
Gå till album
Visa egenskaper
Dela
- Egenskaper för låt
+ Låtegenskaper
Format
Storlek
Samplingsfrekvens
@@ -60,34 +60,34 @@
Okej
Avbryt
Spara
- Tillstånd återstallde
+ Tillstånd återställt
Om
Källkod
Wiki
Licenser
Visa och kontrollera musikuppspelning
Laddar ditt musikbibliotek…
- Overvåker ditt musikbibliotek för ändringar…
+ Övervakar ändringar i ditt musikbibliotek…
Tillagd i kö
- Spellista skapade
+ Spellista skapad
Tillagd till spellista
Sök i ditt musikbibliotek…
Inställningar
Utseende
- Ändra tema och färger på appen
+ Ändra färger och tema
Automatisk
Ljust
Svart tema
- Rundläge
+ Runt läge
Bevilja
En enkel, rationell musikspelare för Android.
- Övervakar musikbiblioteket
+ Övervakar musikbibliotek
Spår
- Live-album
+ Liveskiva
Ta bort
Live-sammanställning
Singel
- Live-singel
+ Live singel
Sammanställningar
Blandband
DJ-mix
@@ -99,33 +99,33 @@
Sortera
Lägg till kö
Lägg till
- Tillstånd tog bort
- Bithastighet
+ Tillstånd togs bort
+ Överföringskapacitet
Återställ
Tillstånd sparat
Version
- Statistik över beroende
- Byt namn av spellista
+ Bibliotekstatistik
+ Bytt namn på spellista
Spellista tog bort
- Utvecklad av Alexander Capeheart
+ Alexander Capeheart
Tema
Mörkt
Färgschema
- Använda rent svart för det mörka temat
+ Använd ren svart till det mörka temat
Aktivera rundade hörn på ytterligare element i användargränssnittet (kräver att albumomslag är rundade)
Anpassa
- Ändra synlighet och ordningsföljd av bibliotekflikar
+ Ändra synlighet och ordningsföljd på bibliotekflikar
Anpassad åtgärd för uppspelningsfält
Anpassad aviseringsåtgärd
Hoppa till nästa
Upprepningsmodus
Beteende
- När spelar från artikeluppgifter
+ Vid uppspelning baserat på objektuppgifter
Spela från genre
- Komma ihåg blandningsstatus
+ Kom ihåg blanda-status
Behåll blandning på när en ny låt spelas
- Kontent
- Kontrollera hur musik och bilar laddas
+ Innehåll
+ Kontrollera hur musik och bilder laddas in
Musik
Automatisk omladdning
Inkludera bara musik
@@ -134,33 +134,33 @@
Plus (+)
Intelligent sortering
Sorterar namn som börjar med siffror eller ord som \"the\" korrekt (fungerar bäst med engelskspråkig music)
- Dölj medarbetare
+ Dölj medverkande
Skärm
Bibliotekflikar
- När spelar från biblioteket
- Spela från visad artikel
+ Vid uppspelning från biblioteket
+ Spela från visat objekt
Spela från alla låtar
- Spela från konstnär
+ Spela från artist
Spela från album
Semikolon (;)
Ladda om musikbiblioteket när det ändras (kräver permanent meddelande)
Komma (,)
Snedstreck (/)
Konfigurera tecken som separerar flera värden i taggar
- Advarsel: Denna inställning kan leda till att vissa taggar separeras felaktigt. För att åtgärda detta, prefixa oönskade separatortecken med ett backslash (\\).
+ Varning: Denna inställning kan leda till att vissa taggar separeras felaktigt. För att åtgärda detta, prefixa oönskade separatortecken med ett backslash (\\).
Anpassa UI-kontroller och beteende
Av
- Hörlurar-autouppspelning
+ Autouppspelning med hörlurar
Pausa när en låt upprepas
Musik laddas inte från mapparna som ni lägger till.
- Öppna kö
+ Öppna kön
Dynamisk
- %d konstnärer som laddats
+ Inlästa artister: %d
- - %d konstnär
- - %d konstnärer
+ - %d artist
+ - %d artister
- Bildar
+ Bilder
Ljud
Konfigurera ljud- och uppspelningsbeteende
Spola tillbaka innan spår hoppar tillbaka
@@ -168,23 +168,23 @@
Rensa det tidigare sparade uppspelningsläget om det finns
Återställ uppspelningsläge
-%.1f dB
- Radera %s\? Detta kan inte ångras.
+ Ta bort %s? Detta kan inte ångras.
Endast visa artister som är direkt krediterade på ett album (funkar bäst på välmärkta bibliotek)
Albumomslag
Snabbt
Bibliotek
Inkludera
Uppdatera musik
- Ladda musikbiblioteket om och använd cachad taggar när det är möjligt
- Uthållighet
+ Läs in musik på nytt, vid möjlighet med användning av cachade taggar
+ Persistens
Rensa uppspelningsläge
Återställ det tidigare lagrade uppspelningsläget om det finns
Misslyckades att spara uppspelningsläget
- Blanda alla spår
+ Blanda alla låtar
Rensa sökfrågan
- Radera mappen
+ Ta bort mapp
Genrebild för %s
- Spellistabild för %s
+ Bild spellista för %s
MPEG-1-ljud
MPEG-4-ljud
OGG-ljud
@@ -204,84 +204,84 @@
Orange
Brun
Alltid börja uppspelning när hörlurar kopplas till (kanske inte fungerar på alla enheter)
- Pausa vid upprepa
- ReplayGain förförstärkare
+ Pausa vid upprepning
+ ReplayGain försteg
Justering utan taggar
Musikmappar
Varning: Om man ändrar förförstärkaren till ett högt positivt värde kan det leda till toppning på vissa ljudspår.
- Hantera var musik bör laddas in från
+ Hantera vart musik läses in ifrån
Mappar
Modus
Utesluta
Musik laddas endast från mapparna som ni lägger till.
- Spara det aktuella uppspelningsläget
- Skanna musik om
+ Spara aktuellt uppspelningsläge
+ Skanna om musik
Rensa tagbiblioteket och ladda komplett om musikbiblioteket (långsammare, men mer komplett)
Ingen musik på gång
- Laddning av musik misslyckades
- Auxio behöver tillstånd för att läsa ditt musikbibliotek
- Ingen app på gång som kan hantera denna uppgift
+ Läsa in musik misslyckades
+ Auxio måste ges behörighet för att läsa in ditt musikbibliotek
+ Ingen lämplig app kunde hittas
Denna mapp stöds inte
Misslyckades att återställa uppspelningsläget
Spår %d
Spela eller pausa
Flytta detta spår
- Okänd konstnär
+ Okänd artist
Okänd genre
- Avancerad audio-koding (AAC)
- %d utvalda
+ Avancerad audio-kodning (AAC)
+ %d valda
Spellista %d
+%.1f dB
- - %d spår
- - %d spår
+ - %d låt
+ - %d låtar
- %d album
- %d album
- %d spår som laddats
- Total längd: %s
+ Inlästa låtar: %d
+ Total spårlängd: %s
Kopierade
Urval
Felinformation
Rapportera
- Ingen datum
- Ingen disk
+ Inget datum
+ Ingen skiva
Inget spår
- Inga spår
- Lilla
+ Inga låtar
+ Lila
%d kbps
%d Hz
- %d album som laddats
- %d genrer som laddats
- Spela upp låten själv
+ Inlästa album: %d
+ Inlästa genrer: %d
+ Spela endast vald låt
Hög kvalitet
Tvinga fyrkantiga skivomslag
- Beskär alla albumomslag till en 1:1 sidförhållande
+ Beskär alla albumomslag till ett 1:1 sidförhållande
Spola tillbaka innan att hoppa till föregående låt
Justering med taggar
Inga mappar
Misslyckades att rensa uppspelningsläget
Skapa en ny spellista
Stoppa uppspelning
- Radera detta spår
+ Ta bort låt
Auxio-ikon
- Flytta denna flik
+ Flytta flik
Albumomslag
- Urvalbild
+ Urvalsbild
Mörklila
Indigo
- Disk %d
+ Skiva %d
Spara uppspelningsläge
- Hoppa till nästa spår
- Hoppa till sista spår
+ Hoppa till nästa låt
+ Hoppa till sista låt
Ändra upprepningsläge
- Slå på eller av blandningen
+ Blanda På/Av
Albumomslag för %s
- Konstnärbild för %s
+ Artistbild för %s
Ingen musik spelas
- Fritt tapsfritt ljudkodek (FLAC)
+ Fri förlustfri ljudkodek (FLAC)
Rosa
Laddar ditt musikbibliotek… (%1$d/%2$d)
Ampersand (&)
@@ -291,4 +291,35 @@
Föredra album om ett album spelar
Förförstarkning användas för befintliga justeringar vid uppspelning
Röd
+ Visa mera
+ Låt
+ Importerad spellista
+ Kunde inte importera spellista från denna fil
+ Lista efter
+ Riktning
+ Demo
+ Demos
+ Upphovsperson
+ Spellistan har importerats
+ Spellistan har exporterats
+ Skänk projektet ett bidrag så lägger vi till ditt namn här!
+ Kom ihåg pausat läge
+ Fortsätt uppspelning/pausat läge vid spårbyte och listredigering
+ Kunde inte importera spellistan till denna fil
+ Tom spellista
+ Importera spellista
+ Vy
+ Sökväg
+ ReplayGain Spårbaserad Volymjustering
+ ReplayGain Albumbaserad Volymjustering
+ Donera
+ Bidragsgivare
+ Importera
+ Exportera
+ Exportera spellista
+ Sökvägsform
+ Absolut
+ Relativ
+ Använd Windowskompatibla sökvägar
+ Inga album
\ No newline at end of file
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index c14a92f56..1429775f1 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -325,4 +325,13 @@
Список відтворення експортовано
Експортувати список відтворення
Неможливо експортувати список відтворення в цей файл
+ Імпортувати список відтворення
+ Пожертвувати
+ Прибічники
+ Підлаштування ReplayGain пісні
+ Підлаштування ReplayGain альбому
+ Пожертвуйте на проєкт, щоб ваше ім\'я було додано сюди!
+ Автор
+ Залишати відтворення/паузу під час пропуску або редагування черги
+ Запам\'ятовувати паузу
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 9991f91d0..825fd6716 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -319,4 +319,13 @@
无法将播放列表导出到此文件
导入了播放列表
导出了播放列表
+ 导入播放列表
+ 回放增益曲目调整
+ 支持者
+ 回放增益专辑调整
+ 作者
+ 捐赠
+ 要在此添加您的名字请给项目捐款!
+ 跳过或编辑队列时保留播放/暂停状态
+ 记住暂停状态
\ No newline at end of file
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index 5c745d67d..42c64c068 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -15,4 +15,7 @@
Vorbis
Opus
Microsoft WAVE
+
+
+ yrliet
\ No newline at end of file
diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml
index eb07550e3..f5c45132a 100644
--- a/app/src/main/res/values/settings.xml
+++ b/app/src/main/res/values/settings.xml
@@ -32,9 +32,7 @@
KEY_KEEP_SHUFFLE
KEY_PREV_REWIND
KEY_LOOP_PAUSE
- auxio_save_state
- auxio_wipe_state
- auxio_restore_state
+ auxio_remember_pause
auxio_home_tabs
auxio_hide_collaborators
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 241b71838..5a465a49c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -288,6 +288,8 @@
Rewind before skipping to the previous song
Pause on repeat
Pause when a song repeats
+ Remember pause
+ Remain playing/paused when skipping or editing queue
ReplayGain
ReplayGain strategy
Prefer track
diff --git a/app/src/main/res/xml-v31/widget_info.xml b/app/src/main/res/xml-v31/widget_info.xml
index 11fcafb44..1d12aaff8 100644
--- a/app/src/main/res/xml-v31/widget_info.xml
+++ b/app/src/main/res/xml-v31/widget_info.xml
@@ -4,10 +4,10 @@
android:initialLayout="@layout/widget_default"
android:minWidth="@dimen/widget_def_width"
android:minHeight="@dimen/widget_def_height"
+ android:minResizeHeight="0dp"
android:minResizeWidth="@dimen/widget_def_width"
- android:minResizeHeight="@dimen/widget_def_height"
android:previewImage="@drawable/ui_widget_preview"
- android:previewLayout="@layout/widget_small"
+ android:previewLayout="@layout/widget_docked_thin"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="2"
diff --git a/app/src/main/res/xml/preferences_audio.xml b/app/src/main/res/xml/preferences_audio.xml
index ba4402385..9768fe3dd 100644
--- a/app/src/main/res/xml/preferences_audio.xml
+++ b/app/src/main/res/xml/preferences_audio.xml
@@ -21,6 +21,12 @@
app:summary="@string/set_repeat_pause_desc"
app:title="@string/set_repeat_pause" />
+
+
diff --git a/app/src/main/res/xml/preferences_root.xml b/app/src/main/res/xml/preferences_root.xml
index 980ce2c9c..64b8ebafc 100644
--- a/app/src/main/res/xml/preferences_root.xml
+++ b/app/src/main/res/xml/preferences_root.xml
@@ -45,22 +45,4 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/widget_info.xml b/app/src/main/res/xml/widget_info.xml
index 84ac71fc7..35608e938 100644
--- a/app/src/main/res/xml/widget_info.xml
+++ b/app/src/main/res/xml/widget_info.xml
@@ -3,8 +3,8 @@
android:initialLayout="@layout/widget_default"
android:minWidth="@dimen/widget_def_width"
android:minHeight="@dimen/widget_def_height"
+ android:minResizeHeight="0dp"
android:minResizeWidth="@dimen/widget_def_width"
- android:minResizeHeight="@dimen/widget_def_height"
android:previewImage="@drawable/ui_widget_preview"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0"
diff --git a/fastlane/metadata/android/en-US/changelogs/41.txt b/fastlane/metadata/android/en-US/changelogs/41.txt
new file mode 100644
index 000000000..96c4f675b
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/41.txt
@@ -0,0 +1,2 @@
+Auxio 3.4.0 adds gapless playback and new widget designs, alongside a variety of fixes.
+For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.4.0
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 3f4927359..fab8fccd9 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -12,6 +12,7 @@ precise/original dates, sort tags, and more
- SD Card-aware folder management
- Reliable playlisting functionality
- Playback state persistence
+- Automatic gapless playback
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
- External equalizer support (ex. Wavelet)
- Edge-to-edge
diff --git a/fastlane/metadata/android/hr/full_description.txt b/fastlane/metadata/android/hr/full_description.txt
index f5f8d2d17..6647c70f4 100644
--- a/fastlane/metadata/android/hr/full_description.txt
+++ b/fastlane/metadata/android/hr/full_description.txt
@@ -20,4 +20,4 @@ precizni/izvorni datumi, sortiranje oznaka i više
- Automatska reprodukcija slušalica
- Elegantni widgeti koji se automatski prilagođavaju njihovoj veličini
- Potpuno privatno i izvan mreže
-- Nema zaobljenih naslovnica albuma (Osim ako ih ne želite. Onda možete.)
+- Bez zaobljenih naslovnica albuma (zadano)