From 6194eba8c57f077e65064a133fddd33796ab02b4 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Fri, 17 Oct 2025 17:19:44 -0400 Subject: [PATCH] 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. --- app/build.gradle.kts | 2 + .../2.json | 110 ++++++++ .../3.json | 125 +++++++++ .../4.json | 197 +++++++++++++ .../wholphin/test/TestDbMigrations.kt | 117 ++++++++ .../damontecres/wholphin/MainActivity.kt | 7 +- .../damontecres/wholphin/data/AppDatabase.kt | 59 +++- .../wholphin/data/ItemPlaybackDao.kt | 25 ++ .../wholphin/data/ItemPlaybackRepository.kt | 62 +++++ .../wholphin/data/JellyfinServer.kt | 35 ++- .../wholphin/data/ServerRepository.kt | 22 +- .../wholphin/data/model/ItemPlayback.kt | 52 ++++ .../wholphin/hilt/DatabaseModule.kt | 11 +- .../damontecres/wholphin/ui/Extensions.kt | 9 + .../wholphin/ui/detail/SeriesViewModel.kt | 22 ++ .../wholphin/ui/detail/movie/MovieDetails.kt | 16 ++ .../ui/detail/movie/MovieDetailsHeader.kt | 32 +-- .../ui/detail/series/FocusedEpisodeFooter.kt | 33 +-- .../ui/detail/series/SeriesOverview.kt | 12 + .../ui/detail/series/SeriesOverviewContent.kt | 3 + .../wholphin/ui/nav/Destination.kt | 2 + .../wholphin/ui/playback/PlaybackControls.kt | 3 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 8 +- .../wholphin/ui/playback/PlaybackPage.kt | 11 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 261 +++++++++++++----- .../wholphin/ui/setup/ServerList.kt | 3 +- .../wholphin/ui/setup/SwitchUserViewModel.kt | 19 +- .../damontecres/wholphin/ui/setup/UserList.kt | 4 +- .../damontecres/wholphin/util/Formatting.kt | 26 ++ .../util/TrackActivityPlaybackListener.kt | 16 +- gradle/libs.versions.toml | 2 + 31 files changed, 1152 insertions(+), 154 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/2.json create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/3.json create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/4.json create mode 100644 app/src/androidTest/java/com/github/damontecres/wholphin/test/TestDbMigrations.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt 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" }