mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
f220aeee44
commit
6194eba8c5
31 changed files with 1152 additions and 154 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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("-", "")
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
|
|
|
|||
|
|
@ -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<List<Person>>(listOf())
|
||||
val chapters = MutableLiveData<List<Chapter>>(listOf())
|
||||
val chosenStreams = MutableLiveData<ChosenStreams?>(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<ItemDetailsDialogInfo?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(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<Person>,
|
||||
chapters: List<Chapter>,
|
||||
playOnClick: (Duration) -> Unit,
|
||||
|
|
@ -286,6 +301,7 @@ fun MovieDetailsContent(
|
|||
) {
|
||||
MovieDetailsHeader(
|
||||
movie = movie,
|
||||
chosenStreams = chosenStreams,
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
overviewOnClick = overviewOnClick,
|
||||
Modifier
|
||||
|
|
|
|||
|
|
@ -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,15 +148,9 @@ fun MovieDetailsHeader(
|
|||
modifier = Modifier.widthIn(max = 200.dp),
|
||||
)
|
||||
}
|
||||
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 }
|
||||
val audioDisplay =
|
||||
remember(movie.id, chosenStreams) { getAudioDisplay(movie.data, chosenStreams) }
|
||||
audioDisplay
|
||||
?.let {
|
||||
TitleValueText(
|
||||
stringResource(R.string.audio),
|
||||
|
|
@ -160,8 +158,8 @@ fun MovieDetailsHeader(
|
|||
modifier = Modifier.widthIn(max = 200.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
formatSubtitleLang(dto.mediaStreams)
|
||||
|
||||
getSubtitleDisplay(movie.data, chosenStreams)
|
||||
?.let {
|
||||
if (it.isNotNullOrBlank()) {
|
||||
TitleValueText(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
val audioDisplay =
|
||||
remember(ep.id, chosenStreams) { getAudioDisplay(ep.data, chosenStreams) }
|
||||
audioDisplay?.let {
|
||||
TitleValueText(
|
||||
stringResource(R.string.audio),
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
formatSubtitleLang(dto.mediaStreams)
|
||||
val subtitleText = getSubtitleDisplay(ep.data, chosenStreams)
|
||||
subtitleText
|
||||
?.let {
|
||||
if (it.isNotNullOrBlank()) {
|
||||
TitleValueText(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<BaseItem?>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AudioStream>,
|
||||
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<AudioStream>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<List<AudioStream>>(listOf())
|
||||
val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf())
|
||||
val currentPlayback = MutableLiveData<CurrentPlayback?>(null)
|
||||
val currentItemPlayback = MutableLiveData<ItemPlayback>()
|
||||
val trickplay = MutableLiveData<TrickplayInfo?>(null)
|
||||
val chapters = MutableLiveData<List<Chapter>>(listOf())
|
||||
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
|
||||
|
|
@ -121,7 +130,14 @@ class PlaybackViewModel
|
|||
val playlist = MutableLiveData<Playlist>(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<TrackSupport>,
|
||||
val playMethod: PlayMethod,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<JellyfinServer>,
|
||||
connectionStatus: Map<String, ServerConnectionStatus>,
|
||||
connectionStatus: Map<UUID, ServerConnectionStatus>,
|
||||
onSwitchServer: (JellyfinServer) -> Unit,
|
||||
onAddServer: () -> Unit,
|
||||
onRemoveServer: (JellyfinServer) -> Unit,
|
||||
|
|
|
|||
|
|
@ -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<List<JellyfinServer>>(listOf())
|
||||
val serverStatus = MutableLiveData<Map<String, ServerConnectionStatus>>(mapOf())
|
||||
val serverQuickConnect = MutableLiveData<Map<String, Boolean>>(mapOf())
|
||||
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
|
||||
val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf())
|
||||
|
||||
val users = MutableLiveData<List<JellyfinUser>>(listOf())
|
||||
val quickConnectState = MutableLiveData<QuickConnectResult?>(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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<MediaStream>?): 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue