mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +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
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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")
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.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(
|
||||||
|
|
|
||||||
|
|
@ -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("-", "")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,15 +148,9 @@ 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 {
|
|
||||||
// TODO probably a cleaner way to do this
|
|
||||||
// Removes part of "5.1 Surround - English - AAC - Default"
|
|
||||||
it
|
|
||||||
.replace(" - Default", "")
|
|
||||||
.ifBlank { null }
|
|
||||||
?.let {
|
?.let {
|
||||||
TitleValueText(
|
TitleValueText(
|
||||||
stringResource(R.string.audio),
|
stringResource(R.string.audio),
|
||||||
|
|
@ -160,8 +158,8 @@ fun MovieDetailsHeader(
|
||||||
modifier = Modifier.widthIn(max = 200.dp),
|
modifier = Modifier.widthIn(max = 200.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
formatSubtitleLang(dto.mediaStreams)
|
getSubtitleDisplay(movie.data, chosenStreams)
|
||||||
?.let {
|
?.let {
|
||||||
if (it.isNotNullOrBlank()) {
|
if (it.isNotNullOrBlank()) {
|
||||||
TitleValueText(
|
TitleValueText(
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
dto.mediaStreams
|
remember(ep.id, chosenStreams) { getAudioDisplay(ep.data, chosenStreams) }
|
||||||
?.firstOrNull { it.type == MediaStreamType.AUDIO }
|
audioDisplay?.let {
|
||||||
?.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(
|
TitleValueText(
|
||||||
stringResource(R.string.audio),
|
stringResource(R.string.audio),
|
||||||
it,
|
it,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
formatSubtitleLang(dto.mediaStreams)
|
val subtitleText = getSubtitleDisplay(ep.data, chosenStreams)
|
||||||
|
subtitleText
|
||||||
?.let {
|
?.let {
|
||||||
if (it.isNotNullOrBlank()) {
|
if (it.isNotNullOrBlank()) {
|
||||||
TitleValueText(
|
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) {
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue