playback: use room for persistence
Finally give up and use Room to persist the playback state. This should make dependency injection much easier, and the implementation isn't exactly the *worst*, as I was already using "raw" data structures for the old queue database.
This commit is contained in:
parent
fb004a9e8b
commit
f27215a4be
15 changed files with 536 additions and 507 deletions
|
@ -4,6 +4,7 @@ plugins {
|
||||||
id "androidx.navigation.safeargs.kotlin"
|
id "androidx.navigation.safeargs.kotlin"
|
||||||
id "com.diffplug.spotless"
|
id "com.diffplug.spotless"
|
||||||
id "kotlin-parcelize"
|
id "kotlin-parcelize"
|
||||||
|
id 'kotlin-kapt'
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
@ -93,6 +94,12 @@ dependencies {
|
||||||
// Preferences
|
// Preferences
|
||||||
implementation "androidx.preference:preference-ktx:1.2.0"
|
implementation "androidx.preference:preference-ktx:1.2.0"
|
||||||
|
|
||||||
|
// Database
|
||||||
|
def room_version = '2.4.3'
|
||||||
|
implementation "androidx.room:room-runtime:$room_version"
|
||||||
|
kapt "androidx.room:room-compiler:$room_version"
|
||||||
|
implementation "androidx.room:room-ktx:$room_version"
|
||||||
|
|
||||||
// --- THIRD PARTY ---
|
// --- THIRD PARTY ---
|
||||||
|
|
||||||
// Exoplayer
|
// Exoplayer
|
||||||
|
|
|
@ -48,6 +48,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
*
|
*
|
||||||
* TODO: Unit testing
|
* TODO: Unit testing
|
||||||
*
|
*
|
||||||
|
* TODO: Migrate to value classes FOR ALL ENUMS
|
||||||
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
|
@ -66,153 +66,150 @@ interface PlaybackSettings : Settings<PlaybackSettings.Listener> {
|
||||||
fun onNotificationActionChanged() {}
|
fun onNotificationActionChanged() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Real(context: Context) : Settings.Real<Listener>(context), PlaybackSettings {
|
|
||||||
override val inListPlaybackMode: MusicMode
|
|
||||||
get() =
|
|
||||||
MusicMode.fromIntCode(
|
|
||||||
sharedPreferences.getInt(
|
|
||||||
getString(R.string.set_key_in_list_playback_mode), Int.MIN_VALUE))
|
|
||||||
?: MusicMode.SONGS
|
|
||||||
|
|
||||||
override val inParentPlaybackMode: MusicMode?
|
|
||||||
get() =
|
|
||||||
MusicMode.fromIntCode(
|
|
||||||
sharedPreferences.getInt(
|
|
||||||
getString(R.string.set_key_in_parent_playback_mode), Int.MIN_VALUE))
|
|
||||||
|
|
||||||
override val barAction: ActionMode
|
|
||||||
get() =
|
|
||||||
ActionMode.fromIntCode(
|
|
||||||
sharedPreferences.getInt(getString(R.string.set_key_bar_action), Int.MIN_VALUE))
|
|
||||||
?: ActionMode.NEXT
|
|
||||||
|
|
||||||
override val notificationAction: ActionMode
|
|
||||||
get() =
|
|
||||||
ActionMode.fromIntCode(
|
|
||||||
sharedPreferences.getInt(
|
|
||||||
getString(R.string.set_key_notif_action), Int.MIN_VALUE))
|
|
||||||
?: ActionMode.REPEAT
|
|
||||||
|
|
||||||
override val headsetAutoplay: Boolean
|
|
||||||
get() =
|
|
||||||
sharedPreferences.getBoolean(getString(R.string.set_key_headset_autoplay), false)
|
|
||||||
|
|
||||||
override val replayGainMode: ReplayGainMode
|
|
||||||
get() =
|
|
||||||
ReplayGainMode.fromIntCode(
|
|
||||||
sharedPreferences.getInt(
|
|
||||||
getString(R.string.set_key_replay_gain), Int.MIN_VALUE))
|
|
||||||
?: ReplayGainMode.DYNAMIC
|
|
||||||
|
|
||||||
override var replayGainPreAmp: ReplayGainPreAmp
|
|
||||||
get() =
|
|
||||||
ReplayGainPreAmp(
|
|
||||||
sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_with), 0f),
|
|
||||||
sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_without), 0f))
|
|
||||||
set(value) {
|
|
||||||
sharedPreferences.edit {
|
|
||||||
putFloat(getString(R.string.set_key_pre_amp_with), value.with)
|
|
||||||
putFloat(getString(R.string.set_key_pre_amp_without), value.without)
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override val keepShuffle: Boolean
|
|
||||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_keep_shuffle), true)
|
|
||||||
|
|
||||||
override val rewindWithPrev: Boolean
|
|
||||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_rewind_prev), true)
|
|
||||||
|
|
||||||
override val pauseOnRepeat: Boolean
|
|
||||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false)
|
|
||||||
|
|
||||||
override fun migrate() {
|
|
||||||
// "Use alternate notification action" was converted to an ActionMode setting in 3.0.0.
|
|
||||||
if (sharedPreferences.contains(OLD_KEY_ALT_NOTIF_ACTION)) {
|
|
||||||
logD("Migrating $OLD_KEY_ALT_NOTIF_ACTION")
|
|
||||||
|
|
||||||
val mode =
|
|
||||||
if (sharedPreferences.getBoolean(OLD_KEY_ALT_NOTIF_ACTION, false)) {
|
|
||||||
ActionMode.SHUFFLE
|
|
||||||
} else {
|
|
||||||
ActionMode.REPEAT
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedPreferences.edit {
|
|
||||||
putInt(getString(R.string.set_key_notif_action), mode.intCode)
|
|
||||||
remove(OLD_KEY_ALT_NOTIF_ACTION)
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlaybackMode was converted to MusicMode in 3.0.0
|
|
||||||
|
|
||||||
fun Int.migratePlaybackMode() =
|
|
||||||
when (this) {
|
|
||||||
// Convert PlaybackMode into MusicMode
|
|
||||||
IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS
|
|
||||||
IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS
|
|
||||||
IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS
|
|
||||||
IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharedPreferences.contains(OLD_KEY_LIB_PLAYBACK_MODE)) {
|
|
||||||
logD("Migrating $OLD_KEY_LIB_PLAYBACK_MODE")
|
|
||||||
|
|
||||||
val mode =
|
|
||||||
sharedPreferences
|
|
||||||
.getInt(OLD_KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS)
|
|
||||||
.migratePlaybackMode()
|
|
||||||
?: MusicMode.SONGS
|
|
||||||
|
|
||||||
sharedPreferences.edit {
|
|
||||||
putInt(getString(R.string.set_key_in_list_playback_mode), mode.intCode)
|
|
||||||
remove(OLD_KEY_LIB_PLAYBACK_MODE)
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharedPreferences.contains(OLD_KEY_DETAIL_PLAYBACK_MODE)) {
|
|
||||||
logD("Migrating $OLD_KEY_DETAIL_PLAYBACK_MODE")
|
|
||||||
|
|
||||||
val mode =
|
|
||||||
sharedPreferences
|
|
||||||
.getInt(OLD_KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE)
|
|
||||||
.migratePlaybackMode()
|
|
||||||
|
|
||||||
sharedPreferences.edit {
|
|
||||||
putInt(
|
|
||||||
getString(R.string.set_key_in_parent_playback_mode),
|
|
||||||
mode?.intCode ?: Int.MIN_VALUE)
|
|
||||||
remove(OLD_KEY_DETAIL_PLAYBACK_MODE)
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSettingChanged(key: String, listener: Listener) {
|
|
||||||
when (key) {
|
|
||||||
getString(R.string.set_key_replay_gain),
|
|
||||||
getString(R.string.set_key_pre_amp_with),
|
|
||||||
getString(R.string.set_key_pre_amp_without) ->
|
|
||||||
listener.onReplayGainSettingsChanged()
|
|
||||||
getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION"
|
|
||||||
const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2"
|
|
||||||
const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Get a framework-backed implementation.
|
* Get a framework-backed implementation.
|
||||||
* @param context [Context] required.
|
* @param context [Context] required.
|
||||||
*/
|
*/
|
||||||
fun from(context: Context): PlaybackSettings = Real(context)
|
fun from(context: Context): PlaybackSettings = RealPlaybackSettings(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RealPlaybackSettings(context: Context) :
|
||||||
|
Settings.Real<PlaybackSettings.Listener>(context), PlaybackSettings {
|
||||||
|
override val inListPlaybackMode: MusicMode
|
||||||
|
get() =
|
||||||
|
MusicMode.fromIntCode(
|
||||||
|
sharedPreferences.getInt(
|
||||||
|
getString(R.string.set_key_in_list_playback_mode), Int.MIN_VALUE))
|
||||||
|
?: MusicMode.SONGS
|
||||||
|
|
||||||
|
override val inParentPlaybackMode: MusicMode?
|
||||||
|
get() =
|
||||||
|
MusicMode.fromIntCode(
|
||||||
|
sharedPreferences.getInt(
|
||||||
|
getString(R.string.set_key_in_parent_playback_mode), Int.MIN_VALUE))
|
||||||
|
|
||||||
|
override val barAction: ActionMode
|
||||||
|
get() =
|
||||||
|
ActionMode.fromIntCode(
|
||||||
|
sharedPreferences.getInt(getString(R.string.set_key_bar_action), Int.MIN_VALUE))
|
||||||
|
?: ActionMode.NEXT
|
||||||
|
|
||||||
|
override val notificationAction: ActionMode
|
||||||
|
get() =
|
||||||
|
ActionMode.fromIntCode(
|
||||||
|
sharedPreferences.getInt(getString(R.string.set_key_notif_action), Int.MIN_VALUE))
|
||||||
|
?: ActionMode.REPEAT
|
||||||
|
|
||||||
|
override val headsetAutoplay: Boolean
|
||||||
|
get() = sharedPreferences.getBoolean(getString(R.string.set_key_headset_autoplay), false)
|
||||||
|
|
||||||
|
override val replayGainMode: ReplayGainMode
|
||||||
|
get() =
|
||||||
|
ReplayGainMode.fromIntCode(
|
||||||
|
sharedPreferences.getInt(getString(R.string.set_key_replay_gain), Int.MIN_VALUE))
|
||||||
|
?: ReplayGainMode.DYNAMIC
|
||||||
|
|
||||||
|
override var replayGainPreAmp: ReplayGainPreAmp
|
||||||
|
get() =
|
||||||
|
ReplayGainPreAmp(
|
||||||
|
sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_with), 0f),
|
||||||
|
sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_without), 0f))
|
||||||
|
set(value) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putFloat(getString(R.string.set_key_pre_amp_with), value.with)
|
||||||
|
putFloat(getString(R.string.set_key_pre_amp_without), value.without)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val keepShuffle: Boolean
|
||||||
|
get() = sharedPreferences.getBoolean(getString(R.string.set_key_keep_shuffle), true)
|
||||||
|
|
||||||
|
override val rewindWithPrev: Boolean
|
||||||
|
get() = sharedPreferences.getBoolean(getString(R.string.set_key_rewind_prev), true)
|
||||||
|
|
||||||
|
override val pauseOnRepeat: Boolean
|
||||||
|
get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false)
|
||||||
|
|
||||||
|
override fun migrate() {
|
||||||
|
// "Use alternate notification action" was converted to an ActionMode setting in 3.0.0.
|
||||||
|
if (sharedPreferences.contains(OLD_KEY_ALT_NOTIF_ACTION)) {
|
||||||
|
logD("Migrating $OLD_KEY_ALT_NOTIF_ACTION")
|
||||||
|
|
||||||
|
val mode =
|
||||||
|
if (sharedPreferences.getBoolean(OLD_KEY_ALT_NOTIF_ACTION, false)) {
|
||||||
|
ActionMode.SHUFFLE
|
||||||
|
} else {
|
||||||
|
ActionMode.REPEAT
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putInt(getString(R.string.set_key_notif_action), mode.intCode)
|
||||||
|
remove(OLD_KEY_ALT_NOTIF_ACTION)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaybackMode was converted to MusicMode in 3.0.0
|
||||||
|
|
||||||
|
fun Int.migratePlaybackMode() =
|
||||||
|
when (this) {
|
||||||
|
// Convert PlaybackMode into MusicMode
|
||||||
|
IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS
|
||||||
|
IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS
|
||||||
|
IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS
|
||||||
|
IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharedPreferences.contains(OLD_KEY_LIB_PLAYBACK_MODE)) {
|
||||||
|
logD("Migrating $OLD_KEY_LIB_PLAYBACK_MODE")
|
||||||
|
|
||||||
|
val mode =
|
||||||
|
sharedPreferences
|
||||||
|
.getInt(OLD_KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS)
|
||||||
|
.migratePlaybackMode()
|
||||||
|
?: MusicMode.SONGS
|
||||||
|
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putInt(getString(R.string.set_key_in_list_playback_mode), mode.intCode)
|
||||||
|
remove(OLD_KEY_LIB_PLAYBACK_MODE)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharedPreferences.contains(OLD_KEY_DETAIL_PLAYBACK_MODE)) {
|
||||||
|
logD("Migrating $OLD_KEY_DETAIL_PLAYBACK_MODE")
|
||||||
|
|
||||||
|
val mode =
|
||||||
|
sharedPreferences
|
||||||
|
.getInt(OLD_KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE)
|
||||||
|
.migratePlaybackMode()
|
||||||
|
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putInt(
|
||||||
|
getString(R.string.set_key_in_parent_playback_mode),
|
||||||
|
mode?.intCode ?: Int.MIN_VALUE)
|
||||||
|
remove(OLD_KEY_DETAIL_PLAYBACK_MODE)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSettingChanged(key: String, listener: PlaybackSettings.Listener) {
|
||||||
|
when (key) {
|
||||||
|
getString(R.string.set_key_replay_gain),
|
||||||
|
getString(R.string.set_key_pre_amp_with),
|
||||||
|
getString(R.string.set_key_pre_amp_without) -> listener.onReplayGainSettingsChanged()
|
||||||
|
getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION"
|
||||||
|
const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2"
|
||||||
|
const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,8 +26,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
|
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||||
|
import org.oxycblt.auxio.playback.queue.Queue
|
||||||
import org.oxycblt.auxio.playback.state.*
|
import org.oxycblt.auxio.playback.state.*
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An [AndroidViewModel] that provides a safe UI frontend for the current playback state.
|
* An [AndroidViewModel] that provides a safe UI frontend for the current playback state.
|
||||||
|
@ -38,6 +39,7 @@ class PlaybackViewModel(application: Application) :
|
||||||
private val musicSettings = MusicSettings.from(application)
|
private val musicSettings = MusicSettings.from(application)
|
||||||
private val playbackSettings = PlaybackSettings.from(application)
|
private val playbackSettings = PlaybackSettings.from(application)
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
private val playbackManager = PlaybackStateManager.getInstance()
|
||||||
|
private val persistenceRepository = PersistenceRepository.from(application)
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private var lastPositionJob: Job? = null
|
private var lastPositionJob: Job? = null
|
||||||
|
|
||||||
|
@ -428,7 +430,7 @@ class PlaybackViewModel(application: Application) :
|
||||||
*/
|
*/
|
||||||
fun savePlaybackState(onDone: (Boolean) -> Unit) {
|
fun savePlaybackState(onDone: (Boolean) -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val saved = playbackManager.saveState(PlaybackStateDatabase.getInstance(context))
|
val saved = playbackManager.saveState(persistenceRepository)
|
||||||
onDone(saved)
|
onDone(saved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -439,7 +441,7 @@ class PlaybackViewModel(application: Application) :
|
||||||
*/
|
*/
|
||||||
fun wipePlaybackState(onDone: (Boolean) -> Unit) {
|
fun wipePlaybackState(onDone: (Boolean) -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val wiped = playbackManager.wipeState(PlaybackStateDatabase.getInstance(context))
|
val wiped = playbackManager.wipeState(persistenceRepository)
|
||||||
onDone(wiped)
|
onDone(wiped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -451,8 +453,7 @@ class PlaybackViewModel(application: Application) :
|
||||||
*/
|
*/
|
||||||
fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) {
|
fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val restored =
|
val restored = playbackManager.restoreState(persistenceRepository, true)
|
||||||
playbackManager.restoreState(PlaybackStateDatabase.getInstance(context), true)
|
|
||||||
onDone(restored)
|
onDone(restored)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.playback.persist
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines conversions used in the persistence table.
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
object PersistenceConverters {
|
||||||
|
/** @see [Music.UID.toString] */
|
||||||
|
@TypeConverter fun fromMusicUID(uid: Music.UID?) = uid?.toString()
|
||||||
|
|
||||||
|
/** @see [Music.UID.fromString]*/
|
||||||
|
@TypeConverter fun toMusicUid(string: String?) = string?.let(Music.UID::fromString)
|
||||||
|
}
|
|
@ -0,0 +1,189 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.playback.persist
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides raw access to the database storing the persisted playback state.
|
||||||
|
* @author Alexander Capehart
|
||||||
|
*/
|
||||||
|
@Database(
|
||||||
|
entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false)
|
||||||
|
@TypeConverters(PersistenceConverters::class)
|
||||||
|
abstract class PersistenceDatabase : RoomDatabase() {
|
||||||
|
/**
|
||||||
|
* Get the current [PlaybackStateDao].
|
||||||
|
* @return A [PlaybackStateDao] providing control of the database's playback state tables.
|
||||||
|
*/
|
||||||
|
abstract fun playbackStateDao(): PlaybackStateDao
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current [QueueDao].
|
||||||
|
* @return A [QueueDao] providing control of the database's queue tables.
|
||||||
|
*/
|
||||||
|
abstract fun queueDao(): QueueDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile private var INSTANCE: PersistenceDatabase? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get/create the shared instance of this database.
|
||||||
|
* @param context [Context] required.
|
||||||
|
*/
|
||||||
|
fun getInstance(context: Context): PersistenceDatabase {
|
||||||
|
val instance = INSTANCE
|
||||||
|
if (instance != null) {
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(this) {
|
||||||
|
val newInstance =
|
||||||
|
Room.databaseBuilder(
|
||||||
|
context,
|
||||||
|
PersistenceDatabase::class.java,
|
||||||
|
"auxio_playback_persistence.db")
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.fallbackToDestructiveMigrationOnDowngrade()
|
||||||
|
.build()
|
||||||
|
INSTANCE = newInstance
|
||||||
|
return newInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides control of the persisted playback state table.
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
@Dao
|
||||||
|
interface PlaybackStateDao {
|
||||||
|
/**
|
||||||
|
* Get the previously persisted [PlaybackState].
|
||||||
|
* @return The previously persisted [PlaybackState], or null if one was not present.
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM ${PlaybackState.TABLE_NAME} WHERE id = 0")
|
||||||
|
suspend fun getState(): PlaybackState?
|
||||||
|
|
||||||
|
/** Delete any previously persisted [PlaybackState]s. */
|
||||||
|
@Query("DELETE FROM ${PlaybackState.TABLE_NAME}") suspend fun nukeState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new [PlaybackState] into the database.
|
||||||
|
* @param state The [PlaybackState] to insert.
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertState(state: PlaybackState)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides control of the persisted queue state tables.
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
@Dao
|
||||||
|
interface QueueDao {
|
||||||
|
/**
|
||||||
|
* Get the previously persisted queue heap.
|
||||||
|
* @return A list of persisted [QueueHeapItem]s wrapping each heap item.
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM ${QueueHeapItem.TABLE_NAME}") suspend fun getHeap(): List<QueueHeapItem>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the previously persisted queue mapping.
|
||||||
|
* @return A list of persisted [QueueMappingItem]s wrapping each heap item.
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM ${QueueMappingItem.TABLE_NAME}")
|
||||||
|
suspend fun getMapping(): List<QueueMappingItem>
|
||||||
|
|
||||||
|
/** Delete any previously persisted queue heap entries. */
|
||||||
|
@Query("DELETE FROM ${QueueHeapItem.TABLE_NAME}") suspend fun nukeHeap()
|
||||||
|
|
||||||
|
/** Delete any previously persisted queue mapping entries. */
|
||||||
|
@Query("DELETE FROM ${QueueMappingItem.TABLE_NAME}") suspend fun nukeMapping()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert new heap entries into the database.
|
||||||
|
* @param heap The list of wrapped [QueueHeapItem]s to insert.
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertHeap(heap: List<QueueHeapItem>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert new mapping entries into the database.
|
||||||
|
* @param mapping The list of wrapped [QueueMappingItem] to insert.
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||||
|
suspend fun insertMapping(mapping: List<QueueMappingItem>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A raw representation of the persisted playback state.
|
||||||
|
* @author Alexander Capehart
|
||||||
|
*/
|
||||||
|
@Entity(tableName = PlaybackState.TABLE_NAME)
|
||||||
|
data class PlaybackState(
|
||||||
|
@PrimaryKey val id: Int,
|
||||||
|
val index: Int,
|
||||||
|
val positionMs: Long,
|
||||||
|
val repeatMode: RepeatMode,
|
||||||
|
val songUid: Music.UID,
|
||||||
|
val parentUid: Music.UID?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val TABLE_NAME = "playback_state"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A raw representation of the an individual item in the persisted queue's heap.
|
||||||
|
* @author Alexander Capehart
|
||||||
|
*/
|
||||||
|
@Entity(tableName = QueueHeapItem.TABLE_NAME)
|
||||||
|
data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) {
|
||||||
|
companion object {
|
||||||
|
const val TABLE_NAME = "queue_heap"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A raw representation of the heap indices at a particular position in the persisted queue.
|
||||||
|
* @author Alexander Capehart
|
||||||
|
*/
|
||||||
|
@Entity(tableName = QueueMappingItem.TABLE_NAME)
|
||||||
|
data class QueueMappingItem(
|
||||||
|
@PrimaryKey val id: Int,
|
||||||
|
val orderedIndex: Int,
|
||||||
|
val shuffledIndex: Int
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val TABLE_NAME = "queue_mapping"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,134 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.playback.persist
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.library.Library
|
||||||
|
import org.oxycblt.auxio.playback.queue.Queue
|
||||||
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the persisted playback state in a structured manner.
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
interface PersistenceRepository {
|
||||||
|
/**
|
||||||
|
* Read the previously persisted [SavedState].
|
||||||
|
* @param library The [Library] required to de-serialize the [SavedState].
|
||||||
|
*/
|
||||||
|
suspend fun readState(library: Library): SavedState?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a new [SavedState].
|
||||||
|
* @param state The [SavedState] to persist.
|
||||||
|
*/
|
||||||
|
suspend fun saveState(state: SavedState?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Get a framework-backed implementation.
|
||||||
|
* @param context [Context] required.
|
||||||
|
*/
|
||||||
|
fun from(context: Context): PersistenceRepository = RealPersistenceRepository(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RealPersistenceRepository(private val context: Context) : PersistenceRepository {
|
||||||
|
private val database: PersistenceDatabase by lazy { PersistenceDatabase.getInstance(context) }
|
||||||
|
private val playbackStateDao: PlaybackStateDao by lazy { database.playbackStateDao() }
|
||||||
|
private val queueDao: QueueDao by lazy { database.queueDao() }
|
||||||
|
|
||||||
|
override suspend fun readState(library: Library): PersistenceRepository.SavedState? {
|
||||||
|
val playbackState = playbackStateDao.getState() ?: return null
|
||||||
|
val heap = queueDao.getHeap()
|
||||||
|
val mapping = queueDao.getMapping()
|
||||||
|
|
||||||
|
val orderedMapping = mutableListOf<Int>()
|
||||||
|
val shuffledMapping = mutableListOf<Int>()
|
||||||
|
for (entry in mapping) {
|
||||||
|
orderedMapping.add(entry.orderedIndex)
|
||||||
|
shuffledMapping.add(entry.shuffledIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
val parent = playbackState.parentUid?.let { library.find<MusicParent>(it) }
|
||||||
|
|
||||||
|
return PersistenceRepository.SavedState(
|
||||||
|
parent = parent,
|
||||||
|
queueState =
|
||||||
|
Queue.SavedState(
|
||||||
|
heap.map { library.find(it.uid) },
|
||||||
|
orderedMapping,
|
||||||
|
shuffledMapping,
|
||||||
|
playbackState.index,
|
||||||
|
playbackState.songUid),
|
||||||
|
positionMs = playbackState.positionMs,
|
||||||
|
repeatMode = playbackState.repeatMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveState(state: PersistenceRepository.SavedState?) {
|
||||||
|
// Only bother saving a state if a song is actively playing from one.
|
||||||
|
// This is not the case with a null state.
|
||||||
|
playbackStateDao.nukeState()
|
||||||
|
queueDao.nukeHeap()
|
||||||
|
queueDao.nukeMapping()
|
||||||
|
logD("Cleared state")
|
||||||
|
if (state != null) {
|
||||||
|
// Transform saved state into raw state, which can then be written to the database.
|
||||||
|
val playbackState =
|
||||||
|
PlaybackState(
|
||||||
|
id = 0,
|
||||||
|
index = state.queueState.index,
|
||||||
|
positionMs = state.positionMs,
|
||||||
|
repeatMode = state.repeatMode,
|
||||||
|
songUid = state.queueState.songUid,
|
||||||
|
parentUid = state.parent?.uid)
|
||||||
|
playbackStateDao.insertState(playbackState)
|
||||||
|
|
||||||
|
// Convert the remaining queue information do their database-specific counterparts.
|
||||||
|
val heap =
|
||||||
|
state.queueState.heap.mapIndexed { i, song ->
|
||||||
|
QueueHeapItem(i, requireNotNull(song).uid)
|
||||||
|
}
|
||||||
|
queueDao.insertHeap(heap)
|
||||||
|
val mapping =
|
||||||
|
state.queueState.orderedMapping.zip(state.queueState.shuffledMapping).mapIndexed {
|
||||||
|
i,
|
||||||
|
pair ->
|
||||||
|
QueueMappingItem(i, pair.first, pair.second)
|
||||||
|
}
|
||||||
|
queueDao.insertMapping(mapping)
|
||||||
|
logD("Wrote state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.playback.state
|
package org.oxycblt.auxio.playback.queue
|
||||||
|
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlin.random.nextInt
|
import kotlin.random.nextInt
|
|
@ -24,7 +24,6 @@ import org.oxycblt.auxio.list.adapter.BasicListInstructions
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.playback.state.Queue
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewModel] that manages the current queue state and allows navigation through the queue.
|
* A [ViewModel] that manages the current queue state and allows navigation through the queue.
|
||||||
|
|
|
@ -1,335 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2021 Auxio Project
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.playback.state
|
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
|
||||||
import android.provider.BaseColumns
|
|
||||||
import androidx.core.database.getIntOrNull
|
|
||||||
import androidx.core.database.sqlite.transaction
|
|
||||||
import org.oxycblt.auxio.music.*
|
|
||||||
import org.oxycblt.auxio.music.library.Library
|
|
||||||
import org.oxycblt.auxio.util.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [SQLiteDatabase] that persists the current playback state for future app lifecycles.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class PlaybackStateDatabase private constructor(context: Context) :
|
|
||||||
SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
|
|
||||||
|
|
||||||
override fun onCreate(db: SQLiteDatabase) {
|
|
||||||
// Here, we have to split the database into two tables. One contains the queue with
|
|
||||||
// an indefinite amount of items, and the other contains only one entry consisting
|
|
||||||
// of the non-queue parts of the state, such as the playback position.
|
|
||||||
db.createTable(TABLE_STATE) {
|
|
||||||
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
|
|
||||||
append("${PlaybackStateColumns.INDEX} INTEGER NOT NULL,")
|
|
||||||
append("${PlaybackStateColumns.POSITION} LONG NOT NULL,")
|
|
||||||
append("${PlaybackStateColumns.REPEAT_MODE} INTEGER NOT NULL,")
|
|
||||||
append("${PlaybackStateColumns.SONG_UID} STRING,")
|
|
||||||
append("${PlaybackStateColumns.PARENT_UID} STRING")
|
|
||||||
}
|
|
||||||
|
|
||||||
db.createTable(TABLE_QUEUE_HEAP) {
|
|
||||||
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
|
|
||||||
append("${QueueHeapColumns.SONG_UID} STRING NOT NULL")
|
|
||||||
}
|
|
||||||
|
|
||||||
db.createTable(TABLE_QUEUE_MAPPINGS) {
|
|
||||||
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
|
|
||||||
append("${QueueMappingColumns.ORDERED_INDEX} INT NOT NULL,")
|
|
||||||
append("${QueueMappingColumns.SHUFFLED_INDEX} INT")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
|
|
||||||
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
|
|
||||||
|
|
||||||
private fun nuke(db: SQLiteDatabase) {
|
|
||||||
logD("Nuking database")
|
|
||||||
db.apply {
|
|
||||||
execSQL("DROP TABLE IF EXISTS $TABLE_STATE")
|
|
||||||
execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_HEAP")
|
|
||||||
execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_MAPPINGS")
|
|
||||||
onCreate(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- INTERFACE FUNCTIONS ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a persisted [SavedState] from the database.
|
|
||||||
* @param library [Library] required to restore [SavedState].
|
|
||||||
* @return A persisted [SavedState], or null if one could not be found.
|
|
||||||
*/
|
|
||||||
fun read(library: Library): SavedState? {
|
|
||||||
requireBackgroundThread()
|
|
||||||
// Read the saved state and queue. If the state is non-null, that must imply an
|
|
||||||
// existent, albeit possibly empty, queue.
|
|
||||||
val rawState = readRawPlaybackState() ?: return null
|
|
||||||
val rawQueueState = readRawQueueState(library)
|
|
||||||
// Restore parent item from the music library. If this fails, then the playback mode
|
|
||||||
// reverts to "All Songs", which is considered okay.
|
|
||||||
val parent = rawState.parentUid?.let { library.find<MusicParent>(it) }
|
|
||||||
return SavedState(
|
|
||||||
parent = parent,
|
|
||||||
queueState =
|
|
||||||
Queue.SavedState(
|
|
||||||
heap = rawQueueState.heap,
|
|
||||||
orderedMapping = rawQueueState.orderedMapping,
|
|
||||||
shuffledMapping = rawQueueState.shuffledMapping,
|
|
||||||
index = rawState.index,
|
|
||||||
songUid = rawState.songUid),
|
|
||||||
positionMs = rawState.positionMs,
|
|
||||||
repeatMode = rawState.repeatMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readRawPlaybackState() =
|
|
||||||
readableDatabase.queryAll(TABLE_STATE) { cursor ->
|
|
||||||
if (!cursor.moveToFirst()) {
|
|
||||||
// Empty, nothing to do.
|
|
||||||
return@queryAll null
|
|
||||||
}
|
|
||||||
|
|
||||||
val indexIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.INDEX)
|
|
||||||
val posIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.POSITION)
|
|
||||||
val repeatModeIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.REPEAT_MODE)
|
|
||||||
val songUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.SONG_UID)
|
|
||||||
val parentUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.PARENT_UID)
|
|
||||||
RawPlaybackState(
|
|
||||||
index = cursor.getInt(indexIndex),
|
|
||||||
positionMs = cursor.getLong(posIndex),
|
|
||||||
repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex))
|
|
||||||
?: RepeatMode.NONE,
|
|
||||||
songUid = Music.UID.fromString(cursor.getString(songUidIndex))
|
|
||||||
?: return@queryAll null,
|
|
||||||
parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readRawQueueState(library: Library): RawQueueState {
|
|
||||||
val heap = mutableListOf<Song?>()
|
|
||||||
readableDatabase.queryAll(TABLE_QUEUE_HEAP) { cursor ->
|
|
||||||
if (cursor.count == 0) {
|
|
||||||
// Empty, nothing to do.
|
|
||||||
return@queryAll
|
|
||||||
}
|
|
||||||
|
|
||||||
val songIndex = cursor.getColumnIndexOrThrow(QueueHeapColumns.SONG_UID)
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
heap.add(Music.UID.fromString(cursor.getString(songIndex))?.let(library::find))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logD("Successfully read queue of ${heap.size} songs")
|
|
||||||
|
|
||||||
val orderedMapping = mutableListOf<Int?>()
|
|
||||||
val shuffledMapping = mutableListOf<Int?>()
|
|
||||||
readableDatabase.queryAll(TABLE_QUEUE_MAPPINGS) { cursor ->
|
|
||||||
if (cursor.count == 0) {
|
|
||||||
// Empty, nothing to do.
|
|
||||||
return@queryAll
|
|
||||||
}
|
|
||||||
|
|
||||||
val orderedIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.ORDERED_INDEX)
|
|
||||||
val shuffledIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.SHUFFLED_INDEX)
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
orderedMapping.add(cursor.getInt(orderedIndex))
|
|
||||||
cursor.getIntOrNull(shuffledIndex)?.let(shuffledMapping::add)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return RawQueueState(heap, orderedMapping.filterNotNull(), shuffledMapping.filterNotNull())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the previous [SavedState] and write a new one.
|
|
||||||
* @param state The new [SavedState] to write, or null to clear the database entirely.
|
|
||||||
*/
|
|
||||||
fun write(state: SavedState?) {
|
|
||||||
requireBackgroundThread()
|
|
||||||
// Only bother saving a state if a song is actively playing from one.
|
|
||||||
// This is not the case with a null state.
|
|
||||||
if (state != null) {
|
|
||||||
// Transform saved state into raw state, which can then be written to the database.
|
|
||||||
val rawPlaybackState =
|
|
||||||
RawPlaybackState(
|
|
||||||
index = state.queueState.index,
|
|
||||||
positionMs = state.positionMs,
|
|
||||||
repeatMode = state.repeatMode,
|
|
||||||
songUid = state.queueState.songUid,
|
|
||||||
parentUid = state.parent?.uid)
|
|
||||||
writeRawPlaybackState(rawPlaybackState)
|
|
||||||
val rawQueueState =
|
|
||||||
RawQueueState(
|
|
||||||
heap = state.queueState.heap,
|
|
||||||
orderedMapping = state.queueState.orderedMapping,
|
|
||||||
shuffledMapping = state.queueState.shuffledMapping)
|
|
||||||
writeRawQueueState(rawQueueState)
|
|
||||||
logD("Wrote state")
|
|
||||||
} else {
|
|
||||||
writeRawPlaybackState(null)
|
|
||||||
writeRawQueueState(null)
|
|
||||||
logD("Cleared state")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeRawPlaybackState(rawPlaybackState: RawPlaybackState?) {
|
|
||||||
writableDatabase.transaction {
|
|
||||||
delete(TABLE_STATE, null, null)
|
|
||||||
|
|
||||||
if (rawPlaybackState != null) {
|
|
||||||
val stateData =
|
|
||||||
ContentValues(7).apply {
|
|
||||||
put(BaseColumns._ID, 0)
|
|
||||||
put(PlaybackStateColumns.SONG_UID, rawPlaybackState.songUid.toString())
|
|
||||||
put(PlaybackStateColumns.POSITION, rawPlaybackState.positionMs)
|
|
||||||
put(PlaybackStateColumns.PARENT_UID, rawPlaybackState.parentUid?.toString())
|
|
||||||
put(PlaybackStateColumns.INDEX, rawPlaybackState.index)
|
|
||||||
put(PlaybackStateColumns.REPEAT_MODE, rawPlaybackState.repeatMode.intCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
insert(TABLE_STATE, null, stateData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeRawQueueState(rawQueueState: RawQueueState?) {
|
|
||||||
writableDatabase.writeList(rawQueueState?.heap ?: listOf(), TABLE_QUEUE_HEAP) { i, song ->
|
|
||||||
ContentValues(2).apply {
|
|
||||||
put(BaseColumns._ID, i)
|
|
||||||
put(QueueHeapColumns.SONG_UID, unlikelyToBeNull(song).uid.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val combinedMapping =
|
|
||||||
rawQueueState?.run {
|
|
||||||
if (shuffledMapping.isNotEmpty()) {
|
|
||||||
orderedMapping.zip(shuffledMapping)
|
|
||||||
} else {
|
|
||||||
orderedMapping.map { Pair(it, null) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writableDatabase.writeList(combinedMapping ?: listOf(), TABLE_QUEUE_MAPPINGS) { i, pair ->
|
|
||||||
ContentValues(3).apply {
|
|
||||||
put(BaseColumns._ID, i)
|
|
||||||
put(QueueMappingColumns.ORDERED_INDEX, pair.first)
|
|
||||||
put(QueueMappingColumns.SHUFFLED_INDEX, pair.second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A lower-level form of [SavedState] that contains individual field-based information. */
|
|
||||||
private data class RawPlaybackState(
|
|
||||||
/** @see Queue.SavedState.index */
|
|
||||||
val index: Int,
|
|
||||||
/** @see SavedState.positionMs */
|
|
||||||
val positionMs: Long,
|
|
||||||
/** @see SavedState.repeatMode */
|
|
||||||
val repeatMode: RepeatMode,
|
|
||||||
/**
|
|
||||||
* The [Music.UID] of the [Song] that was originally in the queue at [index]. This can be
|
|
||||||
* used to restore the currently playing item in the queue if the index mapping changed.
|
|
||||||
*/
|
|
||||||
val songUid: Music.UID,
|
|
||||||
/** @see SavedState.parent */
|
|
||||||
val parentUid: Music.UID?
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A lower-level form of [Queue.SavedState] that contains heap and mapping information. */
|
|
||||||
private data class RawQueueState(
|
|
||||||
/** @see Queue.SavedState.heap */
|
|
||||||
val heap: List<Song?>,
|
|
||||||
/** @see Queue.SavedState.orderedMapping */
|
|
||||||
val orderedMapping: List<Int>,
|
|
||||||
/** @see Queue.SavedState.shuffledMapping */
|
|
||||||
val shuffledMapping: List<Int>
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Defines the columns used in the playback state table. */
|
|
||||||
private object PlaybackStateColumns {
|
|
||||||
/** @see RawPlaybackState.index */
|
|
||||||
const val INDEX = "queue_index"
|
|
||||||
/** @see RawPlaybackState.positionMs */
|
|
||||||
const val POSITION = "position"
|
|
||||||
/** @see RawPlaybackState.repeatMode */
|
|
||||||
const val REPEAT_MODE = "repeat_mode"
|
|
||||||
/** @see RawPlaybackState.songUid */
|
|
||||||
const val SONG_UID = "song_uid"
|
|
||||||
/** @see RawPlaybackState.parentUid */
|
|
||||||
const val PARENT_UID = "parent"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Defines the columns used in the queue heap table. */
|
|
||||||
private object QueueHeapColumns {
|
|
||||||
/** @see Music.UID */
|
|
||||||
const val SONG_UID = "song_uid"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Defines the columns used in the queue mapping table. */
|
|
||||||
private object QueueMappingColumns {
|
|
||||||
/** @see Queue.SavedState.orderedMapping */
|
|
||||||
const val ORDERED_INDEX = "ordered_index"
|
|
||||||
/** @see Queue.SavedState.shuffledMapping */
|
|
||||||
const val SHUFFLED_INDEX = "shuffled_index"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val DB_NAME = "auxio_playback_state.db"
|
|
||||||
private const val DB_VERSION = 9
|
|
||||||
private const val TABLE_STATE = "playback_state"
|
|
||||||
private const val TABLE_QUEUE_HEAP = "queue_heap"
|
|
||||||
private const val TABLE_QUEUE_MAPPINGS = "queue_mapping"
|
|
||||||
|
|
||||||
@Volatile private var INSTANCE: PlaybackStateDatabase? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a singleton instance.
|
|
||||||
* @return The (possibly newly-created) singleton instance.
|
|
||||||
*/
|
|
||||||
fun getInstance(context: Context): PlaybackStateDatabase {
|
|
||||||
val currentInstance = INSTANCE
|
|
||||||
|
|
||||||
if (currentInstance != null) {
|
|
||||||
return currentInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(this) {
|
|
||||||
val newInstance = PlaybackStateDatabase(context.applicationContext)
|
|
||||||
INSTANCE = newInstance
|
|
||||||
return newInstance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -23,6 +23,8 @@ import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.library.Library
|
import org.oxycblt.auxio.music.library.Library
|
||||||
|
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||||
|
import org.oxycblt.auxio.playback.queue.Queue
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logE
|
import org.oxycblt.auxio.util.logE
|
||||||
|
@ -387,11 +389,11 @@ class PlaybackStateManager private constructor() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore the previously saved state (if any) and apply it to the playback state.
|
* Restore the previously saved state (if any) and apply it to the playback state.
|
||||||
* @param database The [PlaybackStateDatabase] to load from.
|
* @param repository The [PersistenceRepository] to load from.
|
||||||
* @param force Whether to do a restore regardless of any prior playback state.
|
* @param force Whether to do a restore regardless of any prior playback state.
|
||||||
* @return If the state was restored, false otherwise.
|
* @return If the state was restored, false otherwise.
|
||||||
*/
|
*/
|
||||||
suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean {
|
suspend fun restoreState(repository: PersistenceRepository, force: Boolean): Boolean {
|
||||||
if (isInitialized && !force) {
|
if (isInitialized && !force) {
|
||||||
// Already initialized and not forcing a restore, nothing to do.
|
// Already initialized and not forcing a restore, nothing to do.
|
||||||
return false
|
return false
|
||||||
|
@ -401,7 +403,7 @@ class PlaybackStateManager private constructor() {
|
||||||
val internalPlayer = internalPlayer ?: return false
|
val internalPlayer = internalPlayer ?: return false
|
||||||
val state =
|
val state =
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { database.read(library) }
|
withContext(Dispatchers.IO) { repository.readState(library) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logE("Unable to restore playback state.")
|
logE("Unable to restore playback state.")
|
||||||
logE(e.stackTraceToString())
|
logE(e.stackTraceToString())
|
||||||
|
@ -432,16 +434,16 @@ class PlaybackStateManager private constructor() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the current state.
|
* Save the current state.
|
||||||
* @param database The [PlaybackStateDatabase] to save the state to.
|
* @param database The [PersistenceRepository] to save the state to.
|
||||||
* @return If state was saved, false otherwise.
|
* @return If state was saved, false otherwise.
|
||||||
*/
|
*/
|
||||||
suspend fun saveState(database: PlaybackStateDatabase): Boolean {
|
suspend fun saveState(database: PersistenceRepository): Boolean {
|
||||||
logD("Saving state to DB")
|
logD("Saving state to DB")
|
||||||
// Create the saved state from the current playback state.
|
// Create the saved state from the current playback state.
|
||||||
val state =
|
val state =
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
queue.toSavedState()?.let {
|
queue.toSavedState()?.let {
|
||||||
PlaybackStateDatabase.SavedState(
|
PersistenceRepository.SavedState(
|
||||||
parent = parent,
|
parent = parent,
|
||||||
queueState = it,
|
queueState = it,
|
||||||
positionMs = playerState.calculateElapsedPositionMs(),
|
positionMs = playerState.calculateElapsedPositionMs(),
|
||||||
|
@ -449,7 +451,7 @@ class PlaybackStateManager private constructor() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
withContext(Dispatchers.IO) { database.write(state) }
|
withContext(Dispatchers.IO) { database.saveState(state) }
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logE("Unable to save playback state.")
|
logE("Unable to save playback state.")
|
||||||
|
@ -460,13 +462,13 @@ class PlaybackStateManager private constructor() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the current state.
|
* Clear the current state.
|
||||||
* @param database The [PlaybackStateDatabase] to clear te state from
|
* @param repository The [PersistenceRepository] to clear the state from
|
||||||
* @return If the state was cleared, false otherwise.
|
* @return If the state was cleared, false otherwise.
|
||||||
*/
|
*/
|
||||||
suspend fun wipeState(database: PlaybackStateDatabase) =
|
suspend fun wipeState(repository: PersistenceRepository) =
|
||||||
try {
|
try {
|
||||||
logD("Wiping state")
|
logD("Wiping state")
|
||||||
withContext(Dispatchers.IO) { database.write(null) }
|
withContext(Dispatchers.IO) { repository.saveState(null) }
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logE("Unable to wipe playback state.")
|
logE("Unable to wipe playback state.")
|
||||||
|
|
|
@ -35,9 +35,9 @@ import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.ActionMode
|
import org.oxycblt.auxio.playback.ActionMode
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
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.InternalPlayer
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.playback.state.Queue
|
|
||||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
|
|
|
@ -48,9 +48,9 @@ import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.library.Library
|
import org.oxycblt.auxio.music.library.Library
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
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.replaygain.ReplayGainAudioProcessor
|
||||||
import org.oxycblt.auxio.playback.state.InternalPlayer
|
import org.oxycblt.auxio.playback.state.InternalPlayer
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
|
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
import org.oxycblt.auxio.service.ForegroundManager
|
import org.oxycblt.auxio.service.ForegroundManager
|
||||||
|
@ -95,6 +95,7 @@ class PlaybackService :
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private lateinit var musicSettings: MusicSettings
|
private lateinit var musicSettings: MusicSettings
|
||||||
private lateinit var playbackSettings: PlaybackSettings
|
private lateinit var playbackSettings: PlaybackSettings
|
||||||
|
private lateinit var persistenceRepository: PersistenceRepository
|
||||||
|
|
||||||
// State
|
// State
|
||||||
private lateinit var foregroundManager: ForegroundManager
|
private lateinit var foregroundManager: ForegroundManager
|
||||||
|
@ -147,6 +148,7 @@ class PlaybackService :
|
||||||
// Initialize the core service components
|
// Initialize the core service components
|
||||||
musicSettings = MusicSettings.from(this)
|
musicSettings = MusicSettings.from(this)
|
||||||
playbackSettings = PlaybackSettings.from(this)
|
playbackSettings = PlaybackSettings.from(this)
|
||||||
|
persistenceRepository = PersistenceRepository.from(this)
|
||||||
foregroundManager = ForegroundManager(this)
|
foregroundManager = ForegroundManager(this)
|
||||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
// 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.
|
// condition to cause us to load music before we were fully initialize.
|
||||||
|
@ -331,9 +333,7 @@ class PlaybackService :
|
||||||
// to save the current state as it's not long until this service (and likely the whole
|
// to save the current state as it's not long until this service (and likely the whole
|
||||||
// app) is killed.
|
// app) is killed.
|
||||||
logD("Saving playback state")
|
logD("Saving playback state")
|
||||||
saveScope.launch {
|
saveScope.launch { playbackManager.saveState(persistenceRepository) }
|
||||||
playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -348,10 +348,7 @@ class PlaybackService :
|
||||||
when (action) {
|
when (action) {
|
||||||
// Restore state -> Start a new restoreState job
|
// Restore state -> Start a new restoreState job
|
||||||
is InternalPlayer.Action.RestoreState -> {
|
is InternalPlayer.Action.RestoreState -> {
|
||||||
restoreScope.launch {
|
restoreScope.launch { playbackManager.restoreState(persistenceRepository, false) }
|
||||||
playbackManager.restoreState(
|
|
||||||
PlaybackStateDatabase.getInstance(this@PlaybackService), false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Shuffle all -> Start new playback from all songs
|
// Shuffle all -> Start new playback from all songs
|
||||||
is InternalPlayer.Action.ShuffleAll -> {
|
is InternalPlayer.Action.ShuffleAll -> {
|
||||||
|
|
|
@ -28,9 +28,9 @@ import org.oxycblt.auxio.image.ImageSettings
|
||||||
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
|
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
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.InternalPlayer
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.playback.state.Queue
|
|
||||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
import org.oxycblt.auxio.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
import org.oxycblt.auxio.util.getDimenPixels
|
import org.oxycblt.auxio.util.getDimenPixels
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.7.21'
|
ext {
|
||||||
ext.navigation_version = "2.5.3"
|
kotlin_version = '1.7.21'
|
||||||
|
navigation_version = "2.5.3"
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
|
|
Loading…
Reference in a new issue