Remember playback choices & fix some track selection issues (#26)

Remembers changes in playback tracks across playbacks. So, if you change
the audio track, quit the app, and return to the same id, the app will
play the audio track previously chosen.

Additionally, this PR fixes a UI inconsistency when there are multiple
versions of a item. Previously sometimes the audio/subtitle tracks for
wrong version would be shown during playback.
This commit is contained in:
damontecres 2025-10-17 17:19:44 -04:00 committed by GitHub
parent f220aeee44
commit 6194eba8c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1152 additions and 154 deletions

View file

@ -1,13 +1,66 @@
package com.github.damontecres.wholphin.data
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.damontecres.wholphin.data.model.ItemPlayback
import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID
@Database(
entities = [JellyfinServer::class, JellyfinUser::class],
version = 2,
exportSchema = false,
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class],
version = 4,
exportSchema = true,
autoMigrations = [AutoMigration(3, 4)],
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun serverDao(): JellyfinServerDao
abstract fun itemPlaybackDao(): ItemPlaybackDao
}
class Converters {
@TypeConverter
fun convertToString(id: UUID): String = id.toString().replace("-", "")
@TypeConverter
fun convertToUUID(str: String): UUID = str.toUUID()
}
object Migrations {
val Migrate2to3 =
object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE users RENAME TO users_old")
db.execSQL(
"""
CREATE TABLE "users" (
rowId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
id text NOT NULL,
name text,
serverId text NOT NULL,
accessToken text,
FOREIGN KEY (serverId) REFERENCES "servers" (id) ON DELETE CASCADE ON UPDATE NO ACTION
)
""".trimIndent(),
)
db.execSQL("UPDATE servers SET id = REPLACE(id, '-', '')")
db.execSQL(
"""
INSERT INTO users (id, name, serverId, accessToken)
SELECT REPLACE(id, '-', ''), name, REPLACE(serverId, '-', ''), accessToken
FROM users_old
""".trimIndent(),
)
db.execSQL("DROP TABLE users_old")
db.execSQL("CREATE INDEX IF NOT EXISTS index_users_serverId ON users (serverId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_users_id ON users (id)")
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_users_id_serverId ON users (id, serverId)")
}
}
}

View file

@ -0,0 +1,25 @@
package com.github.damontecres.wholphin.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.damontecres.wholphin.data.model.ItemPlayback
import java.util.UUID
@Dao
interface ItemPlaybackDao {
fun getItem(
user: JellyfinUser,
itemId: UUID,
): ItemPlayback? = getItem(user.rowId, itemId)
@Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId")
fun getItem(
userId: Int,
itemId: UUID,
): ItemPlayback?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveItem(item: ItemPlayback): Long
}

View file

@ -0,0 +1,62 @@
package com.github.damontecres.wholphin.data
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.TrackIndex
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ItemPlaybackRepository
@Inject
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
) {
fun getSelectedTracks(
itemId: UUID,
item: BaseItem,
): ChosenStreams? =
serverRepository.currentUser?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
if (itemPlayback != null) {
Timber.v("Got itemPlayback for %s", itemId)
val source =
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId }
if (source != null) {
val audioStream =
if (itemPlayback.audioIndexEnabled) {
source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex }
} else {
null
}
val subtitleStream =
if (itemPlayback.subtitleIndexEnabled) {
source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else {
null
}
ChosenStreams(
itemId,
audioStream,
subtitleStream,
itemPlayback.subtitleIndex == TrackIndex.DISABLED,
)
} else {
null
}
} else {
null
}
}
}
data class ChosenStreams(
val itemId: UUID,
val audioStream: MediaStream?,
val subtitleStream: MediaStream?,
val subtitlesDisabled: Boolean,
)

View file

