diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c9c6de36..8a87db91 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,6 +56,7 @@ android { targetSdk = 36 versionCode = getVersionCode() versionName = getAppVersion() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -217,6 +218,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.preference.ktx) + implementation(libs.androidx.room.testing) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/2.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/2.json new file mode 100644 index 00000000..da12c17d --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/2.json @@ -0,0 +1,110 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "894c56e016891f22711b29de75a23b46", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, PRIMARY KEY(`id`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "serverId" + ] + }, + "indices": [ + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '894c56e016891f22711b29de75a23b46')" + ] + } +} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/3.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/3.json new file mode 100644 index 00000000..00cb0a95 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/3.json @@ -0,0 +1,125 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "2b3163bf790e82a3b6568c471cc52906", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2b3163bf790e82a3b6568c471cc52906')" + ] + } +} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/4.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/4.json new file mode 100644 index 00000000..2808d0d5 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/4.json @@ -0,0 +1,197 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "9c4aaf3796c72aa43273904ac45a33c7", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9c4aaf3796c72aa43273904ac45a33c7')" + ] + } +} diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestDbMigrations.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestDbMigrations.kt new file mode 100644 index 00000000..3fd8faf8 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestDbMigrations.kt @@ -0,0 +1,117 @@ +package com.github.damontecres.wholphin.test + +import androidx.room.testing.MigrationTestHelper +import androidx.room.util.useCursor +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.github.damontecres.wholphin.data.AppDatabase +import com.github.damontecres.wholphin.data.Migrations +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class TestDbMigrations { + private val testDbName = "migration-test" + + @get:Rule + val helper: MigrationTestHelper = + MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + ) + + @Test + @Throws(IOException::class) + fun migrate2To3() { + val serverId = UUID.randomUUID() + val userId = UUID.randomUUID() + helper.createDatabase(testDbName, 2).apply { + execSQL( + "INSERT INTO servers VALUES (?, ?, ?)", + arrayOf( + serverId.toString(), + "name", + "url", + ), + ) + execSQL( + "INSERT INTO users (serverId, id, name, accessToken) VALUES (?, ?, ?, ?)", + arrayOf( + serverId.toString(), + userId.toString(), + "username", + "token", + ), + ) + close() + } + + val db = + helper.runMigrationsAndValidate( + testDbName, + 3, + true, + migrations = arrayOf(Migrations.Migrate2to3), + ) + + db.query("SELECT id FROM servers").useCursor { c -> + c.moveToFirst() + Assert.assertEquals(serverId.toString().replace("-", ""), c.getString(0)) + } + db.query("SELECT serverId, id FROM users").useCursor { c -> + c.moveToFirst() + Assert.assertEquals(serverId.toString().replace("-", ""), c.getString(0)) + Assert.assertEquals(userId.toString().replace("-", ""), c.getString(1)) + } + } + + @Test + @Throws(IOException::class) + fun migrate2To3_2() { + val serverId = UUID.randomUUID() + val userId = UUID.randomUUID() + helper.createDatabase(testDbName, 2).apply { + execSQL( + "INSERT INTO servers VALUES (?, ?, ?)", + arrayOf( + serverId.toString().replace("-", ""), + "name", + "url", + ), + ) + execSQL( + "INSERT INTO users (serverId, id, name, accessToken) VALUES (?, ?, ?, ?)", + arrayOf( + serverId.toString(), + userId.toString(), + "username", + "token", + ), + ) + close() + } + + val db = + helper.runMigrationsAndValidate( + testDbName, + 3, + true, + migrations = arrayOf(Migrations.Migrate2to3), + ) + + db.query("SELECT id FROM servers").useCursor { c -> + c.moveToFirst() + Assert.assertEquals(serverId.toString().replace("-", ""), c.getString(0)) + } + db.query("SELECT rowId, serverId, id FROM users").useCursor { c -> + c.moveToFirst() + Assert.assertTrue(c.getInt(0) > 0) + Assert.assertEquals(serverId.toString().replace("-", ""), c.getString(1)) + Assert.assertEquals(userId.toString().replace("-", ""), c.getString(2)) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 6d390e6a..67277279 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -43,6 +43,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import okhttp3.OkHttpClient +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject @@ -91,11 +92,11 @@ class MainActivity : AppCompatActivity() { ) { var isRestoringSession by remember { mutableStateOf(true) } LaunchedEffect(Unit) { - if (appPreferences.currentServerId.isNotBlank()) { + if (appPreferences.currentServerId.isNotBlank() && appPreferences.currentUserId.isNotBlank()) { try { serverRepository.restoreSession( - appPreferences.currentServerId, - appPreferences.currentUserId, + appPreferences.currentServerId?.toUUIDOrNull(), + appPreferences.currentUserId?.toUUIDOrNull(), ) } catch (ex: Exception) { Timber.e(ex, "Exception restoring session") diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index e982b84b..3707309b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -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)") + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt new file mode 100644 index 00000000..3e2517ee --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt @@ -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 +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt new file mode 100644 index 00000000..5ad64d1a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServer.kt index bddb8cc1..8becb8d1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServer.kt @@ -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? } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 5bfc8768..1e96f647 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt new file mode 100644 index 00000000..9bd699a1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt @@ -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 +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt index ed9f4794..845f9a46 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt @@ -7,7 +7,9 @@ import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile import androidx.room.Room import com.github.damontecres.wholphin.data.AppDatabase +import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import dagger.Module @@ -30,13 +32,20 @@ object DatabaseModule { context, AppDatabase::class.java, "wholphin", - ).fallbackToDestructiveMigration(false) + ).addMigrations(Migrations.Migrate2to3) +// .setQueryCallback({ sqlQuery, args -> +// Timber.v("sqlQuery=$sqlQuery, args=$args") +// }, Dispatchers.IO.asExecutor()) .build() @Provides @Singleton fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao() + @Provides + @Singleton + fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 25185a83..defbd166 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber +import java.util.UUID import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract @@ -351,3 +352,11 @@ fun CoroutineScope.launchIO( start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, ): Job = launch(context = Dispatchers.IO + context, start = start, block = block) + +/** + * Converts a UUID to the format used server-side (ie without hyphens). + * + * This is the inverse of [org.jellyfin.sdk.model.serializer.toUUID] + * + */ +fun UUID.toServerString() = this.toString().replace("-", "") diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt index 80da92e0..58b0428f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt @@ -11,11 +11,14 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import com.github.damontecres.wholphin.data.ChosenStreams +import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager @@ -29,6 +32,7 @@ import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -57,6 +61,7 @@ class SeriesViewModel @param:ApplicationContext val context: Context, @param:AuthOkHttpClient private val okHttpClient: OkHttpClient, private val navigationManager: NavigationManager, + private val itemPlaybackRepository: ItemPlaybackRepository, ) : ItemViewModel(api) { private var player: Player? = null private lateinit var seriesId: UUID @@ -306,6 +311,23 @@ class SeriesViewModel release() navigationManager.navigateTo(destination) } + + val chosenStreams = MutableLiveData(null) + private var chosenStreamsJob: Job? = null + + fun lookUpChosenTracks( + itemId: UUID, + item: BaseItem, + ) { + chosenStreamsJob?.cancel() + chosenStreamsJob = + viewModelScope.launchIO { + val result = itemPlaybackRepository.getSelectedTracks(itemId, item) + withContext(Dispatchers.Main) { + chosenStreams.value = result + } + } + } } data class ItemListAndMapping( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index df53ef05..ee2c5860 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -36,6 +36,8 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.data.ChosenStreams +import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Person @@ -52,6 +54,7 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager @@ -62,6 +65,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.model.extensions.ticks @@ -75,10 +79,12 @@ class MovieViewModel constructor( api: ApiClient, val navigationManager: NavigationManager, + val itemPlaybackRepository: ItemPlaybackRepository, ) : LoadingItemViewModel(api) { private lateinit var itemId: UUID val people = MutableLiveData>(listOf()) val chapters = MutableLiveData>(listOf()) + val chosenStreams = MutableLiveData(null) override fun init( itemId: UUID, @@ -88,6 +94,12 @@ class MovieViewModel return viewModelScope.launch(ExceptionHandler()) { super.init(itemId, potential)?.join() item.value?.let { item -> + viewModelScope.launchIO { + val result = itemPlaybackRepository.getSelectedTracks(item.id, item) + withContext(Dispatchers.Main) { + chosenStreams.value = result + } + } people.value = item.data.people ?.letNotEmpty { people -> @@ -123,6 +135,7 @@ fun MovieDetails( val people by viewModel.people.observeAsState(listOf()) val chapters by viewModel.chapters.observeAsState(listOf()) val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val chosenStreams by viewModel.chosenStreams.observeAsState(null) var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } @@ -137,6 +150,7 @@ fun MovieDetails( MovieDetailsContent( preferences = preferences, movie = movie, + chosenStreams = chosenStreams, people = people, chapters = chapters, playOnClick = { @@ -222,6 +236,7 @@ fun MovieDetails( fun MovieDetailsContent( preferences: UserPreferences, movie: BaseItem, + chosenStreams: ChosenStreams?, people: List, chapters: List, playOnClick: (Duration) -> Unit, @@ -286,6 +301,7 @@ fun MovieDetailsContent( ) { MovieDetailsHeader( movie = movie, + chosenStreams = chosenStreams, bringIntoViewRequester = bringIntoViewRequester, overviewOnClick = overviewOnClick, Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 057882d9..ffb6d154 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -24,6 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.OverviewText @@ -33,7 +35,8 @@ import com.github.damontecres.wholphin.ui.components.TitleValueText import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.timeRemaining -import com.github.damontecres.wholphin.util.formatSubtitleLang +import com.github.damontecres.wholphin.util.getAudioDisplay +import com.github.damontecres.wholphin.util.getSubtitleDisplay import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks @@ -41,6 +44,7 @@ import org.jellyfin.sdk.model.extensions.ticks @Composable fun MovieDetailsHeader( movie: BaseItem, + chosenStreams: ChosenStreams?, bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, modifier: Modifier = Modifier, @@ -144,24 +148,18 @@ fun MovieDetailsHeader( modifier = Modifier.widthIn(max = 200.dp), ) } - dto.mediaStreams - ?.firstOrNull { it.type == MediaStreamType.AUDIO } - ?.displayTitle + val audioDisplay = + remember(movie.id, chosenStreams) { getAudioDisplay(movie.data, chosenStreams) } + audioDisplay ?.let { - // TODO probably a cleaner way to do this - // Removes part of "5.1 Surround - English - AAC - Default" - it - .replace(" - Default", "") - .ifBlank { null } - ?.let { - TitleValueText( - stringResource(R.string.audio), - it, - modifier = Modifier.widthIn(max = 200.dp), - ) - } + TitleValueText( + stringResource(R.string.audio), + it, + modifier = Modifier.widthIn(max = 200.dp), + ) } - formatSubtitleLang(dto.mediaStreams) + + getSubtitleDisplay(movie.data, chosenStreams) ?.let { if (it.isNotNullOrBlank()) { TitleValueText( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index 5a473d7e..178fca4d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -11,11 +11,13 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons import com.github.damontecres.wholphin.ui.components.TitleValueText import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.util.formatSubtitleLang +import com.github.damontecres.wholphin.util.getAudioDisplay +import com.github.damontecres.wholphin.util.getSubtitleDisplay import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration @@ -23,6 +25,7 @@ import kotlin.time.Duration @Composable fun FocusedEpisodeFooter( ep: BaseItem, + chosenStreams: ChosenStreams?, playOnClick: (Duration) -> Unit, moreOnClick: () -> Unit, watchOnClick: () -> Unit, @@ -58,25 +61,17 @@ fun FocusedEpisodeFooter( it, ) } + val audioDisplay = + remember(ep.id, chosenStreams) { getAudioDisplay(ep.data, chosenStreams) } + audioDisplay?.let { + TitleValueText( + stringResource(R.string.audio), + it, + ) + } - dto.mediaStreams - ?.firstOrNull { it.type == MediaStreamType.AUDIO } - ?.displayTitle - ?.let { - // TODO probably a cleaner way to do this - // Removes part of "5.1 Surround - English - AAC - Default" - it - .replace(" - Default", "") - .ifBlank { null } - ?.let { - TitleValueText( - stringResource(R.string.audio), - it, - ) - } - } - - formatSubtitleLang(dto.mediaStreams) + val subtitleText = getSubtitleDisplay(ep.data, chosenStreams) + subtitleText ?.let { if (it.isNotNullOrBlank()) { TitleValueText( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 5d2376d5..e913b63f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -116,6 +116,17 @@ fun SeriesOverview( } } + LaunchedEffect(position) { + (episodes as? EpisodeList.Success) + ?.episodes + ?.items + ?.getOrNull(position.episodeRowIndex) + ?.let { + viewModel.lookUpChosenTracks(it.id, it) + } + } + val chosenStreams by viewModel.chosenStreams.observeAsState(null) + when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) @@ -130,6 +141,7 @@ fun SeriesOverview( series = series, seasons = seasons.items, episodes = episodes, + chosenStreams = chosenStreams, position = position, backdropImageUrl = remember { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index d181579d..c3f7b8f0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -42,6 +42,7 @@ import androidx.tv.material3.TabRow import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.Text import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -60,6 +61,7 @@ fun SeriesOverviewContent( series: BaseItem, seasons: List, episodes: EpisodeList, + chosenStreams: ChosenStreams?, position: SeriesOverviewPosition, backdropImageUrl: String?, firstItemFocusRequester: FocusRequester, @@ -281,6 +283,7 @@ fun SeriesOverviewContent( focusedEpisode?.let { ep -> FocusedEpisodeFooter( ep = ep, + chosenStreams = chosenStreams, playOnClick = playOnClick, moreOnClick = moreOnClick, watchOnClick = watchOnClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 250aa9ab..736de9e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -5,6 +5,7 @@ package com.github.damontecres.wholphin.ui.nav import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.util.UuidSerializer @@ -70,6 +71,7 @@ sealed class Destination( @Transient val item: BaseItem? = null, val startIndex: Int? = null, val shuffle: Boolean = false, + val itemPlayback: ItemPlayback? = null, ) : Destination(true) { override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index ed99afb7..ecd57950 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -56,6 +56,7 @@ import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.seekBack @@ -398,7 +399,7 @@ fun RightPlaybackButtons( onSelectChoice = { index, _ -> val send = if (index == 0) { - -1 + TrackIndex.DISABLED } else { subtitleStreams[index - 1].index } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index cdd265b6..330602e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -45,6 +45,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.cards.ChapterCard @@ -84,6 +85,7 @@ fun PlaybackOverlay( playbackSpeed: Float, moreButtonOptions: MoreButtonOptions, currentPlayback: CurrentPlayback?, + currentItemPlayback: ItemPlayback, audioStreams: List, currentSegment: MediaSegmentDto?, modifier: Modifier = Modifier, @@ -156,6 +158,7 @@ fun PlaybackOverlay( playbackSpeed = playbackSpeed, moreButtonOptions = moreButtonOptions, currentPlayback = currentPlayback, + currentItemPlayback = currentItemPlayback, audioStreams = audioStreams, subtitle = subtitle, seekBarInteractionSource = seekBarInteractionSource, @@ -414,6 +417,7 @@ fun Controller( playbackSpeed: Float, moreButtonOptions: MoreButtonOptions, currentPlayback: CurrentPlayback?, + currentItemPlayback: ItemPlayback, audioStreams: List, nextState: OverlayViewState?, currentSegment: MediaSegmentDto?, @@ -461,8 +465,8 @@ fun Controller( seekEnabled = seekEnabled, seekBarInteractionSource = seekBarInteractionSource, moreButtonOptions = moreButtonOptions, - subtitleIndex = currentPlayback?.subtitleIndex, - audioIndex = currentPlayback?.audioIndex, + subtitleIndex = currentItemPlayback.subtitleIndex, + audioIndex = currentItemPlayback.audioIndex, audioStreams = audioStreams, playbackSpeed = playbackSpeed, scale = scale, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index e37d8d73..528e0716 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -56,6 +56,7 @@ import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume @@ -67,6 +68,7 @@ import com.github.damontecres.wholphin.util.seasonEpisode import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -99,6 +101,12 @@ fun PlaybackPage( val trickplay by viewModel.trickplay.observeAsState(null) val chapters by viewModel.chapters.observeAsState(listOf()) val currentPlayback by viewModel.currentPlayback.observeAsState(null) + val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( + ItemPlayback( + userId = -1, + itemId = UUID.randomUUID(), + ), + ) val currentSegment by viewModel.currentSegment.observeAsState(null) var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } @@ -318,6 +326,7 @@ fun PlaybackPage( playbackSpeed = playbackSpeed, moreButtonOptions = MoreButtonOptions(mapOf()), currentPlayback = currentPlayback, + currentItemPlayback = currentItemPlayback, audioStreams = audioStreams, trickplayInfo = trickplay, trickplayUrlFor = viewModel::getTrickplayUrl, @@ -331,7 +340,7 @@ fun PlaybackPage( } // Subtitles - if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentPlayback?.subtitleIndex != null) { + if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { AndroidView( factory = { context -> SubtitleView(context).apply { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 140d08ed..770f299e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -15,16 +15,21 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistCreator +import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener @@ -88,7 +93,10 @@ class PlaybackViewModel val api: ApiClient, val playlistCreator: PlaylistCreator, val navigationManager: NavigationManager, - ) : ViewModel() { + val itemPlaybackDao: ItemPlaybackDao, + val serverRepository: ServerRepository, + ) : ViewModel(), + Player.Listener { val player: ExoPlayer = ExoPlayer .Builder(context) @@ -105,6 +113,7 @@ class PlaybackViewModel val audioStreams = MutableLiveData>(listOf()) val subtitleStreams = MutableLiveData>(listOf()) val currentPlayback = MutableLiveData(null) + val currentItemPlayback = MutableLiveData() val trickplay = MutableLiveData(null) val chapters = MutableLiveData>(listOf()) val currentSegment = EqualityMutableLiveData(null) @@ -121,7 +130,14 @@ class PlaybackViewModel val playlist = MutableLiveData(Playlist(listOf())) init { - addCloseable { this@PlaybackViewModel.activityListener?.release() } + player.addListener(this) + addCloseable { player.removeListener(this@PlaybackViewModel) } + addCloseable { + this@PlaybackViewModel.activityListener?.let { + it.release() + player.removeListener(it) + } + } addCloseable { player.release() } } @@ -156,7 +172,7 @@ class PlaybackViewModel queriedItem } - val played = play(base, destination.positionMs) + val played = play(base, destination.positionMs, destination.itemPlayback) if (!played) { playUpNextUp() } @@ -175,6 +191,7 @@ class PlaybackViewModel private suspend fun play( base: BaseItemDto, positionMs: Long, + itemPlayback: ItemPlayback? = null, ): Boolean = withContext(Dispatchers.IO) { Timber.i("Playing ${base.id}") @@ -186,6 +203,38 @@ class PlaybackViewModel ) return@withContext false } + + val playbackConfig = + if (itemPlayback != null) { + itemPlayback + } else { + val user = serverRepository.currentUser!! + itemPlaybackDao.getItem(user, base.id)?.let { + Timber.v("Fetched itemPlayback from DB: %s", it) + if (it.sourceId == null) { + it.copy( + // TODO Better choice than first (which may not be the best resolution), using getPostedPlaybackInfo might be better? + sourceId = + base.mediaSources + ?.firstOrNull() + ?.id + ?.toUUIDOrNull(), + ) + } else { + it + } + } + ?: ItemPlayback( + userId = user.rowId, + itemId = base.id, + sourceId = + base.mediaSources + ?.firstOrNull() + ?.id + ?.toUUIDOrNull(), + ) + } + dto = base val title = if (base.type == BaseItemKind.EPISODE) { @@ -206,12 +255,28 @@ class PlaybackViewModel withContext(Dispatchers.Main) { this@PlaybackViewModel.title.value = title this@PlaybackViewModel.subtitle.value = subtitle + this@PlaybackViewModel.currentItemPlayback.value = playbackConfig } - base.mediaStreams - ?.filter { it.type == MediaStreamType.VIDEO } - ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } + val mediaSource = + if (playbackConfig.sourceId != null) { + base.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == playbackConfig.sourceId } + } else { + base.mediaSources?.firstOrNull() + } + if (mediaSource == null) { + showToast( + context, + "Item has no media sources, skipping...", + Toast.LENGTH_SHORT, + ) + return@withContext false + } + +// mediaSource.mediaStreams +// ?.filter { it.type == MediaStreamType.VIDEO } +// ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } val subtitleStreams = - base.mediaStreams + mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } ?.map { SubtitleStream( @@ -226,7 +291,7 @@ class PlaybackViewModel ) }.orEmpty() val audioStreams = - base.mediaStreams + mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } ?.map { AudioStream( @@ -244,50 +309,23 @@ class PlaybackViewModel // TODO audio selection based on channel layout or preferences or default val audioLanguage = preferences.userConfig.audioLanguagePreference val audioIndex = - if (audioLanguage != null) { + if (playbackConfig.audioIndexEnabled) { + playbackConfig.audioIndex + } else if (audioLanguage != null) { audioStreams.firstOrNull { it.language == audioLanguage }?.index ?: audioStreams.firstOrNull()?.index } else { audioStreams.firstOrNull()?.index } - val subtitleMode = preferences.userConfig.subtitleMode - val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference + val subtitleIndex = - when (subtitleMode) { - SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage != null) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index - } else { - subtitleStreams.firstOrNull()?.index - } - } - - SubtitlePlaybackMode.ONLY_FORCED -> - if (subtitleLanguage != null) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index - } else { - subtitleStreams.firstOrNull { it.forced }?.index - } - - SubtitlePlaybackMode.SMART -> { - if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index - } else { - null - } - } - - SubtitlePlaybackMode.DEFAULT -> { - // TODO check for language? - ( - subtitleStreams.firstOrNull { it.default && it.forced } - ?: subtitleStreams.firstOrNull { it.default } - ?: subtitleStreams.firstOrNull { it.forced } - )?.index - } - - SubtitlePlaybackMode.NONE -> null - } + determineSubtitleIndex( + subtitleStreams = subtitleStreams, + subtitleMode = preferences.userConfig.subtitleMode, + subtitleLanguage = preferences.userConfig.subtitleLanguagePreference, + audioLanguage = audioLanguage, + playbackConfig = playbackConfig, + ) // Timber.v("base.mediaStreams=${base.mediaStreams}") // Timber.v("subtitleTracks=$subtitleStreams") @@ -300,9 +338,11 @@ class PlaybackViewModel changeStreams( base, + playbackConfig, audioIndex, subtitleIndex, if (positionMs > 0) positionMs else C.TIME_UNSET, + itemPlayback != null, // If it was passed in, then it was not queried from the database ) player.prepare() @@ -316,20 +356,26 @@ class PlaybackViewModel @OptIn(UnstableApi::class) private suspend fun changeStreams( item: BaseItemDto, + currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!, audioIndex: Int?, subtitleIndex: Int?, positionMs: Long = C.TIME_UNSET, - ) { + userInitiated: Boolean, + ) = withContext(Dispatchers.IO) { val itemId = item.id - if (currentPlayback.value?.let { - it.itemId == itemId && - it.audioIndex == audioIndex && - it.subtitleIndex == subtitleIndex - } == true - ) { - Timber.i("No change in playback for changeStreams") - return - } + + // TODO +// if (currentItemPlayback.let { +// it.itemId == itemId && +// it.audioIndex == audioIndex && +// it.subtitleIndex == subtitleIndex +// } == true +// ) { +// Timber.i("No change in playback for changeStreams") +// return@withContext +// } + Timber.d("changeStreams: userInitiated=$userInitiated, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") + // TODO if the new audio or subtitle index is already in the streams (eg direct play), should toggle in the player instead val maxBitrate = preferences.appPreferences.playbackPreferences.maxBitrate @@ -348,8 +394,8 @@ class PlaybackViewModel subtitleStreamIndex = subtitleIndex, allowVideoStreamCopy = true, allowAudioStreamCopy = true, - autoOpenLiveStream = true, - mediaSourceId = null, + autoOpenLiveStream = false, + mediaSourceId = currentItemPlayback.sourceId?.toServerString(), alwaysBurnInSubtitleWhenTranscoding = null, maxStreamingBitrate = maxBitrate.toInt(), ), @@ -409,14 +455,26 @@ class PlaybackViewModel val playback = CurrentPlayback( - itemId, - audioIndex, - subtitleIndex, - source.id?.toUUIDOrNull(), listOf(), playMethod = transcodeType, playSessionId = response.playSessionId, ) + val itemPlayback = + currentItemPlayback.copy( + sourceId = source.id?.toUUIDOrNull(), + audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, + subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, + ) + if (userInitiated) { + viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + Timber.v("Saving user initiated item playback: %s", itemPlayback) + val rowId = itemPlaybackDao.saveItem(itemPlayback) + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentItemPlayback.value = + itemPlayback.copy(rowId = rowId) + } + } + } withContext(Dispatchers.Main) { // TODO, don't need to release & recreate when switching streams @@ -429,6 +487,7 @@ class PlaybackViewModel api = api, player = player, playback = playback, + itemPlayback = itemPlayback, ) player.addListener(activityListener) this@PlaybackViewModel.activityListener = activityListener @@ -436,6 +495,7 @@ class PlaybackViewModel duration.value = source.runTimeTicks?.ticks stream.value = decision currentPlayback.value = playback + this@PlaybackViewModel.currentItemPlayback.value = itemPlayback player.setMediaItem( mediaItem, positionMs, @@ -452,10 +512,6 @@ class PlaybackViewModel audioIndex, subtitleIndex, ) - currentPlayback.value = - currentPlayback.value?.copy( - tracks = checkForSupport(tracks), - ) player.removeListener(this) } } @@ -476,32 +532,34 @@ class PlaybackViewModel } fun changeAudioStream(index: Int) { - val itemId = currentPlayback.value?.itemId ?: return viewModelScope.launch(ExceptionHandler()) { changeStreams( dto, + currentItemPlayback.value!!, index, - currentPlayback.value?.subtitleIndex, + currentItemPlayback.value?.subtitleIndex, player.currentPosition, + true, ) } } fun changeSubtitleStream(index: Int?) { - val itemId = currentPlayback.value?.itemId ?: return viewModelScope.launch(ExceptionHandler()) { changeStreams( dto, - currentPlayback.value?.audioIndex, + currentItemPlayback.value!!, + currentItemPlayback.value?.audioIndex, index, player.currentPosition, + true, ) } } fun getTrickplayUrl(index: Int): String? { val itemId = dto.id - val mediaSourceId = currentPlayback.value?.mediaSourceId + val mediaSourceId = currentItemPlayback.value?.sourceId val trickPlayInfo = trickplay.value ?: return null return api.trickplayApi.getTrickplayTileImageUrl( itemId, @@ -643,13 +701,16 @@ class PlaybackViewModel } } } + + override fun onTracksChanged(tracks: Tracks) { + currentPlayback.value = + currentPlayback.value?.copy( + tracks = checkForSupport(tracks), + ) + } } data class CurrentPlayback( - val itemId: UUID, - val audioIndex: Int?, - val subtitleIndex: Int?, - val mediaSourceId: UUID?, val tracks: List, val playMethod: PlayMethod, val playSessionId: String?, @@ -728,3 +789,53 @@ private fun applyTrackSelections( } } } + +private fun determineSubtitleIndex( + subtitleStreams: List, + subtitleMode: SubtitlePlaybackMode, + audioLanguage: String?, + subtitleLanguage: String?, + playbackConfig: ItemPlayback, +): Int? { + if (playbackConfig.subtitleIndex == TrackIndex.DISABLED) { + return null + } else if (playbackConfig.subtitleIndexEnabled) { + return playbackConfig.subtitleIndex + } else { + return when (subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage != null) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index + } else { + subtitleStreams.firstOrNull()?.index + } + } + + SubtitlePlaybackMode.ONLY_FORCED -> + if (subtitleLanguage != null) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index + } else { + subtitleStreams.firstOrNull { it.forced }?.index + } + + SubtitlePlaybackMode.SMART -> { + if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index + } else { + null + } + } + + SubtitlePlaybackMode.DEFAULT -> { + // TODO check for language? + ( + subtitleStreams.firstOrNull { it.default && it.forced } + ?: subtitleStreams.firstOrNull { it.default } + ?: subtitleStreams.firstOrNull { it.forced } + )?.index + } + + SubtitlePlaybackMode.NONE -> null + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index ea174fb2..1f35436f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -28,6 +28,7 @@ import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import java.util.UUID sealed interface ServerConnectionStatus { object Success : ServerConnectionStatus @@ -45,7 +46,7 @@ sealed interface ServerConnectionStatus { @Composable fun ServerList( servers: List, - connectionStatus: Map, + connectionStatus: Map, onSwitchServer: (JellyfinServer) -> Unit, onAddServer: () -> Unit, onRemoveServer: (JellyfinServer) -> Unit, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index a03decfd..67694b8d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -25,7 +25,10 @@ import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectResult +import org.jellyfin.sdk.model.serializer.toUUID +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber +import java.util.UUID import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -39,8 +42,8 @@ class SwitchUserViewModel val navigationManager: NavigationManager, ) : ViewModel() { val servers = MutableLiveData>(listOf()) - val serverStatus = MutableLiveData>(mapOf()) - val serverQuickConnect = MutableLiveData>(mapOf()) + val serverStatus = MutableLiveData>(mapOf()) + val serverQuickConnect = MutableLiveData>(mapOf()) val users = MutableLiveData>(listOf()) val quickConnectState = MutableLiveData(null) @@ -245,7 +248,7 @@ class SwitchUserViewModel .createApi(serverUrl) .systemApi .getPublicSystemInfo() - val id = serverInfo.id + val id = serverInfo.id?.toUUIDOrNull() if (id != null && serverInfo.startupWizardCompleted == true) { serverRepository.addAndChangeServer( JellyfinServer( @@ -310,7 +313,15 @@ class SwitchUserViewModel val newServerList = discoveredServers.value!! .toMutableList() - .apply { add(JellyfinServer(server.id, server.name, server.address)) } + .apply { + add( + JellyfinServer( + server.id.toUUID(), + server.name, + server.address, + ), + ) + } withContext(Dispatchers.Main) { discoveredServers.value = newServerList } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index d40ad745..777e0032 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -46,7 +46,7 @@ fun UserList( ListItem( enabled = true, selected = user == currentUser, - headlineContent = { Text(text = user.name ?: user.id) }, + headlineContent = { Text(text = user.name ?: user.id.toString()) }, leadingContent = { if (user.id == currentUser?.id) { Icon( @@ -99,7 +99,7 @@ fun UserList( showDeleteDialog?.let { user -> DialogPopup( showDialog = true, - title = user.name ?: user.id, + title = user.name ?: user.id.toString(), dialogItems = listOf( DialogItem("Switch", R.string.fa_arrow_left_arrow_right) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt index 23c3e0e3..be4dfa2a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Formatting.kt @@ -1,6 +1,10 @@ package com.github.damontecres.wholphin.util import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaStream @@ -60,3 +64,25 @@ fun formatSubtitleLang(mediaStreams: List?): String? = ?.mapNotNull { it.language } ?.distinct() ?.joinToString(", ") { languageName(it) } + +fun getAudioDisplay( + item: BaseItemDto, + chosenStreams: ChosenStreams?, +) = ( + chosenStreams?.audioStream + ?: item.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO } +)?.displayTitle + ?.replace(" - Default", "") + ?.ifBlank { null } + +@Composable +fun getSubtitleDisplay( + item: BaseItemDto, + chosenStreams: ChosenStreams?, +) = if (chosenStreams?.subtitlesDisabled == true) { + stringResource(R.string.disabled) +} else if (chosenStreams?.subtitleStream != null) { + languageName(chosenStreams.subtitleStream.language) +} else { + formatSubtitleLang(item.mediaStreams) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index e797b296..946db132 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util import androidx.annotation.OptIn import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -32,6 +33,7 @@ class TrackActivityPlaybackListener( private val api: ApiClient, private val player: Player, var playback: CurrentPlayback, + var itemPlayback: ItemPlayback, ) : Player.Listener { private val coroutineScope = CoroutineScope(Dispatchers.Main) private val task: TimerTask @@ -46,14 +48,14 @@ class TrackActivityPlaybackListener( api.playStateApi.reportPlaybackStart( PlaybackStartInfo( canSeek = true, - itemId = playback.itemId, + itemId = itemPlayback.itemId, isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, playMethod = playback.playMethod, repeatMode = RepeatMode.REPEAT_NONE, playbackOrder = PlaybackOrder.DEFAULT, isMuted = false, - audioStreamIndex = playback.audioIndex, - subtitleStreamIndex = playback.subtitleIndex, + audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, + subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, ), ) } @@ -93,7 +95,7 @@ class TrackActivityPlaybackListener( coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( - itemId = playback.itemId, + itemId = itemPlayback.itemId, positionTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks }, failed = false, playSessionId = playback.playSessionId, @@ -130,7 +132,7 @@ class TrackActivityPlaybackListener( // Timber.v("saveActivity: itemId=$itemId, pos=$calcPosition") api.playStateApi.reportPlaybackProgress( PlaybackProgressInfo( - itemId = playback.itemId, + itemId = itemPlayback.itemId, positionTicks = calcPosition.inWholeTicks, canSeek = true, isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, @@ -139,8 +141,8 @@ class TrackActivityPlaybackListener( repeatMode = RepeatMode.REPEAT_NONE, playbackOrder = PlaybackOrder.DEFAULT, playSessionId = playback.playSessionId, - audioStreamIndex = playback.audioIndex, - subtitleStreamIndex = playback.subtitleIndex, + audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, + subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, ), ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac4aa01f..8e585bdb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ hilt = "2.57.2" room = "2.8.2" material3 = "1.4.0" preferenceKtx = "1.2.1" +roomTesting = "2.8.2" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -89,6 +90,7 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "roomTesting" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }