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.

- - Latest Version + + Latest Version Releases @@ -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 @@ 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)