@ -5,6 +5,7 @@ import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
@ -12,17 +13,17 @@ import androidx.room.Query
import androidx.room.Relation
import androidx.room.Transaction
import androidx.room.Update
import java.util.UUID
@Entity(tableName = "servers")
data class JellyfinServer(
@PrimaryKey val id: String,
@PrimaryKey val id: UUID,
val name: String?,
val url: String,
)
@Entity(
tableName = "users",
primaryKeys = ["id", "serverId"],
foreignKeys = [
ForeignKey(
entity = JellyfinServer::class,
@ -31,13 +32,16 @@ data class JellyfinServer(
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("id", "serverId", unique = true)],
)
data class JellyfinUser(
@PrimaryKey(autoGenerate = true)
val rowId: Int = 0,
@ColumnInfo(index = true)
val id: String,
val id: UUID,
val name: String?,
@ColumnInfo(index = true)
val serverId: String,
val serverId: UUID,
val accessToken: String?,
)
@ -66,16 +70,27 @@ interface JellyfinServerDao {
}
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun addUser(user: JellyfinUser)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun addUser(user: JellyfinUser): Long
@Update
fun updateUser(user: JellyfinUser): Int
@Transaction
fun addOrUpdateUser(user: JellyfinUser) {
val result = addUser(user)
if (result == -1L) {
updateUser(user)
}
}
@Query("DELETE FROM servers WHERE id = :serverId")
fun deleteServer(serverId: String)
fun deleteServer(serverId: UUID)
@Query("DELETE FROM users WHERE serverId = :serverId AND id = :userId")
fun deleteUser(
serverId: String,
userId: String,
serverId: UUID,
userId: UUID,
)
@Transaction
@ -84,5 +99,5 @@ interface JellyfinServerDao {
@Transaction
@Query("SELECT * FROM servers WHERE id = :serverId")
fun getServer(serverId: String): JellyfinServerUsers?
fun getServer(serverId: UUID): JellyfinServerUsers?
}

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin
@ -13,7 +14,9 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@ -81,17 +84,17 @@ class ServerRepository
val updatedServer = server.copy(name = sysInfo.serverName)
val updatedUser =
user.copy(
id = userDto.id.toString(),
id = userDto.id,
name = userDto.name,
)
serverDao.addOrUpdateServer(updatedServer)
serverDao.addUser(updatedUser)
serverDao.addOrUpdateUser(updatedUser)
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = updatedServer.id
currentUserId = updatedUser.id
currentServerId = updatedServer.id.toServerString()
currentUserId = updatedUser.id.toServerString()
}.build()
}
withContext(Dispatchers.Main) {
@ -105,9 +108,12 @@ class ServerRepository
* Restores a session for the given server & user such as when the app reopens
*/
suspend fun restoreSession(
serverId: String,
userId: String,
serverId: UUID?,
userId: UUID?,
): Boolean {
if (serverId == null || userId == null) {
return false
}
val serverAndUsers =
withContext(Dispatchers.IO) {
serverDao.getServer(serverId)
@ -134,7 +140,7 @@ class ServerRepository
if (accessToken != null) {
val authedUser = authenticationResult.user
val server =
authenticationResult.serverId?.let {
authenticationResult.serverId?.toUUIDOrNull()?.let {
JellyfinServer(
id = it,
name = authedUser?.serverName,
@ -145,7 +151,7 @@ class ServerRepository
val user =
authedUser?.let {
JellyfinUser(
id = it.id.toString(),
id = it.id,
name = it.name,
serverId = server.id,
accessToken = accessToken,

View file

@ -0,0 +1,52 @@
@file:UseSerializers(UuidSerializer::class)
package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.damontecres.wholphin.data.JellyfinUser
import com.github.damontecres.wholphin.util.UuidSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import java.util.UUID
@Entity(
foreignKeys = [
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("userId"),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
),
],
indices = [Index("userId", "itemId", unique = true)],
)
@Serializable
data class ItemPlayback(
@PrimaryKey(autoGenerate = true)
val rowId: Long = 0,
val userId: Int,
val itemId: UUID,
val sourceId: UUID? = null,
val audioIndex: Int = TrackIndex.UNSPECIFIED,
val subtitleIndex: Int = TrackIndex.UNSPECIFIED,
) {
@Transient val audioIndexEnabled = audioIndex >= 0
@Transient val subtitleIndexEnabled = subtitleIndex >= 0
}
object TrackIndex {
/**
* The user has not explicitly specified a track to use
*/
const val UNSPECIFIED = -1
/**
* The user has explicitly disabled the tracks (eg turned off subtitles)
*/
const val DISABLED = -2
}