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

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

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

View file

@ -56,6 +56,7 @@ android {
targetSdk = 36 targetSdk = 36
versionCode = getVersionCode() versionCode = getVersionCode()
versionName = getAppVersion() versionName = getAppVersion()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
@ -217,6 +218,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.preference.ktx) implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.room.testing)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)

View file

@ -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')"
]
}
}

View file

@ -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')"
]
}
}

View file

@ -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')"
]
}
}

View file

@ -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))
}
}
}

View file

@ -43,6 +43,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -91,11 +92,11 @@ class MainActivity : AppCompatActivity() {
) { ) {
var isRestoringSession by remember { mutableStateOf(true) } var isRestoringSession by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (appPreferences.currentServerId.isNotBlank()) { if (appPreferences.currentServerId.isNotBlank() && appPreferences.currentUserId.isNotBlank()) {
try { try {
serverRepository.restoreSession( serverRepository.restoreSession(
appPreferences.currentServerId, appPreferences.currentServerId?.toUUIDOrNull(),
appPreferences.currentUserId, appPreferences.currentUserId?.toUUIDOrNull(),
) )
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Exception restoring session") Timber.e(ex, "Exception restoring session")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,9 @@ import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile import androidx.datastore.dataStoreFile
import androidx.room.Room import androidx.room.Room
import com.github.damontecres.wholphin.data.AppDatabase 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.JellyfinServerDao
import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import dagger.Module import dagger.Module
@ -30,13 +32,20 @@ object DatabaseModule {
context, context,
AppDatabase::class.java, AppDatabase::class.java,
"wholphin", "wholphin",
).fallbackToDestructiveMigration(false) ).addMigrations(Migrations.Migrate2to3)
// .setQueryCallback({ sqlQuery, args ->
// Timber.v("sqlQuery=$sqlQuery, args=$args")
// }, Dispatchers.IO.asExecutor())
.build() .build()
@Provides @Provides
@Singleton @Singleton
fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao() fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao()
@Provides
@Singleton
fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(

View file

@ -40,6 +40,7 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.util.UUID
import kotlin.contracts.ExperimentalContracts import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind import kotlin.contracts.InvocationKind
import kotlin.contracts.contract import kotlin.contracts.contract
@ -351,3 +352,11 @@ fun CoroutineScope.launchIO(
start: CoroutineStart = CoroutineStart.DEFAULT, start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit, block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = Dispatchers.IO + context, start = start, block = block) ): 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("-", "")

View file

@ -11,11 +11,14 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory 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.BaseItem
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.preferences.UserPreferences 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.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -57,6 +61,7 @@ class SeriesViewModel
@param:ApplicationContext val context: Context, @param:ApplicationContext val context: Context,
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient, @param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val itemPlaybackRepository: ItemPlaybackRepository,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
private var player: Player? = null private var player: Player? = null
private lateinit var seriesId: UUID private lateinit var seriesId: UUID
@ -306,6 +311,23 @@ class SeriesViewModel
release() release()
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
val chosenStreams = MutableLiveData<ChosenStreams?>(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( data class ItemListAndMapping(

View file

@ -36,6 +36,8 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage 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.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Person 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.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel
import com.github.damontecres.wholphin.ui.isNotNullOrBlank 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.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager 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.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -75,10 +79,12 @@ class MovieViewModel
constructor( constructor(
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val itemPlaybackRepository: ItemPlaybackRepository,
) : LoadingItemViewModel(api) { ) : LoadingItemViewModel(api) {
private lateinit var itemId: UUID private lateinit var itemId: UUID
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf()) val chapters = MutableLiveData<List<Chapter>>(listOf())
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
override fun init( override fun init(
itemId: UUID, itemId: UUID,
@ -88,6 +94,12 @@ class MovieViewModel
return viewModelScope.launch(ExceptionHandler()) { return viewModelScope.launch(ExceptionHandler()) {
super.init(itemId, potential)?.join() super.init(itemId, potential)?.join()
item.value?.let { item -> item.value?.let { item ->
viewModelScope.launchIO {
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
withContext(Dispatchers.Main) {
chosenStreams.value = result
}
}
people.value = people.value =
item.data.people item.data.people
?.letNotEmpty { people -> ?.letNotEmpty { people ->
@ -123,6 +135,7 @@ fun MovieDetails(
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val chapters by viewModel.chapters.observeAsState(listOf()) val chapters by viewModel.chapters.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -137,6 +150,7 @@ fun MovieDetails(
MovieDetailsContent( MovieDetailsContent(
preferences = preferences, preferences = preferences,
movie = movie, movie = movie,
chosenStreams = chosenStreams,
people = people, people = people,
chapters = chapters, chapters = chapters,
playOnClick = { playOnClick = {
@ -222,6 +236,7 @@ fun MovieDetails(
fun MovieDetailsContent( fun MovieDetailsContent(
preferences: UserPreferences, preferences: UserPreferences,
movie: BaseItem, movie: BaseItem,
chosenStreams: ChosenStreams?,
people: List<Person>, people: List<Person>,
chapters: List<Chapter>, chapters: List<Chapter>,
playOnClick: (Duration) -> Unit, playOnClick: (Duration) -> Unit,
@ -286,6 +301,7 @@ fun MovieDetailsContent(
) { ) {
MovieDetailsHeader( MovieDetailsHeader(
movie = movie, movie = movie,
chosenStreams = chosenStreams,
bringIntoViewRequester = bringIntoViewRequester, bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick, overviewOnClick = overviewOnClick,
Modifier Modifier

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R 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.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.OverviewText 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.timeRemaining 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.MediaStreamType
import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -41,6 +44,7 @@ import org.jellyfin.sdk.model.extensions.ticks
@Composable @Composable
fun MovieDetailsHeader( fun MovieDetailsHeader(
movie: BaseItem, movie: BaseItem,
chosenStreams: ChosenStreams?,
bringIntoViewRequester: BringIntoViewRequester, bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -144,24 +148,18 @@ fun MovieDetailsHeader(
modifier = Modifier.widthIn(max = 200.dp), modifier = Modifier.widthIn(max = 200.dp),
) )
} }
dto.mediaStreams val audioDisplay =
?.firstOrNull { it.type == MediaStreamType.AUDIO } remember(movie.id, chosenStreams) { getAudioDisplay(movie.data, chosenStreams) }
?.displayTitle audioDisplay
?.let { ?.let {
// TODO probably a cleaner way to do this TitleValueText(
// Removes part of "5.1 Surround - English - AAC - Default" stringResource(R.string.audio),
it it,
.replace(" - Default", "") modifier = Modifier.widthIn(max = 200.dp),
.ifBlank { null } )
?.let {
TitleValueText(
stringResource(R.string.audio),
it,
modifier = Modifier.widthIn(max = 200.dp),
)
}
} }
formatSubtitleLang(dto.mediaStreams)
getSubtitleDisplay(movie.data, chosenStreams)
?.let { ?.let {
if (it.isNotNullOrBlank()) { if (it.isNotNullOrBlank()) {
TitleValueText( TitleValueText(

View file

@ -11,11 +11,13 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R 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.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.TitleValueText import com.github.damontecres.wholphin.ui.components.TitleValueText
import com.github.damontecres.wholphin.ui.isNotNullOrBlank 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.api.MediaStreamType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration import kotlin.time.Duration
@ -23,6 +25,7 @@ import kotlin.time.Duration
@Composable @Composable
fun FocusedEpisodeFooter( fun FocusedEpisodeFooter(
ep: BaseItem, ep: BaseItem,
chosenStreams: ChosenStreams?,
playOnClick: (Duration) -> Unit, playOnClick: (Duration) -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
@ -58,25 +61,17 @@ fun FocusedEpisodeFooter(
it, it,
) )
} }
val audioDisplay =
remember(ep.id, chosenStreams) { getAudioDisplay(ep.data, chosenStreams) }
audioDisplay?.let {
TitleValueText(
stringResource(R.string.audio),
it,
)
}
dto.mediaStreams val subtitleText = getSubtitleDisplay(ep.data, chosenStreams)
?.firstOrNull { it.type == MediaStreamType.AUDIO } subtitleText
?.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)
?.let { ?.let {
if (it.isNotNullOrBlank()) { if (it.isNotNullOrBlank()) {
TitleValueText( TitleValueText(

View file

@ -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) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
@ -130,6 +141,7 @@ fun SeriesOverview(
series = series, series = series,
seasons = seasons.items, seasons = seasons.items,
episodes = episodes, episodes = episodes,
chosenStreams = chosenStreams,
position = position, position = position,
backdropImageUrl = backdropImageUrl =
remember { remember {

View file

@ -42,6 +42,7 @@ import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage 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.BaseItem
import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -60,6 +61,7 @@ fun SeriesOverviewContent(
series: BaseItem, series: BaseItem,
seasons: List<BaseItem?>, seasons: List<BaseItem?>,
episodes: EpisodeList, episodes: EpisodeList,
chosenStreams: ChosenStreams?,
position: SeriesOverviewPosition, position: SeriesOverviewPosition,
backdropImageUrl: String?, backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester, firstItemFocusRequester: FocusRequester,
@ -281,6 +283,7 @@ fun SeriesOverviewContent(
focusedEpisode?.let { ep -> focusedEpisode?.let { ep ->
FocusedEpisodeFooter( FocusedEpisodeFooter(
ep = ep, ep = ep,
chosenStreams = chosenStreams,
playOnClick = playOnClick, playOnClick = playOnClick,
moreOnClick = moreOnClick, moreOnClick = moreOnClick,
watchOnClick = watchOnClick, watchOnClick = watchOnClick,

View file

@ -5,6 +5,7 @@ package com.github.damontecres.wholphin.ui.nav
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.util.UuidSerializer import com.github.damontecres.wholphin.util.UuidSerializer
@ -70,6 +71,7 @@ sealed class Destination(
@Transient val item: BaseItem? = null, @Transient val item: BaseItem? = null,
val startIndex: Int? = null, val startIndex: Int? = null,
val shuffle: Boolean = false, val shuffle: Boolean = false,
val itemPlayback: ItemPlayback? = null,
) : Destination(true) { ) : Destination(true) {
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"

View file

@ -56,6 +56,7 @@ import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R 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.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekBack
@ -398,7 +399,7 @@ fun RightPlaybackButtons(
onSelectChoice = { index, _ -> onSelectChoice = { index, _ ->
val send = val send =
if (index == 0) { if (index == 0) {
-1 TrackIndex.DISABLED
} else { } else {
subtitleStreams[index - 1].index subtitleStreams[index - 1].index
} }

View file

@ -45,6 +45,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter 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.Playlist
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.cards.ChapterCard
@ -84,6 +85,7 @@ fun PlaybackOverlay(
playbackSpeed: Float, playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions, moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?, currentPlayback: CurrentPlayback?,
currentItemPlayback: ItemPlayback,
audioStreams: List<AudioStream>, audioStreams: List<AudioStream>,
currentSegment: MediaSegmentDto?, currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -156,6 +158,7 @@ fun PlaybackOverlay(
playbackSpeed = playbackSpeed, playbackSpeed = playbackSpeed,
moreButtonOptions = moreButtonOptions, moreButtonOptions = moreButtonOptions,
currentPlayback = currentPlayback, currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioStreams = audioStreams, audioStreams = audioStreams,
subtitle = subtitle, subtitle = subtitle,
seekBarInteractionSource = seekBarInteractionSource, seekBarInteractionSource = seekBarInteractionSource,
@ -414,6 +417,7 @@ fun Controller(
playbackSpeed: Float, playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions, moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?, currentPlayback: CurrentPlayback?,
currentItemPlayback: ItemPlayback,
audioStreams: List<AudioStream>, audioStreams: List<AudioStream>,
nextState: OverlayViewState?, nextState: OverlayViewState?,
currentSegment: MediaSegmentDto?, currentSegment: MediaSegmentDto?,
@ -461,8 +465,8 @@ fun Controller(
seekEnabled = seekEnabled, seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource, seekBarInteractionSource = seekBarInteractionSource,
moreButtonOptions = moreButtonOptions, moreButtonOptions = moreButtonOptions,
subtitleIndex = currentPlayback?.subtitleIndex, subtitleIndex = currentItemPlayback.subtitleIndex,
audioIndex = currentPlayback?.audioIndex, audioIndex = currentItemPlayback.audioIndex,
audioStreams = audioStreams, audioStreams = audioStreams,
playbackSpeed = playbackSpeed, playbackSpeed = playbackSpeed,
scale = scale, scale = scale,

View file

@ -56,6 +56,7 @@ import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation 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.data.model.Playlist
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
@ -67,6 +68,7 @@ import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -99,6 +101,12 @@ fun PlaybackPage(
val trickplay by viewModel.trickplay.observeAsState(null) val trickplay by viewModel.trickplay.observeAsState(null)
val chapters by viewModel.chapters.observeAsState(listOf()) val chapters by viewModel.chapters.observeAsState(listOf())
val currentPlayback by viewModel.currentPlayback.observeAsState(null) 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) val currentSegment by viewModel.currentSegment.observeAsState(null)
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
@ -318,6 +326,7 @@ fun PlaybackPage(
playbackSpeed = playbackSpeed, playbackSpeed = playbackSpeed,
moreButtonOptions = MoreButtonOptions(mapOf()), moreButtonOptions = MoreButtonOptions(mapOf()),
currentPlayback = currentPlayback, currentPlayback = currentPlayback,
currentItemPlayback = currentItemPlayback,
audioStreams = audioStreams, audioStreams = audioStreams,
trickplayInfo = trickplay, trickplayInfo = trickplay,
trickplayUrlFor = viewModel::getTrickplayUrl, trickplayUrlFor = viewModel::getTrickplayUrl,
@ -331,7 +340,7 @@ fun PlaybackPage(
} }
// Subtitles // Subtitles
if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentPlayback?.subtitleIndex != null) { if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
AndroidView( AndroidView(
factory = { context -> factory = { context ->
SubtitleView(context).apply { SubtitleView(context).apply {

View file

@ -15,16 +15,21 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer 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.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter 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.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistCreator 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.AppPreference
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.showToast 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.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
@ -88,7 +93,10 @@ class PlaybackViewModel
val api: ApiClient, val api: ApiClient,
val playlistCreator: PlaylistCreator, val playlistCreator: PlaylistCreator,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { val itemPlaybackDao: ItemPlaybackDao,
val serverRepository: ServerRepository,
) : ViewModel(),
Player.Listener {
val player: ExoPlayer = val player: ExoPlayer =
ExoPlayer ExoPlayer
.Builder(context) .Builder(context)
@ -105,6 +113,7 @@ class PlaybackViewModel
val audioStreams = MutableLiveData<List<AudioStream>>(listOf()) val audioStreams = MutableLiveData<List<AudioStream>>(listOf())
val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf()) val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf())
val currentPlayback = MutableLiveData<CurrentPlayback?>(null) val currentPlayback = MutableLiveData<CurrentPlayback?>(null)
val currentItemPlayback = MutableLiveData<ItemPlayback>()
val trickplay = MutableLiveData<TrickplayInfo?>(null) val trickplay = MutableLiveData<TrickplayInfo?>(null)
val chapters = MutableLiveData<List<Chapter>>(listOf()) val chapters = MutableLiveData<List<Chapter>>(listOf())
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null) val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
@ -121,7 +130,14 @@ class PlaybackViewModel
val playlist = MutableLiveData<Playlist>(Playlist(listOf())) val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
init { 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() } addCloseable { player.release() }
} }
@ -156,7 +172,7 @@ class PlaybackViewModel
queriedItem queriedItem
} }
val played = play(base, destination.positionMs) val played = play(base, destination.positionMs, destination.itemPlayback)
if (!played) { if (!played) {
playUpNextUp() playUpNextUp()
} }
@ -175,6 +191,7 @@ class PlaybackViewModel
private suspend fun play( private suspend fun play(
base: BaseItemDto, base: BaseItemDto,
positionMs: Long, positionMs: Long,
itemPlayback: ItemPlayback? = null,
): Boolean = ): Boolean =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Timber.i("Playing ${base.id}") Timber.i("Playing ${base.id}")
@ -186,6 +203,38 @@ class PlaybackViewModel
) )
return@withContext false 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 dto = base
val title = val title =
if (base.type == BaseItemKind.EPISODE) { if (base.type == BaseItemKind.EPISODE) {
@ -206,12 +255,28 @@ class PlaybackViewModel
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.title.value = title this@PlaybackViewModel.title.value = title
this@PlaybackViewModel.subtitle.value = subtitle this@PlaybackViewModel.subtitle.value = subtitle
this@PlaybackViewModel.currentItemPlayback.value = playbackConfig
} }
base.mediaStreams val mediaSource =
?.filter { it.type == MediaStreamType.VIDEO } if (playbackConfig.sourceId != null) {
?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } 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 = val subtitleStreams =
base.mediaStreams mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE } ?.filter { it.type == MediaStreamType.SUBTITLE }
?.map { ?.map {
SubtitleStream( SubtitleStream(
@ -226,7 +291,7 @@ class PlaybackViewModel
) )
}.orEmpty() }.orEmpty()
val audioStreams = val audioStreams =
base.mediaStreams mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO } ?.filter { it.type == MediaStreamType.AUDIO }
?.map { ?.map {
AudioStream( AudioStream(
@ -244,50 +309,23 @@ class PlaybackViewModel
// TODO audio selection based on channel layout or preferences or default // TODO audio selection based on channel layout or preferences or default
val audioLanguage = preferences.userConfig.audioLanguagePreference val audioLanguage = preferences.userConfig.audioLanguagePreference
val audioIndex = val audioIndex =
if (audioLanguage != null) { if (playbackConfig.audioIndexEnabled) {
playbackConfig.audioIndex
} else if (audioLanguage != null) {
audioStreams.firstOrNull { it.language == audioLanguage }?.index audioStreams.firstOrNull { it.language == audioLanguage }?.index
?: audioStreams.firstOrNull()?.index ?: audioStreams.firstOrNull()?.index
} else { } else {
audioStreams.firstOrNull()?.index audioStreams.firstOrNull()?.index
} }
val subtitleMode = preferences.userConfig.subtitleMode
val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference
val subtitleIndex = val subtitleIndex =
when (subtitleMode) { determineSubtitleIndex(
SubtitlePlaybackMode.ALWAYS -> { subtitleStreams = subtitleStreams,
if (subtitleLanguage != null) { subtitleMode = preferences.userConfig.subtitleMode,
subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index subtitleLanguage = preferences.userConfig.subtitleLanguagePreference,
} else { audioLanguage = audioLanguage,
subtitleStreams.firstOrNull()?.index playbackConfig = playbackConfig,
} )
}
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
}
// Timber.v("base.mediaStreams=${base.mediaStreams}") // Timber.v("base.mediaStreams=${base.mediaStreams}")
// Timber.v("subtitleTracks=$subtitleStreams") // Timber.v("subtitleTracks=$subtitleStreams")
@ -300,9 +338,11 @@ class PlaybackViewModel
changeStreams( changeStreams(
base, base,
playbackConfig,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
if (positionMs > 0) positionMs else C.TIME_UNSET, 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() player.prepare()
@ -316,20 +356,26 @@ class PlaybackViewModel
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun changeStreams( private suspend fun changeStreams(
item: BaseItemDto, item: BaseItemDto,
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
positionMs: Long = C.TIME_UNSET, positionMs: Long = C.TIME_UNSET,
) { userInitiated: Boolean,
) = withContext(Dispatchers.IO) {
val itemId = item.id val itemId = item.id
if (currentPlayback.value?.let {
it.itemId == itemId && // TODO
it.audioIndex == audioIndex && // if (currentItemPlayback.let {
it.subtitleIndex == subtitleIndex // it.itemId == itemId &&
} == true // it.audioIndex == audioIndex &&
) { // it.subtitleIndex == subtitleIndex
Timber.i("No change in playback for changeStreams") // } == true
return // ) {
} // 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 // TODO if the new audio or subtitle index is already in the streams (eg direct play), should toggle in the player instead
val maxBitrate = val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate preferences.appPreferences.playbackPreferences.maxBitrate
@ -348,8 +394,8 @@ class PlaybackViewModel
subtitleStreamIndex = subtitleIndex, subtitleStreamIndex = subtitleIndex,
allowVideoStreamCopy = true, allowVideoStreamCopy = true,
allowAudioStreamCopy = true, allowAudioStreamCopy = true,
autoOpenLiveStream = true, autoOpenLiveStream = false,
mediaSourceId = null, mediaSourceId = currentItemPlayback.sourceId?.toServerString(),
alwaysBurnInSubtitleWhenTranscoding = null, alwaysBurnInSubtitleWhenTranscoding = null,
maxStreamingBitrate = maxBitrate.toInt(), maxStreamingBitrate = maxBitrate.toInt(),
), ),
@ -409,14 +455,26 @@ class PlaybackViewModel
val playback = val playback =
CurrentPlayback( CurrentPlayback(
itemId,
audioIndex,
subtitleIndex,
source.id?.toUUIDOrNull(),
listOf(), listOf(),
playMethod = transcodeType, playMethod = transcodeType,
playSessionId = response.playSessionId, 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) { withContext(Dispatchers.Main) {
// TODO, don't need to release & recreate when switching streams // TODO, don't need to release & recreate when switching streams
@ -429,6 +487,7 @@ class PlaybackViewModel
api = api, api = api,
player = player, player = player,
playback = playback, playback = playback,
itemPlayback = itemPlayback,
) )
player.addListener(activityListener) player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener this@PlaybackViewModel.activityListener = activityListener
@ -436,6 +495,7 @@ class PlaybackViewModel
duration.value = source.runTimeTicks?.ticks duration.value = source.runTimeTicks?.ticks
stream.value = decision stream.value = decision
currentPlayback.value = playback currentPlayback.value = playback
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
player.setMediaItem( player.setMediaItem(
mediaItem, mediaItem,
positionMs, positionMs,
@ -452,10 +512,6 @@ class PlaybackViewModel
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
) )
currentPlayback.value =
currentPlayback.value?.copy(
tracks = checkForSupport(tracks),
)
player.removeListener(this) player.removeListener(this)
} }
} }
@ -476,32 +532,34 @@ class PlaybackViewModel
} }
fun changeAudioStream(index: Int) { fun changeAudioStream(index: Int) {
val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
changeStreams( changeStreams(
dto, dto,
currentItemPlayback.value!!,
index, index,
currentPlayback.value?.subtitleIndex, currentItemPlayback.value?.subtitleIndex,
player.currentPosition, player.currentPosition,
true,
) )
} }
} }
fun changeSubtitleStream(index: Int?) { fun changeSubtitleStream(index: Int?) {
val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
changeStreams( changeStreams(
dto, dto,
currentPlayback.value?.audioIndex, currentItemPlayback.value!!,
currentItemPlayback.value?.audioIndex,
index, index,
player.currentPosition, player.currentPosition,
true,
) )
} }
} }
fun getTrickplayUrl(index: Int): String? { fun getTrickplayUrl(index: Int): String? {
val itemId = dto.id val itemId = dto.id
val mediaSourceId = currentPlayback.value?.mediaSourceId val mediaSourceId = currentItemPlayback.value?.sourceId
val trickPlayInfo = trickplay.value ?: return null val trickPlayInfo = trickplay.value ?: return null
return api.trickplayApi.getTrickplayTileImageUrl( return api.trickplayApi.getTrickplayTileImageUrl(
itemId, itemId,
@ -643,13 +701,16 @@ class PlaybackViewModel
} }
} }
} }
override fun onTracksChanged(tracks: Tracks) {
currentPlayback.value =
currentPlayback.value?.copy(
tracks = checkForSupport(tracks),
)
}
} }
data class CurrentPlayback( data class CurrentPlayback(
val itemId: UUID,
val audioIndex: Int?,
val subtitleIndex: Int?,
val mediaSourceId: UUID?,
val tracks: List<TrackSupport>, val tracks: List<TrackSupport>,
val playMethod: PlayMethod, val playMethod: PlayMethod,
val playSessionId: String?, val playSessionId: String?,
@ -728,3 +789,53 @@ private fun applyTrackSelections(
} }
} }
} }
private fun determineSubtitleIndex(
subtitleStreams: List<SubtitleStream>,
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
}
}
}

View file

@ -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.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import java.util.UUID
sealed interface ServerConnectionStatus { sealed interface ServerConnectionStatus {
object Success : ServerConnectionStatus object Success : ServerConnectionStatus
@ -45,7 +46,7 @@ sealed interface ServerConnectionStatus {
@Composable @Composable
fun ServerList( fun ServerList(
servers: List<JellyfinServer>, servers: List<JellyfinServer>,
connectionStatus: Map<String, ServerConnectionStatus>, connectionStatus: Map<UUID, ServerConnectionStatus>,
onSwitchServer: (JellyfinServer) -> Unit, onSwitchServer: (JellyfinServer) -> Unit,
onAddServer: () -> Unit, onAddServer: () -> Unit,
onRemoveServer: (JellyfinServer) -> Unit, onRemoveServer: (JellyfinServer) -> Unit,

View file

@ -25,7 +25,10 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectDto
import org.jellyfin.sdk.model.api.QuickConnectResult 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 timber.log.Timber
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -39,8 +42,8 @@ class SwitchUserViewModel
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf()) val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<String, ServerConnectionStatus>>(mapOf()) val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
val serverQuickConnect = MutableLiveData<Map<String, Boolean>>(mapOf()) val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf())
val users = MutableLiveData<List<JellyfinUser>>(listOf()) val users = MutableLiveData<List<JellyfinUser>>(listOf())
val quickConnectState = MutableLiveData<QuickConnectResult?>(null) val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
@ -245,7 +248,7 @@ class SwitchUserViewModel
.createApi(serverUrl) .createApi(serverUrl)
.systemApi .systemApi
.getPublicSystemInfo() .getPublicSystemInfo()
val id = serverInfo.id val id = serverInfo.id?.toUUIDOrNull()
if (id != null && serverInfo.startupWizardCompleted == true) { if (id != null && serverInfo.startupWizardCompleted == true) {
serverRepository.addAndChangeServer( serverRepository.addAndChangeServer(
JellyfinServer( JellyfinServer(
@ -310,7 +313,15 @@ class SwitchUserViewModel
val newServerList = val newServerList =
discoveredServers.value!! discoveredServers.value!!
.toMutableList() .toMutableList()
.apply { add(JellyfinServer(server.id, server.name, server.address)) } .apply {
add(
JellyfinServer(
server.id.toUUID(),
server.name,
server.address,
),
)
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
discoveredServers.value = newServerList discoveredServers.value = newServerList
} }

View file

@ -46,7 +46,7 @@ fun UserList(
ListItem( ListItem(
enabled = true, enabled = true,
selected = user == currentUser, selected = user == currentUser,
headlineContent = { Text(text = user.name ?: user.id) }, headlineContent = { Text(text = user.name ?: user.id.toString()) },
leadingContent = { leadingContent = {
if (user.id == currentUser?.id) { if (user.id == currentUser?.id) {
Icon( Icon(
@ -99,7 +99,7 @@ fun UserList(
showDeleteDialog?.let { user -> showDeleteDialog?.let { user ->
DialogPopup( DialogPopup(
showDialog = true, showDialog = true,
title = user.name ?: user.id, title = user.name ?: user.id.toString(),
dialogItems = dialogItems =
listOf( listOf(
DialogItem("Switch", R.string.fa_arrow_left_arrow_right) { DialogItem("Switch", R.string.fa_arrow_left_arrow_right) {

View file

@ -1,6 +1,10 @@
package com.github.damontecres.wholphin.util package com.github.damontecres.wholphin.util
import android.os.Build 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 com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
@ -60,3 +64,25 @@ fun formatSubtitleLang(mediaStreams: List<MediaStream>?): String? =
?.mapNotNull { it.language } ?.mapNotNull { it.language }
?.distinct() ?.distinct()
?.joinToString(", ") { languageName(it) } ?.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)
}

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -32,6 +33,7 @@ class TrackActivityPlaybackListener(
private val api: ApiClient, private val api: ApiClient,
private val player: Player, private val player: Player,
var playback: CurrentPlayback, var playback: CurrentPlayback,
var itemPlayback: ItemPlayback,
) : Player.Listener { ) : Player.Listener {
private val coroutineScope = CoroutineScope(Dispatchers.Main) private val coroutineScope = CoroutineScope(Dispatchers.Main)
private val task: TimerTask private val task: TimerTask
@ -46,14 +48,14 @@ class TrackActivityPlaybackListener(
api.playStateApi.reportPlaybackStart( api.playStateApi.reportPlaybackStart(
PlaybackStartInfo( PlaybackStartInfo(
canSeek = true, canSeek = true,
itemId = playback.itemId, itemId = itemPlayback.itemId,
isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
playMethod = playback.playMethod, playMethod = playback.playMethod,
repeatMode = RepeatMode.REPEAT_NONE, repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT, playbackOrder = PlaybackOrder.DEFAULT,
isMuted = false, isMuted = false,
audioStreamIndex = playback.audioIndex, audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
subtitleStreamIndex = playback.subtitleIndex, subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
), ),
) )
} }
@ -93,7 +95,7 @@ class TrackActivityPlaybackListener(
coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) {
api.playStateApi.reportPlaybackStopped( api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo( PlaybackStopInfo(
itemId = playback.itemId, itemId = itemPlayback.itemId,
positionTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks }, positionTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks },
failed = false, failed = false,
playSessionId = playback.playSessionId, playSessionId = playback.playSessionId,
@ -130,7 +132,7 @@ class TrackActivityPlaybackListener(
// Timber.v("saveActivity: itemId=$itemId, pos=$calcPosition") // Timber.v("saveActivity: itemId=$itemId, pos=$calcPosition")
api.playStateApi.reportPlaybackProgress( api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo( PlaybackProgressInfo(
itemId = playback.itemId, itemId = itemPlayback.itemId,
positionTicks = calcPosition.inWholeTicks, positionTicks = calcPosition.inWholeTicks,
canSeek = true, canSeek = true,
isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, isPaused = withContext(Dispatchers.Main) { !player.isPlaying },
@ -139,8 +141,8 @@ class TrackActivityPlaybackListener(
repeatMode = RepeatMode.REPEAT_NONE, repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT, playbackOrder = PlaybackOrder.DEFAULT,
playSessionId = playback.playSessionId, playSessionId = playback.playSessionId,
audioStreamIndex = playback.audioIndex, audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled },
subtitleStreamIndex = playback.subtitleIndex, subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled },
), ),
) )
} }

View file

@ -29,6 +29,7 @@ hilt = "2.57.2"
room = "2.8.2" room = "2.8.2"
material3 = "1.4.0" material3 = "1.4.0"
preferenceKtx = "1.2.1" preferenceKtx = "1.2.1"
roomTesting = "2.8.2"
[libraries] [libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } 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" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }