Merge branch 'main' into fea/libass-android-support

This commit is contained in:
Damontecres 2026-03-05 20:39:21 -05:00
commit 271cb0ca20
No known key found for this signature in database
230 changed files with 20792 additions and 2528 deletions

View file

@ -7,7 +7,6 @@ import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
@ -45,42 +44,9 @@ android {
targetSdk = 36
versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
isDebuggable = false
}
debug {
isMinifyEnabled = false
isDebuggable = true
applicationIdSuffix = ".debug"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
javaParameters = true
}
}
buildFeatures {
buildConfig = true
compose = true
}
room {
schemaDirectory("$projectDir/schemas")
}
signingConfigs {
if (shouldSign) {
create("ci") {
@ -98,14 +64,15 @@ android {
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
isDebuggable = false
if (shouldSign) {
signingConfig = signingConfigs.getByName("ci")
} else {
@ -120,10 +87,11 @@ android {
}
}
}
debug {
if (shouldSign) {
signingConfig = signingConfigs.getByName("ci")
}
isMinifyEnabled = false
isDebuggable = true
applicationIdSuffix = ".debug"
}
applicationVariants.all {
@ -138,6 +106,25 @@ android {
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlin {
compilerOptions {
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
jvmTarget = JvmTarget.JVM_11
javaParameters = true
}
}
buildFeatures {
buildConfig = true
compose = true
}
room {
schemaDirectory("$projectDir/schemas")
}
splits {
abi {
@ -150,7 +137,13 @@ android {
sourceSets {
getByName("main") {
kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
@ -223,6 +216,7 @@ dependencies {
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore)
implementation(libs.protobuf.kotlin.lite)
@ -266,6 +260,8 @@ dependencies {
implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.room.testing)
implementation(libs.androidx.palette.ktx)
implementation(libs.androidx.media3.effect)
implementation(libs.androidx.runner)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler)
@ -284,6 +280,8 @@ dependencies {
ksp(libs.auto.service.ksp)
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp)
implementation(libs.kache)
implementation(libs.kache.file)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
@ -299,5 +297,12 @@ dependencies {
testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.androidx.ui.test.manifest)
}

View file

@ -0,0 +1,635 @@
{
"formatVersion": 1,
"database": {
"version": 30,
"identityHash": "f9b86b73e972aa002238d43ab2c8dcc2",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, 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
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"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, `pin` 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"
},
{
"fieldPath": "pin",
"columnName": "pin",
"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"
]
}
]
},
{
"tableName": "NavDrawerPinnedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "LibraryDisplayInfo",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sort",
"columnName": "sort",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "direction",
"columnName": "direction",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'{}'"
},
{
"fieldPath": "viewOptions",
"columnName": "viewOptions",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_LibraryDisplayInfo_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "playback_effects",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoFilter.rotation",
"columnName": "rotation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.brightness",
"columnName": "brightness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.contrast",
"columnName": "contrast",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.saturation",
"columnName": "saturation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.hue",
"columnName": "hue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.red",
"columnName": "red",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.green",
"columnName": "green",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blue",
"columnName": "blue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blur",
"columnName": "blur",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"itemId",
"type"
]
}
},
{
"tableName": "PlaybackLanguageChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "audioLanguage",
"columnName": "audioLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitleLanguage",
"columnName": "subtitleLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitlesDisabled",
"columnName": "subtitlesDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"seriesId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "ItemTrackModification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackIndex",
"columnName": "trackIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "delayMs",
"columnName": "delayMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId",
"trackIndex"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "seerr_servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_seerr_servers_url",
"unique": true,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
}
]
},
{
"tableName": "seerr_users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authMethod",
"columnName": "authMethod",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT"
},
{
"fieldPath": "credential",
"columnName": "credential",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"serverId"
]
},
"foreignKeys": [
{
"table": "seerr_servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
},
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"jellyfinUserRowId"
],
"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, 'f9b86b73e972aa002238d43ab2c8dcc2')"
]
}
}

View file

@ -0,0 +1,642 @@
{
"formatVersion": 1,
"database": {
"version": 31,
"identityHash": "c6829d764ec85321ab3be9905d6c0e3a",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, 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
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"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, `pin` 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"
},
{
"fieldPath": "pin",
"columnName": "pin",
"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"
]
}
]
},
{
"tableName": "NavDrawerPinnedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "LibraryDisplayInfo",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sort",
"columnName": "sort",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "direction",
"columnName": "direction",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'{}'"
},
{
"fieldPath": "viewOptions",
"columnName": "viewOptions",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_LibraryDisplayInfo_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "playback_effects",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoFilter.rotation",
"columnName": "rotation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.brightness",
"columnName": "brightness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.contrast",
"columnName": "contrast",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.saturation",
"columnName": "saturation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.hue",
"columnName": "hue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.red",
"columnName": "red",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.green",
"columnName": "green",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blue",
"columnName": "blue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blur",
"columnName": "blur",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"itemId",
"type"
]
}
},
{
"tableName": "PlaybackLanguageChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "audioLanguage",
"columnName": "audioLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitleLanguage",
"columnName": "subtitleLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitlesDisabled",
"columnName": "subtitlesDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"seriesId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "ItemTrackModification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackIndex",
"columnName": "trackIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "delayMs",
"columnName": "delayMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId",
"trackIndex"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "seerr_servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_seerr_servers_url",
"unique": true,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
}
]
},
{
"tableName": "seerr_users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authMethod",
"columnName": "authMethod",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT"
},
{
"fieldPath": "credential",
"columnName": "credential",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"serverId"
]
},
"foreignKeys": [
{
"table": "seerr_servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
},
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"jellyfinUserRowId"
],
"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, 'c6829d764ec85321ab3be9905d6c0e3a')"
]
}
}

View file

@ -0,0 +1,75 @@
package com.github.damontecres.wholphin.test
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@HiltAndroidTest
class InstrumentedBasicUiTests {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<TestActivity>()
lateinit var screensaverService: ScreensaverService
@Before
fun setup() {
screensaverService = mockk(relaxed = true)
every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
}
@OptIn(ExperimentalTestApi::class)
@Test
fun myTest() {
// Start the app
composeTestRule.setContent {
WholphinTheme {
MainContent(
backStack = mutableListOf(SetupDestination.ServerList),
navigationManager = mockk(relaxed = true),
appPreferences = mockk(relaxed = true),
backdropService = mockk(relaxed = true),
screensaverService = screensaverService,
requestedDestination = Destination.Home(),
modifier = Modifier,
)
}
}
composeTestRule.onNodeWithText("Add Server").assertExists()
composeTestRule.onNodeWithTag("add_server").performKeyInput {
pressKey(Key.DirectionDown) // TODO
}
composeTestRule.onNodeWithTag("add_server").performClickEnter()
composeTestRule.onNodeWithText("Discovered Servers").assertExists()
}
}
@OptIn(ExperimentalTestApi::class)
fun SemanticsNodeInteraction.performClickEnter() =
performKeyInput {
pressKey(Key.DirectionCenter)
}

View file

@ -0,0 +1,7 @@
package com.github.damontecres.wholphin.test
import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class TestActivity : ComponentActivity()

View file

@ -0,0 +1,14 @@
package com.github.damontecres.wholphin.test
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class WholphinTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
name: String?,
context: Context?,
): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

View file

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<!-- Required for Android 11+ to query voice recognition availability -->
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Wholphin"
android:name=".WholphinApplication"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".test.TestActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- <service-->
<!-- android:name=".WholphinDreamService"-->
<!-- android:exported="true"-->
<!-- android:label="@string/app_name"-->
<!-- android:permission="android.permission.BIND_DREAM_SERVICE">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.service.dreams.DreamService" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- </intent-filter>-->
<!-- </service>-->
</application>
</manifest>

View file

@ -68,6 +68,17 @@
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<service
android:name=".WholphinDreamService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
@ -10,8 +11,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -19,27 +18,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Color
import androidx.datastore.core.DataStore
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
@ -48,27 +36,36 @@ import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.SuggestionsSchedulerService
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.UserSwitchListener
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -113,6 +110,12 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
@Inject
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
@Inject
lateinit var backdropService: BackdropService
// Note: unused but injected to ensure it is created
@Inject
lateinit var serverEventListener: ServerEventListener
@ -121,22 +124,20 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
@Inject
lateinit var screensaverService: ScreensaverService
private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
instance = this
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver)
if (savedInstanceState == null) {
appUpgradeHandler.copySubfont(false)
}
refreshRateService.refreshRateMode.observe(this) { modeId ->
// Listen for refresh rate changes
val attrs = window.attributes
if (attrs.preferredDisplayModeId != modeId) {
Timber.d("Switch preferredDisplayModeId to %s", modeId)
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
lifecycleScope.launchIO {
appUpgradeHandler.copySubfont(false)
}
}
viewModel.serverRepository.currentUser.observe(this) { user ->
@ -149,9 +150,42 @@ class MainActivity : AppCompatActivity() {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
screensaverService.keepScreenOn
.onEach { keepScreenOn ->
Timber.v("keepScreenOn: %s", keepScreenOn)
withContext(Dispatchers.Main) {
if (keepScreenOn) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}.catch { ex ->
Timber.e(ex, "Error with keepScreenOn")
}.launchIn(lifecycleScope)
viewModel.appStart()
setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
if (appPreferences == null) {
// Show loading page if it is taking a while to get app preferences
var showLoading by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(500)
Timber.i("Showing loading page")
showLoading = true
}
if (showLoading) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
LoadingPage()
}
}
}
appPreferences?.let { appPreferences ->
LaunchedEffect(appPreferences.signInAutomatically) {
signInAuto = appPreferences.signInAutomatically
@ -177,127 +211,41 @@ class MainActivity : AppCompatActivity() {
true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) {
Surface(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
shape = RectangleShape,
) {
// val backStack = rememberNavBackStack(SetupDestination.Loading)
// setupNavigationManager.backStack = backStack
val backStack = setupNavigationManager.backStack
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
key as SetupDestination
NavEntry(key) {
when (key) {
SetupDestination.Loading -> {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
SetupDestination.ServerList -> {
SwitchServerContent(Modifier.fillMaxSize())
}
is SetupDestination.UserList -> {
SwitchUserContent(
currentServer = key.server,
Modifier.fillMaxSize(),
)
}
is SetupDestination.AppContent -> {
val current = key.current
ProvideLocalClock {
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
LaunchedEffect(Unit) {
try {
updateChecker.maybeShowUpdateToast(
appPreferences.updateUrl,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Exception during update check",
)
}
}
}
val appPreferences by userPreferencesDataStore.data.collectAsState(
appPreferences,
)
val preferences =
remember(appPreferences) {
UserPreferences(appPreferences)
}
var showContent by remember {
mutableStateOf(true)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!preferences.appPreferences.signInAutomatically) {
showContent = false
}
}
if (showContent) {
val requestedDestination =
remember(intent) {
intent?.let(::extractDestination)
}
ApplicationContent(
user = current.user,
server = current.server,
startDestination =
requestedDestination
?: Destination.Home(),
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}
}
}
},
)
}
val requestedDestination =
remember(intent) {
intent?.let(::extractDestination) ?: Destination.Home()
}
MainContent(
backStack = setupNavigationManager.backStack,
navigationManager = navigationManager,
appPreferences = appPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
requestedDestination = requestedDestination,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (screensaverService.state.value.show) {
screensaverService.stop(false)
screensaverService.pulse()
return true
} else {
screensaverService.pulse()
return super.dispatchKeyEvent(event)
}
}
override fun onResume() {
super.onResume()
Timber.d("onResume")
lifecycleScope.launchIO {
lifecycleScope.launchDefault {
screensaverService.pulse()
appUpgradeHandler.run()
}
}
@ -311,6 +259,7 @@ class MainActivity : AppCompatActivity() {
override fun onStop() {
super.onStop()
Timber.d("onStop")
screensaverService.stop(true)
tvProviderSchedulerService.launchOneTimeRefresh()
}
@ -322,6 +271,22 @@ class MainActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
Timber.d("onStart")
lifecycleScope.launchDefault {
val appPreferences = userPreferencesDataStore.data.first()
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
try {
updateChecker.maybeShowUpdateToast(
appPreferences.updateUrl,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Exception during update check",
)
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
@ -384,6 +349,16 @@ class MainActivity : AppCompatActivity() {
}
}
fun changeDisplayMode(modeId: Int) {
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) {
val attrs = window.attributes
if (attrs.preferredDisplayModeId != modeId) {
Timber.d("Switch preferredDisplayModeId to %s", modeId)
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
}
}
}
companion object {
const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType"
@ -391,6 +366,9 @@ class MainActivity : AppCompatActivity() {
const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
lateinit var instance: MainActivity
private set
}
}

View file

@ -0,0 +1,149 @@
package com.github.damontecres.wholphin
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.AppScreensaver
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
@Composable
fun MainContent(
backStack: MutableList<SetupDestination>,
navigationManager: NavigationManager,
appPreferences: AppPreferences,
backdropService: BackdropService,
screensaverService: ScreensaverService,
requestedDestination: Destination,
modifier: Modifier = Modifier,
) {
Surface(
modifier =
modifier
.background(MaterialTheme.colorScheme.background),
shape = RectangleShape,
) {
// val backStack = rememberNavBackStack(SetupDestination.Loading)
// setupNavigationManager.backStack = backStack
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
key as SetupDestination
NavEntry(key) {
when (key) {
SetupDestination.Loading -> {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
SetupDestination.ServerList -> {
SwitchServerContent(Modifier.fillMaxSize())
}
is SetupDestination.UserList -> {
SwitchUserContent(
currentServer = key.server,
Modifier.fillMaxSize(),
)
}
is SetupDestination.AppContent -> {
LaunchedEffect(Unit) {
backdropService.clearBackdrop()
}
val current = key.current
ProvideLocalClock {
val preferences =
remember(appPreferences) {
UserPreferences(appPreferences)
}
var showContent by remember {
mutableStateOf(true)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!appPreferences.signInAutomatically) {
showContent = false
}
}
if (showContent) {
ApplicationContent(
user = current.user,
server = current.server,
startDestination = requestedDestination,
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}
}
}
},
)
val screenSaverState by screensaverService.state.collectAsState()
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
AnimatedVisibility(
screenSaverState.show,
Modifier.fillMaxSize(),
) {
AppScreensaver(appPreferences, Modifier.fillMaxSize())
}
}
}
}

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin
import android.app.Application
import android.os.Build
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.util.Log
import androidx.compose.runtime.Composer
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
@ -27,16 +26,6 @@ class WholphinApplication :
init {
instance = this
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy
.Builder()
.detectNetwork()
.penaltyLog()
.build(),
)
}
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
@ -64,6 +53,23 @@ class WholphinApplication :
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy
.Builder()
.detectNetwork()
.penaltyLog()
.penaltyDeathOnNetwork()
.build(),
)
// StrictMode.setVmPolicy(
// StrictMode.VmPolicy
// .Builder()
// .detectAll()
// .penaltyLog()
// .build(),
// )
}
OkHttp.initialize(this)
initAcra {
buildConfigClass = BuildConfig::class.java

View file

@ -0,0 +1,101 @@
package com.github.damontecres.wholphin
import android.service.dreams.DreamService
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint
class WholphinDreamService :
DreamService(),
SavedStateRegistryOwner {
@Inject
lateinit var screensaverService: ScreensaverService
@Inject
lateinit var userPreferencesService: UserPreferencesService
private val lifecycleRegistry = LifecycleRegistry(this)
private val savedStateRegistryController =
SavedStateRegistryController.create(this).apply {
performAttach()
}
override val lifecycle: Lifecycle get() = lifecycleRegistry
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
override fun onCreate() {
super.onCreate()
savedStateRegistryController.performRestore(null)
lifecycleRegistry.currentState = Lifecycle.State.CREATED
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
setContentView(
ComposeView(this).apply {
setViewTreeLifecycleOwner(this@WholphinDreamService)
setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
setContent {
var prefs by remember { mutableStateOf<UserPreferences?>(null) }
LaunchedEffect(Unit) {
userPreferencesService.flow.collectLatest { prefs = it }
}
prefs?.let { prefs ->
WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) {
ProvideLocalClock {
val screensaverPrefs =
prefs.appPreferences.interfacePreferences.screensaverPreference
val currentItem by itemFlow.collectAsState(null)
AppScreensaverContent(
currentItem = currentItem,
showClock = screensaverPrefs.showClock,
duration = screensaverPrefs.duration.milliseconds,
animate = screensaverPrefs.animate,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
},
)
}
override fun onDreamingStarted() {
super.onDreamingStarted()
lifecycleRegistry.currentState = Lifecycle.State.STARTED
}
override fun onDreamingStopped() {
super.onDreamingStopped()
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
}
}

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.PlaybackEffect
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
@ -32,12 +33,14 @@ import java.util.UUID
ItemPlayback::class,
NavDrawerPinnedItem::class,
LibraryDisplayInfo::class,
PlaybackEffect::class,
PlaybackLanguageChoice::class,
ItemTrackModification::class,
SeerrServer::class,
SeerrUser::class,
],
version = 20,
version = 31,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@ -50,6 +53,8 @@ import java.util.UUID
AutoMigration(10, 11),
AutoMigration(11, 12),
AutoMigration(12, 20),
AutoMigration(20, 30),
AutoMigration(30, 31),
],
)
@TypeConverters(Converters::class)
@ -65,6 +70,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
abstract fun seerrServerDao(): SeerrServerDao
abstract fun playbackEffectDao(): PlaybackEffectDao
}
class Converters {

View file

@ -1,97 +0,0 @@
package com.github.damontecres.wholphin.data
import android.content.Context
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NavDrawerItemRepository
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
) {
suspend fun getNavDrawerItems(): List<NavDrawerItem> {
val user = serverRepository.currentUser.value
val tvAccess =
serverRepository.currentUserDto.value
?.policy
?.enableLiveTvAccess ?: false
val userViews =
api.userViewsApi
.getUserViews(userId = user?.id)
.content.items
val recordingFolders =
if (tvAccess) {
api.liveTvApi
.getRecordingFolders(userId = user?.id)
.content.items
.map { it.id }
.toSet()
} else {
setOf()
}
val builtins =
if (seerrServerRepository.active.first()) {
listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
} else {
listOf(NavDrawerItem.Favorites)
}
val libraries =
userViews
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
.map {
val destination =
if (it.id in recordingFolders) {
Destination.Recordings(it.id)
} else {
BaseItem.from(it, api).destination()
}
ServerNavDrawerItem(
itemId = it.id,
name = it.name ?: it.id.toString(),
destination = destination,
type = it.collectionType ?: CollectionType.UNKNOWN,
)
}
return builtins + libraries
}
suspend fun getFilteredNavDrawerItems(items: List<NavDrawerItem>): List<NavDrawerItem> {
val user = serverRepository.currentUser.value
val navDrawerPins =
user
?.let {
serverPreferencesDao.getNavDrawerPinnedItems(it)
}.orEmpty()
val filtered = items.filter { navDrawerPins.isPinned(it.id) }
if (items.size != filtered.size) {
// Some were filtered out, check if should include More
if (navDrawerPins.isPinned(NavDrawerItem.More.id)) {
return filtered + listOf(NavDrawerItem.More)
}
}
return filtered
}
}
fun List<NavDrawerPinnedItem>.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED

View file

@ -0,0 +1,22 @@
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.PlaybackEffect
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
@Dao
interface PlaybackEffectDao {
@Query("SELECT * FROM playback_effects WHERE jellyfinUserRowId=:jellyfinUserRowId AND itemId=:itemId AND type=:type")
suspend fun getPlaybackEffect(
jellyfinUserRowId: Int,
itemId: UUID,
type: BaseItemKind,
): PlaybackEffect?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(playbackEffect: PlaybackEffect)
}

View file

@ -47,7 +47,7 @@ interface SeerrServerDao {
suspend fun deleteUser(
serverId: Int,
jellyfinUserRowId: Int,
)
): Int
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)

View file

@ -9,15 +9,18 @@ import androidx.lifecycle.map
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult
@ -40,9 +43,8 @@ class ServerRepository
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
private val sharedPreferences = getServerSharedPreferences(context)
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current
@ -58,7 +60,7 @@ class ServerRepository
* The current user is removed
*/
suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.addOrUpdateServer(server)
}
apiClient.update(baseUrl = server.url, accessToken = null)
@ -72,7 +74,7 @@ class ServerRepository
server: JellyfinServer,
user: JellyfinUser,
): CurrentUser? =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server")
}
@ -106,7 +108,7 @@ class ServerRepository
_current.value = CurrentUser(updatedServer, updatedUser)
_currentUserDto.value = userDto
}
sharedPreferences.edit(true) {
getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
}
@ -127,7 +129,7 @@ class ServerRepository
return null
}
val serverAndUsers =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.getServer(serverId)
}
if (serverAndUsers != null) {
@ -150,7 +152,7 @@ class ServerRepository
}
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverId?.let { serverDao.getServer(serverId)?.server }
}
@ -164,7 +166,7 @@ class ServerRepository
suspend fun changeUser(
serverUrl: String,
authenticationResult: AuthenticationResult,
) = withContext(Dispatchers.IO) {
) = withContext(ioDispatcher) {
val accessToken = authenticationResult.accessToken
if (accessToken != null) {
val authedUser = authenticationResult.user
@ -214,7 +216,7 @@ class ServerRepository
}
apiClient.update(accessToken = null)
}
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.deleteUser(user.serverId, user.id)
}
}
@ -234,7 +236,7 @@ class ServerRepository
}
apiClient.update(baseUrl = null, accessToken = null)
}
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.deleteServer(server.id)
}
}
@ -253,7 +255,7 @@ class ServerRepository
suspend fun setUserPin(
user: JellyfinUser,
pin: String?,
) = withContext(Dispatchers.IO) {
) = withContext(ioDispatcher) {
val newUser = user.copy(pin = pin)
val updatedUser = serverDao.addOrUpdateUser(newUser)
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
@ -265,6 +267,17 @@ class ServerRepository
}
}
suspend fun authorizeQuickConnect(code: String): Boolean =
withContext(ioDispatcher) {
val userId = currentUser.value?.id
if (userId == null) {
Timber.e("No user logged in for Quick Connect authorization")
throw IllegalStateException("Must be logged in to authorize Quick Connect")
}
val response = apiClient.quickConnectApi.authorizeQuickConnect(code, userId)
response.content
}
companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(

View file

@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
import java.util.UUID
import kotlin.time.Duration
@Serializable
@ -32,6 +33,8 @@ import kotlin.time.Duration
data class BaseItem(
val data: BaseItemDto,
val useSeriesForPrimary: Boolean,
val imageUrlOverride: String? = null,
val destinationOverride: Destination? = null,
) : CardGridItem {
val id get() = data.id
@ -69,6 +72,8 @@ data class BaseItem(
}
}
val canDelete: Boolean get() = data.canDelete == true
@Transient
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
@ -91,12 +96,20 @@ data class BaseItem(
episodeCornerText =
data.indexNumber?.let { "E$it" }
?: data.premiereDate?.let(::formatDateTime),
episdodeUnplayedCornerText =
data.indexNumber?.let { "E$it" }
?: data.userData
?.unplayedItemCount
?.takeIf { it > 0 }
?.let { abbreviateNumber(it) },
episodeUnplayedCornerText =
if (type == BaseItemKind.SERIES ||
type == BaseItemKind.SEASON ||
type == BaseItemKind.EPISODE ||
type == BaseItemKind.BOX_SET
) {
data.indexNumber?.let { "E$it" }
?: data.userData
?.unplayedItemCount
?.takeIf { it > 0 }
?.let { abbreviateNumber(it) }
} else {
null
},
quickDetails =
buildAnnotatedString {
val details =
@ -106,6 +119,12 @@ data class BaseItem(
data.premiereDate?.let { add(DateFormatter.format(it)) }
} else if (type == BaseItemKind.SERIES) {
data.seriesProductionYears?.let(::add)
} else if (type == BaseItemKind.PHOTO) {
if (data.productionYear != null) {
add(data.productionYear!!.toString())
} else if (data.premiereDate != null) {
add(data.premiereDate!!.toLocalDate().toString())
}
} else {
data.productionYear?.let { add(it.toString()) }
}
@ -154,7 +173,8 @@ data class BaseItem(
it.dayOfMonth.toString().padStart(2, '0')
}?.toIntOrNull()
fun destination(): Destination {
fun destination(index: Int? = null): Destination {
if (destinationOverride != null) return destinationOverride
val result =
// Redirect episodes & seasons to their series if possible
when (type) {
@ -176,6 +196,25 @@ data class BaseItem(
)
}
BaseItemKind.TV_CHANNEL -> {
Destination.Playback(
itemId = id,
positionMs = 0L,
)
}
BaseItemKind.PROGRAM -> {
val channelId = data.channelId
if (channelId != null) {
Destination.Playback(
itemId = channelId,
positionMs = 0L,
)
} else {
Destination.MediaItem(this)
}
}
else -> {
Destination.MediaItem(this)
}
@ -201,6 +240,31 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let {
@Immutable
data class BaseItemUi(
val episodeCornerText: String?,
val episdodeUnplayedCornerText: String?,
val episodeUnplayedCornerText: String?,
val quickDetails: AnnotatedString,
)
fun createGenreDestination(
genreId: UUID,
genreName: String,
parentId: UUID,
parentName: String?,
includeItemTypes: List<BaseItemKind>?,
) = Destination.FilteredCollection(
itemId = parentId,
filter =
CollectionFolderFilter(
nameOverride =
listOfNotNull(
genreName,
parentName,
).joinToString(" "),
filter =
GetItemsFilter(
genres = listOf(genreId),
includeItemTypes = includeItemTypes,
),
useSavedLibraryDisplayInfo = false,
),
recursive = true,
)

View file

@ -31,6 +31,7 @@ data class Chapter(
)
},
)
}.orEmpty()
}?.sortedBy { it.position }
.orEmpty()
}
}

View file

@ -0,0 +1,239 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
sealed interface HomeRowConfig {
val viewOptions: HomeRowViewOptions
fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
/**
* Continue watching media that the user has started but not finished
*/
@Serializable
@SerialName("ContinueWatching")
data class ContinueWatching(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatching = this.copy(viewOptions = viewOptions)
}
/**
* Next up row for next episodes in a series the user has started
*/
@Serializable
@SerialName("NextUp")
data class NextUp(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): NextUp = this.copy(viewOptions = viewOptions)
}
/**
* Combined [ContinueWatching] and [NextUp]
*/
@Serializable
@SerialName("ContinueWatchingCombined")
data class ContinueWatchingCombined(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatchingCombined = this.copy(viewOptions = viewOptions)
}
/**
* Media recently added to a library
*/
@Serializable
@SerialName("RecentlyAdded")
data class RecentlyAdded(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyAdded = this.copy(viewOptions = viewOptions)
}
/**
* Media recently released (premiere date) in a library
*/
@Serializable
@SerialName("RecentlyReleased")
data class RecentlyReleased(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyReleased = this.copy(viewOptions = viewOptions)
}
/**
* Row of a genres in a library
*/
@Serializable
@SerialName("Genres")
data class Genres(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault,
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
}
/**
* Favorites for a specific type
*/
@Serializable
@SerialName("Favorite")
data class Favorite(
val kind: BaseItemKind,
override val viewOptions: HomeRowViewOptions =
if (kind == BaseItemKind.EPISODE) {
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.WIDE,
)
} else {
HomeRowViewOptions()
},
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
}
/**
* Currently recording
*/
@Serializable
@SerialName("Recordings")
data class Recordings(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Recordings = this.copy(viewOptions = viewOptions)
}
/**
* Programs on now/recommended
*/
@Serializable
@SerialName("TvPrograms")
data class TvPrograms(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault,
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions)
}
/**
* Live TV channels
*/
@Serializable
@SerialName("TvChannels")
data class TvChannels(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault,
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions)
}
/**
* Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID
*/
@Serializable
@SerialName("Suggestions")
data class Suggestions(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Suggestions = this.copy(viewOptions = viewOptions)
}
/**
* Fetch by parent ID such as a library, collection, or playlist with optional simple sorting
*/
@Serializable
@SerialName("ByParent")
data class ByParent(
val parentId: UUID,
val recursive: Boolean,
val sort: SortAndDirection? = null,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ByParent = this.copy(viewOptions = viewOptions)
}
/**
* An arbitrary [GetItemsRequest] allowing to query for anything
*/
@Serializable
@SerialName("GetItems")
data class GetItems(
val name: String,
val getItems: GetItemsRequest,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
}
}
/**
* Root class for home page settings
*
* Contains the list of rows and a version
*/
@Serializable
@SerialName("HomePageSettings")
data class HomePageSettings(
val rows: List<HomeRowConfig>,
val version: Int,
) {
companion object {
val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
}
}
/**
* This is the max version supported by this version of the app
*/
const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1
/**
* View options for displaying a row
*
* Allows for changing things like height or aspect ratio
*/
@Serializable
data class HomeRowViewOptions(
val heightDp: Int = Cards.HEIGHT_2X3_DP,
val spacing: Int = 16,
val contentScale: PrefContentScale = PrefContentScale.FILL,
val aspectRatio: AspectRatio = AspectRatio.TALL,
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
val showTitles: Boolean = false,
val useSeries: Boolean = true,
val episodeContentScale: PrefContentScale = PrefContentScale.FILL,
val episodeAspectRatio: AspectRatio = AspectRatio.TALL,
val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
) {
companion object {
val genreDefault =
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.WIDE,
)
val liveTvDefault =
HomeRowViewOptions(
heightDp = 96,
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.FIT,
)
}
}

View file

@ -1,6 +1,10 @@
package com.github.damontecres.wholphin.data.model
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.UUID
@ -19,19 +23,21 @@ data class Person(
) {
companion object {
fun fromDto(
context: Context,
dto: BaseItemPerson,
api: ApiClient,
): Person =
Person(
id = dto.id,
name = dto.name,
role = dto.role,
role = personRole(context, dto.role, dto.type),
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = false,
)
fun fromDto(
context: Context,
dto: BaseItemPerson,
favorite: Boolean,
api: ApiClient,
@ -39,10 +45,51 @@ data class Person(
Person(
id = dto.id,
name = dto.name,
role = dto.role,
role = personRole(context, dto.role, dto.type),
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = favorite,
)
}
}
private fun personRole(
context: Context,
role: String?,
type: PersonKind,
): String? =
if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) {
role
} else if (role.equals(type.name, ignoreCase = true)) {
type.stringRes?.let { context.getString(it) }
} else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) {
role
} else {
listOfNotNull(
role?.takeIf { it.isNotNullOrBlank() },
type.stringRes?.let { context.getString(it) },
).takeIf { it.isNotEmpty() }?.joinToString(" - ")
}
@get:StringRes
private val PersonKind.stringRes: Int?
get() =
when (this) {
PersonKind.UNKNOWN -> R.string.unknown
PersonKind.ACTOR -> R.string.actor
PersonKind.DIRECTOR -> R.string.director
PersonKind.COMPOSER -> R.string.composer
PersonKind.WRITER -> R.string.writer
PersonKind.GUEST_STAR -> R.string.guest_star
PersonKind.PRODUCER -> R.string.producer
PersonKind.CONDUCTOR -> R.string.conductor
PersonKind.LYRICIST -> R.string.lyricist
PersonKind.ARRANGER -> R.string.arranger
PersonKind.ENGINEER -> R.string.engineer
PersonKind.MIXER -> R.string.mixer
PersonKind.REMIXER -> R.string.mixer
PersonKind.CREATOR -> R.string.creator
PersonKind.ARTIST -> R.string.artist
PersonKind.ALBUM_ARTIST -> R.string.artist
else -> null
}

View file

@ -0,0 +1,14 @@
package com.github.damontecres.wholphin.data.model
import androidx.room.Embedded
import androidx.room.Entity
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
data class PlaybackEffect(
val jellyfinUserRowId: Int,
val itemId: UUID,
val type: BaseItemKind,
@Embedded val videoFilter: VideoFilter,
)

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@ -24,4 +25,5 @@ data class NavDrawerPinnedItem(
val userId: Int,
val itemId: String,
val type: NavPinType,
@ColumnInfo(defaultValue = "-1") val order: Int,
)

View file

@ -0,0 +1,223 @@
package com.github.damontecres.wholphin.data.model
import android.graphics.ColorMatrix
import androidx.annotation.IntRange
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.Brightness
import androidx.media3.effect.Contrast
import androidx.media3.effect.GaussianBlur
import androidx.media3.effect.GlEffect
import androidx.media3.effect.HslAdjustment
import androidx.media3.effect.RgbAdjustment
import androidx.media3.effect.ScaleAndRotateTransformation
import androidx.room.Ignore
/**
* Modifications to a video playback
*/
data class VideoFilter(
val rotation: Int = 0,
@param:IntRange(0, 200) val brightness: Int = COLOR_DEFAULT,
@param:IntRange(0, 200) val contrast: Int = COLOR_DEFAULT,
@param:IntRange(0, 200) val saturation: Int = COLOR_DEFAULT,
@param:IntRange(0, 360) val hue: Int = HUE_DEFAULT,
@param:IntRange(0, 200) val red: Int = COLOR_DEFAULT,
@param:IntRange(0, 200) val green: Int = COLOR_DEFAULT,
@param:IntRange(0, 200) val blue: Int = COLOR_DEFAULT,
@param:IntRange(0, 250) val blur: Int = 0,
) {
@Ignore
val colorMatrix: androidx.compose.ui.graphics.ColorMatrix = createComposeColorMatrix()
companion object {
const val COLOR_DEFAULT = 100
const val HUE_DEFAULT = 0
}
fun isRotated(): Boolean = rotation != 0 && rotation % 360 != 0
fun hasRgb(): Boolean = red != COLOR_DEFAULT || green != COLOR_DEFAULT || blue != COLOR_DEFAULT
fun hasBrightness(): Boolean = brightness != COLOR_DEFAULT
fun hasContrast(): Boolean = contrast != COLOR_DEFAULT
fun hasHsl(): Boolean = hue != HUE_DEFAULT || saturation != COLOR_DEFAULT
fun hasBlur(): Boolean = blur > 0
fun hasImageFilter(): Boolean = hasRgb() || hasBrightness() || hasContrast() || saturation != COLOR_DEFAULT
@OptIn(UnstableApi::class)
private fun rgbAdjustment(): RgbAdjustment =
RgbAdjustment
.Builder()
.setRedScale(red / COLOR_DEFAULT.toFloat())
.setGreenScale(green / COLOR_DEFAULT.toFloat())
.setBlueScale(blue / COLOR_DEFAULT.toFloat())
.build()
/**
* Create the list of effects to apply
*/
@OptIn(UnstableApi::class)
fun createEffectList(): List<GlEffect> =
buildList {
if (isRotated()) {
add(
ScaleAndRotateTransformation
.Builder()
.setRotationDegrees(rotation.toFloat())
.build(),
)
}
if (hasRgb()) {
add(rgbAdjustment())
}
if (hasBrightness()) {
add(Brightness((brightness - 100) / 100f))
}
if (hasContrast()) {
add(Contrast((contrast - 100) / 100f))
}
if (hasHsl()) {
add(
HslAdjustment
.Builder()
.adjustHue(hue.toFloat())
.adjustSaturation((saturation - 100).toFloat())
.build(),
)
}
if (hasBlur()) {
add(GaussianBlur(blur / 10f))
}
}
private fun saturationMatrix(): FloatArray {
val rF = 0.2999f
val gF = 0.587f
val bF = 0.114f
val s = saturation / 100.0f
val ms = 1.0f - s
val rT = rF * ms
val gT = gF * ms
val bT = bF * ms
val m =
FloatArray(20) {
when (it) {
0 -> (rT + s)
1 -> gT
2 -> bT
5 -> rT
6 -> (gT + s)
7 -> bT
10 -> rT
11 -> gT
12 -> (bT + s)
18 -> 1f
else -> 0f
}
}
return m
}
@OptIn(UnstableApi::class)
fun createColorMatrix(): ColorMatrix {
val matrix = ColorMatrix()
val tempMatrix = ColorMatrix()
if (saturation != COLOR_DEFAULT) {
matrix.set(saturationMatrix())
}
if (hasRgb()) {
val colorMatrix = rgbAdjustment().getMatrix(0L, false)
val m = FloatArray(20)
m[0] = colorMatrix[0]
m[1] = colorMatrix[1]
m[2] = colorMatrix[2]
m[3] = colorMatrix[3]
m[4] = 0f
m[5] = colorMatrix[4]
m[6] = colorMatrix[5]
m[7] = colorMatrix[6]
m[8] = colorMatrix[7]
m[9] = 0f
m[10] = colorMatrix[8]
m[11] = colorMatrix[9]
m[12] = colorMatrix[10]
m[13] = colorMatrix[11]
m[14] = 0f
m[15] = colorMatrix[12]
m[16] = colorMatrix[13]
m[17] = colorMatrix[14]
m[18] = colorMatrix[15]
m[19] = 0f
tempMatrix.set(m)
matrix.postConcat(tempMatrix)
}
if (hasContrast()) {
val scale = contrast / 100.0f
tempMatrix.setScale(scale, scale, scale, 1f)
matrix.postConcat(tempMatrix)
}
if (hasBrightness()) {
val b = brightness / 100.0f
val m = FloatArray(20)
m[0] = b
m[6] = b
m[12] = b
m[18] = 1f
tempMatrix.set(m)
matrix.postConcat(tempMatrix)
}
// TODO hue
// TODO blur
return matrix
}
@OptIn(UnstableApi::class)
fun createComposeColorMatrix(): androidx.compose.ui.graphics.ColorMatrix {
val matrix =
androidx.compose.ui.graphics
.ColorMatrix()
if (saturation != COLOR_DEFAULT) {
matrix.setToSaturation(saturation / 100f)
}
if (hasRgb()) {
matrix.setToScale(
redScale = red / 100f,
greenScale = green / 100f,
blueScale = blue / 100f,
alphaScale = 1f,
)
}
if (hasContrast()) {
val scale = contrast / 100.0f
val tempMatrix =
androidx.compose.ui.graphics
.ColorMatrix()
tempMatrix.setToScale(scale, scale, scale, 1f)
matrix.timesAssign(tempMatrix)
}
if (hasBrightness()) {
val b = brightness / 100.0f
val m = FloatArray(20)
m[0] = b
m[6] = b
m[12] = b
m[18] = 1f
val tempMatrix =
androidx.compose.ui.graphics
.ColorMatrix(m)
matrix.timesAssign(tempMatrix)
}
// TODO hue
// TODO blur
return matrix
}
}

View file

@ -186,6 +186,45 @@ sealed interface AppPreference<Pref, T> {
summarizer = { value -> value?.toString() },
)
val MaxDaysNextUpOptions = listOf(7, 14, 30, 60, 90, 180, 365)
val MaxDaysNextUp =
AppSliderPreference<AppPreferences>(
title = R.string.max_days_next_up,
defaultValue = -1,
min = 0,
// Max is "no limit" stored as -1
max = MaxDaysNextUpOptions.lastIndex + 1L,
interval = 1,
getter = {
MaxDaysNextUpOptions
.indexOf(it.homePagePreferences.maxDaysNextUp)
.takeIf { it in MaxDaysNextUpOptions.indices }
?.toLong()
?: MaxDaysNextUpOptions.size.toLong()
},
setter = { prefs, index ->
prefs.updateHomePagePreferences {
maxDaysNextUp = MaxDaysNextUpOptions.getOrNull(index.toInt()) ?: -1
}
},
summarizer = { value ->
if (value != null) {
val v = MaxDaysNextUpOptions.getOrNull(value.toInt()) ?: -1
if (v == -1) {
WholphinApplication.instance.getString(R.string.no_limit)
} else {
WholphinApplication.instance.resources.getQuantityString(
R.plurals.days,
v,
v.toString(),
)
}
} else {
null
}
},
)
val CombineContinueNext =
AppSwitchPreference<AppPreferences>(
title = R.string.combine_continue_next,
@ -617,6 +656,13 @@ sealed interface AppPreference<Pref, T> {
setter = { prefs, _ -> prefs },
)
val CustomizeHome =
AppDestinationPreference<AppPreferences>(
title = R.string.customize_home,
destination = Destination.HomeSettings,
summary = R.string.customize_home_summary,
)
val SendCrashReports =
AppSwitchPreference<AppPreferences>(
title = R.string.send_crash_reports,
@ -695,6 +741,18 @@ sealed interface AppPreference<Pref, T> {
valueToIndex = { it.number },
)
val ManageMedia =
AppSwitchPreference<AppPreferences>(
title = R.string.show_media_management,
defaultValue = false,
getter = { it.interfacePreferences.enableMediaManagement },
setter = { prefs, value ->
prefs.updateInterfacePreferences { enableMediaManagement = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val OneClickPause =
AppSwitchPreference<AppPreferences>(
title = R.string.one_click_pause,
@ -710,7 +768,7 @@ sealed interface AppPreference<Pref, T> {
val SubtitleStyle =
AppDestinationPreference<AppPreferences>(
title = R.string.subtitle_style,
destination = Destination.Settings(PreferenceScreenOption.SUBTITLES),
destination = Destination.SubtitleSettings(false),
)
val RefreshRateSwitching =
@ -903,6 +961,60 @@ sealed interface AppPreference<Pref, T> {
getter = { },
setter = { prefs, _ -> prefs },
)
val QuickConnect =
AppClickablePreference<AppPreferences>(
title = R.string.quick_connect,
summary = R.string.quick_connect_summary,
getter = { },
setter = { prefs, _ -> prefs },
)
val SlideshowDuration =
AppSliderPreference<AppPreferences>(
title = R.string.slideshow_duration,
defaultValue = 5_000,
min = 1_000,
max = 30.seconds.inWholeMilliseconds,
interval = 250,
getter = {
it.photoPreferences.slideshowDuration
},
setter = { prefs, value ->
prefs.updatePhotoPreferences {
slideshowDuration = value
}
},
summarizer = { value ->
if (value != null) {
val seconds = value / 1000.0
WholphinApplication.instance.resources.getString(
R.string.decimal_seconds,
seconds,
)
} else {
null
}
},
)
val SlideshowPlayVideos =
AppSwitchPreference<AppPreferences>(
title = R.string.play_videos_during_slideshow,
defaultValue = false,
getter = { it.photoPreferences.slideshowPlayVideos },
setter = { prefs, value ->
prefs.updatePhotoPreferences { slideshowPlayVideos = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val ScreensaverSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.screensaver_settings,
destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER),
)
}
}
@ -913,13 +1025,11 @@ val basicPreferences =
preferences =
listOf(
AppPreference.SignInAuto,
AppPreference.HomePageItems,
AppPreference.CombineContinueNext,
AppPreference.RewatchNextUp,
AppPreference.PlayThemeMusic,
AppPreference.RememberSelectedTab,
AppPreference.SubtitleStyle,
AppPreference.ThemeColors,
AppPreference.ScreensaverSettings,
),
),
PreferenceGroup(
@ -946,6 +1056,7 @@ val basicPreferences =
preferences =
listOf(
AppPreference.RequireProfilePin,
AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems,
),
),
@ -969,8 +1080,6 @@ val basicPreferences =
),
)
val uiPreferences = listOf<PreferenceGroup>()
private val ExoPlayerSettings =
listOf(
AppPreference.FfmpegPreference,
@ -1013,10 +1122,14 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.ShowClock,
AppPreference.ManageMedia,
AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout,
AppPreference.BackdropStylePref,
AppPreference.SlideshowDuration,
AppPreference.SlideshowPlayVideos,
),
),
)
@ -1088,6 +1201,7 @@ val advancedPreferences =
title = R.string.more,
preferences =
listOf(
AppPreference.QuickConnect,
AppPreference.SendAppLogs,
AppPreference.SendCrashReports,
AppPreference.DebugLogging,
@ -1107,6 +1221,24 @@ val liveTvPreferences =
AppPreference.LiveTvColorCodePrograms,
)
val screensaverPreferences =
listOf(
PreferenceGroup(
title = R.string.screensaver,
preferences =
listOf(
ScreensaverPreference.Enabled,
ScreensaverPreference.StartDelay,
ScreensaverPreference.Duration,
ScreensaverPreference.ShowClock,
ScreensaverPreference.Animate,
ScreensaverPreference.MaxAge,
ScreensaverPreference.ItemTypes,
ScreensaverPreference.Start,
),
),
)
data class AppSwitchPreference<Pref>(
@get:StringRes override val title: Int,
override val defaultValue: Boolean,
@ -1161,8 +1293,6 @@ data class AppMultiChoicePreference<Pref, T>(
override val getter: (prefs: Pref) -> List<T>,
override val setter: (prefs: Pref, value: List<T>) -> Pref,
@param:StringRes val summary: Int? = null,
val toSharedPrefs: (T) -> String,
val fromSharedPrefs: (String) -> T?,
) : AppPreference<Pref, List<T>>
data class AppClickablePreference<Pref>(

View file

@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.google.protobuf.InvalidProtocolBufferException
import org.jellyfin.sdk.model.api.BaseItemKind
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
@ -82,6 +83,7 @@ class AppPreferencesSerializer
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
combineContinueNext = AppPreference.CombineContinueNext.defaultValue
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
}.build()
interfacePreferences =
InterfacePreferences
@ -100,6 +102,12 @@ class AppPreferencesSerializer
.apply {
resetSubtitles()
}.build()
hdrSubtitlesPreferences =
SubtitlePreferences
.newBuilder()
.apply {
resetSubtitles()
}.build()
liveTvPreferences =
LiveTvPreferences
@ -113,6 +121,20 @@ class AppPreferencesSerializer
colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue
}.build()
screensaverPreference =
ScreensaverPreferences
.newBuilder()
.apply {
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
duration = ScreensaverPreference.DEFAULT_DURATION
animate = ScreensaverPreference.Animate.defaultValue
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
showClock = ScreensaverPreference.ShowClock.defaultValue
clearItemTypes()
addItemTypes(BaseItemKind.MOVIE.serialName)
addItemTypes(BaseItemKind.SERIES.serialName)
}.build()
}.build()
advancedPreferences =
@ -122,6 +144,14 @@ class AppPreferencesSerializer
imageDiskCacheSizeBytes =
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
}.build()
photoPreferences =
PhotoPreferences
.newBuilder()
.apply {
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
}.build()
}.build()
override suspend fun readFrom(input: InputStream): AppPreferences {
@ -180,6 +210,16 @@ inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.B
advancedPreferences = advancedPreferences.toBuilder().apply(block).build()
}
inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder.() -> Unit): AppPreferences =
update {
photoPreferences = photoPreferences.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences =
updateInterfacePreferences {
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
}
fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
@ -193,4 +233,5 @@ fun SubtitlePreferences.Builder.resetSubtitles() {
backgroundStyle = SubtitleSettings.BackgroundStylePref.defaultValue
margin = SubtitleSettings.Margin.defaultValue.toInt()
edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt()
imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt()
}

View file

@ -0,0 +1,183 @@
package com.github.damontecres.wholphin.preferences
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.BaseItemKind
import kotlin.time.Duration.Companion.milliseconds
object ScreensaverPreference {
val Enabled =
AppSwitchPreference<AppPreferences>(
title = R.string.in_app_screensaver,
defaultValue = false,
getter = { it.interfacePreferences.screensaverPreference.enabled },
setter = { prefs, value ->
prefs.updateScreensaverPreferences { enabled = value }
},
summaryOn = R.string.yes,
summaryOff = R.string.no,
)
const val DEFAULT_START_DELAY = 15 * 60_000L
private val startDelayValues =
listOf(
30_000L,
60_000L,
2 * 60_000L,
5 * 60_000L,
10 * 60_000L,
15 * 60_000L,
30 * 60_000L,
60 * 60_000L,
)
val StartDelay =
AppSliderPreference<AppPreferences>(
title = R.string.start_after,
defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(),
min = 0,
max = startDelayValues.size - 1L,
interval = 1,
getter = {
startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong()
},
setter = { prefs, value ->
prefs.updateScreensaverPreferences {
startDelay = startDelayValues[value.toInt()]
}
},
summarizer = { value ->
if (value != null) {
val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY
v.milliseconds.toString()
} else {
null
}
},
)
const val DEFAULT_DURATION = 30_000L
private val durationValues =
listOf(
15_000L,
30_000L,
60_000L,
2 * 60_000L,
)
val Duration =
AppSliderPreference<AppPreferences>(
title = R.string.duration,
defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(),
min = 0,
max = durationValues.size - 1L,
interval = 1,
getter = {
durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong()
},
setter = { prefs, value ->
prefs.updateScreensaverPreferences {
duration = durationValues[value.toInt()]
}
},
summarizer = { value ->
if (value != null) {
val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION
v.milliseconds.toString()
} else {
null
}
},
)
val ShowClock =
AppSwitchPreference<AppPreferences>(
title = R.string.show_clock,
defaultValue = AppPreference.ShowClock.defaultValue,
getter = { it.interfacePreferences.screensaverPreference.showClock },
setter = { prefs, value ->
prefs.updateScreensaverPreferences { showClock = value }
},
summaryOn = R.string.yes,
summaryOff = R.string.no,
)
val Animate =
AppSwitchPreference<AppPreferences>(
title = R.string.animate,
defaultValue = true,
getter = { it.interfacePreferences.screensaverPreference.animate },
setter = { prefs, value ->
prefs.updateScreensaverPreferences { animate = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
const val DEFAULT_MAX_AGE = 16
private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1)
val MaxAge =
AppSliderPreference<AppPreferences>(
title = R.string.max_age_rating,
defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(),
min = 0,
max = maxAgeValues.size - 1L,
interval = 1,
getter = {
it.interfacePreferences.screensaverPreference.maxAgeFilter
.takeIf { it >= 0 }
?.let { maxAgeValues.indexOf(it).toLong() }
?: maxAgeValues.lastIndex.toLong()
},
setter = { prefs, value ->
prefs.updateScreensaverPreferences {
maxAgeFilter = maxAgeValues[value.toInt()]
}
},
summarizer = { value ->
when (value) {
null -> {
null
}
maxAgeValues.lastIndex.toLong() -> {
WholphinApplication.instance.getString(R.string.no_max)
}
0L -> {
WholphinApplication.instance.getString(R.string.for_all_ages)
}
else -> {
WholphinApplication.instance.getString(
R.string.up_to_age,
maxAgeValues[value.toInt()].toString(),
)
}
}
},
)
val ItemTypes =
AppMultiChoicePreference<AppPreferences, BaseItemKind>(
title = R.string.include_types,
summary = R.string.include_types_summary,
defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO),
displayValues = R.array.screensaver_item_types,
getter = {
it.interfacePreferences.screensaverPreference.itemTypesList.map { type ->
BaseItemKind.fromName(type)
}
},
setter = { prefs, value ->
prefs.updateScreensaverPreferences {
clearItemTypes()
addAllItemTypes(value.map { it.serialName })
}
},
)
val Start =
AppClickablePreference<AppPreferences>(
title = R.string.start_screensaver,
)
}

View file

@ -8,17 +8,22 @@ import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@ -213,4 +218,49 @@ suspend fun upgradeApp(
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
subtitlesPreferences =
subtitlesPreferences
.toBuilder()
.apply {
imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt()
}.build()
// Copy current subtitle prefs as HDR ones
hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build()
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) {
appPreferences.updateData {
it.updatePhotoPreferences {
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) {
appPreferences.updateData {
it.updateHomePagePreferences {
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) {
appPreferences.updateData {
it.updateScreensaverPreferences {
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
duration = ScreensaverPreference.DEFAULT_DURATION
animate = ScreensaverPreference.Animate.defaultValue
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
clearItemTypes()
addItemTypes(BaseItemKind.MOVIE.serialName)
addItemTypes(BaseItemKind.SERIES.serialName)
}
}
}
}

View file

@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import javax.inject.Inject
@ -47,7 +48,12 @@ class BackdropService
suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) {
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
val imageUrl =
if (item.type == BaseItemKind.GENRE) {
item.imageUrlOverride
} else {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
}
submit(item.id.toString(), imageUrl)
}

File diff suppressed because it is too large Load diff

View file

@ -28,15 +28,16 @@ class ImageUrlService
itemType: BaseItemKind,
seriesId: UUID?,
useSeriesForPrimary: Boolean,
imageTags: Map<ImageType, String?>,
imageType: ImageType,
imageTags: Map<ImageType, String?>,
backdropTags: List<String>,
parentThumbId: UUID? = null,
parentBackdropId: UUID? = null,
fillWidth: Int? = null,
fillHeight: Int? = null,
): String? =
when (imageType) {
ImageType.BACKDROP,
ImageType.LOGO,
-> {
ImageType.LOGO -> {
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
getItemImageUrl(
itemId = seriesId,
@ -54,8 +55,86 @@ class ImageUrlService
}
}
ImageType.BACKDROP,
-> {
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
getItemImageUrl(
itemId = seriesId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (backdropTags.isNotEmpty()) {
getItemImageUrl(
itemId = itemId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
ImageType.THUMB -> {
if (useSeriesForPrimary && parentThumbId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
) {
// Use parent's thumb
getItemImageUrl(
itemId = parentThumbId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (useSeriesForPrimary && parentBackdropId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
) {
// No parent thumb, so use backdrop instead
getItemImageUrl(
itemId = parentBackdropId,
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl(
itemId = parentThumbId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (useSeriesForPrimary &&
parentThumbId == null &&
itemType == BaseItemKind.EPISODE &&
imageType !in imageTags
) {
// Workaround to fall back to episode image if no parent thumb
getItemImageUrl(
itemId = itemId,
imageType = ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
// If no thumb, use backdrop if available
getItemImageUrl(
itemId = itemId,
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
getItemImageUrl(
itemId = itemId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
}
}
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BANNER,
-> {
if (useSeriesForPrimary && seriesId != null &&
@ -99,15 +178,19 @@ class ImageUrlService
imageType: ImageType,
fillWidth: Int? = null,
fillHeight: Int? = null,
useSeriesForPrimary: Boolean? = null,
): String? =
if (item != null) {
getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = item.data.seriesId,
useSeriesForPrimary = item.useSeriesForPrimary,
useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary,
imageTags = item.data.imageTags.orEmpty(),
imageType = imageType,
parentThumbId = item.data.parentThumbItemId,
parentBackdropId = item.data.parentBackdropItemId,
backdropTags = item.data.backdropImageTags.orEmpty(),
fillWidth = fillWidth,
fillHeight = fillHeight,
)

View file

@ -4,8 +4,6 @@ import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.main.LatestData
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.qualifiers.ApplicationContext
@ -21,6 +19,8 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
@ -44,6 +44,7 @@ class LatestNextUpService
userId: UUID,
limit: Int,
includeEpisodes: Boolean,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> {
val request =
GetResumeItemsRequest(
@ -60,13 +61,19 @@ class LatestNextUpService
remove(BaseItemKind.EPISODE)
}
},
enableImageTypes =
listOf(
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BACKDROP,
),
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.from(it, api, true) }
.map { BaseItem.from(it, api, useSeriesForPrimary) }
return items
}
@ -75,7 +82,11 @@ class LatestNextUpService
limit: Int,
enableRewatching: Boolean,
enableResumable: Boolean,
maxDays: Int,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> {
val nextUpDateCutoff =
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
val request =
GetNextUpRequest(
userId = userId,
@ -86,13 +97,14 @@ class LatestNextUpService
enableResumable = enableResumable,
enableUserData = true,
enableRewatching = enableRewatching,
nextUpDateCutoff = nextUpDateCutoff,
)
val nextUp =
api.tvShowsApi
.getNextUp(request)
.content
.items
.map { BaseItem.from(it, api, true) }
.map { BaseItem.from(it, api, useSeriesForPrimary) }
return nextUp
}
@ -188,3 +200,17 @@ class LatestNextUpService
return@withContext result
}
}
val supportedLatestCollectionTypes =
setOf(
CollectionType.MOVIES,
CollectionType.TVSHOWS,
CollectionType.HOMEVIDEOS,
// Exclude Live TV because a recording folder view will be used instead
null, // Recordings & mixed collection types
)
data class LatestData(
val title: String,
val request: GetLatestMediaRequest,
)

View file

@ -0,0 +1,110 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MediaManagementService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
) {
private val _deletedItemFlow =
MutableSharedFlow<DeletedItem>(
replay = 1,
extraBufferCapacity = 0,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* Listen for recently deleted items. Useful for ViewModels to react and refresh data
*/
val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow
suspend fun canDelete(item: BaseItem): Boolean {
val appPreferences = userPreferencesService.getCurrent().appPreferences
return canDelete(item, appPreferences)
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean {
Timber.v("canDelete %s: %s", item.id, item.canDelete)
val enabled = appPreferences.interfacePreferences.enableMediaManagement
return enabled &&
item.canDelete &&
if (item.type == BaseItemKind.RECORDING) {
serverRepository.currentUserDto.value
?.policy
?.enableLiveTvManagement == true
} else {
true
}
}
suspend fun deleteItem(item: BaseItem): DeleteResult {
try {
Timber.i("Deleting %s", item.id)
// TODO enable
api.libraryApi.deleteItem(item.id)
_deletedItemFlow.emit(DeletedItem(item))
return DeleteResult.Success
} catch (ex: Exception) {
Timber.e(ex, "Error deleting %s", item.id)
return DeleteResult.Error(ex)
}
}
}
data class DeletedItem(
val item: BaseItem,
)
sealed interface DeleteResult {
data object Success : DeleteResult
data class Error(
val ex: Exception,
) : DeleteResult
}
fun ViewModel.deleteItem(
context: Context,
mediaManagementService: MediaManagementService,
item: BaseItem,
onSuccess: () -> Unit = {},
) = viewModelScope.launchIO {
when (val r = mediaManagementService.deleteItem(item)) {
is DeleteResult.Error -> {
showToast(
context,
"Error deleting item: ${r.ex.localizedMessage}",
)
}
DeleteResult.Success -> {
showToast(context, "Deleted")
onSuccess.invoke()
}
}
}

View file

@ -0,0 +1,80 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.os.Build
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.clientLogApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MediaReportService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
private val clientInfo: ClientInfo,
private val deviceInfo: DeviceInfo,
private val deviceProfileService: DeviceProfileService,
@param:IoCoroutineScope private val ioScope: CoroutineScope,
) {
val json =
Json {
encodeDefaults = false
}
fun sendReportFor(itemId: UUID) {
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
val item = api.userLibraryApi.getItem(itemId = itemId).content
sendReportFor(item)
}
}
suspend fun sendReportFor(item: BaseItemDto) {
val sources =
item.mediaSources ?: api.userLibraryApi
.getItem(itemId = item.id)
.content.mediaSources
val sourcesJson = json.encodeToString(sources)
val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences
val serverVersion = serverRepository.currentServer.value?.serverVersion
val deviceProfile =
deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion)
val deviceProfileJson = json.encodeToString(deviceProfile)
val body =
"""
Send media info
serverVersion=$serverVersion
clientInfo=$clientInfo
deviceInfo=$deviceInfo
manufacturer=${Build.MANUFACTURER}
model=${Build.MODEL}
apiLevel=${Build.VERSION.SDK_INT}
playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")}
deviceProfile=$deviceProfileJson
mediaSources=$sourcesJson
""".trimIndent()
Timber.w(body)
val response by api.clientLogApi.logFile(body)
showToast(context, "Sent! Filename=${response.fileName}")
}
}

View file

@ -0,0 +1,194 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.lifecycle.asFlow
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NavDrawerService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:DefaultCoroutineScope private val coroutineScope: CoroutineScope,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
) {
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
val state: StateFlow<NavDrawerItemState> = _state
init {
serverRepository.currentUser
.asFlow()
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
Pair(user, userDto)
}.onEach { (user, userDto) ->
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
_state.update {
it.copy(
items = emptyList(),
moreItems = emptyList(),
)
}
if (user != null && userDto != null && user.id == userDto.id) {
updateNavDrawer(user, userDto)
}
}.catch { ex ->
Timber.e(ex, "Error updating nav drawer")
showToast(context, "Error fetching user's views")
}.launchIn(coroutineScope)
seerrServerRepository.active
.onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) }
}.launchIn(coroutineScope)
}
suspend fun getAllUserLibraries(
userId: UUID,
tvAccess: Boolean,
): List<Library> {
val userViews =
api.userViewsApi
.getUserViews(userId = userId)
.content.items
val recordingFolders =
if (tvAccess) {
api.liveTvApi
.getRecordingFolders(userId = userId)
.content.items
.map { it.id }
.toSet()
} else {
setOf()
}
val libraries =
userViews
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
.map {
Library(
itemId = it.id,
name = it.name ?: "",
type = it.type,
collectionType = it.collectionType ?: CollectionType.UNKNOWN,
isRecordingFolder = it.id in recordingFolders,
)
}
return libraries
}
suspend fun getFilteredUserLibraries(
user: JellyfinUser,
tvAccess: Boolean,
): List<Library> {
val pins =
serverPreferencesDao
.getNavDrawerPinnedItems(user)
.associateBy { it.itemId }
val libraries =
getAllUserLibraries(user.id, tvAccess)
.filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED }
return libraries
}
suspend fun updateNavDrawer(
user: JellyfinUser,
userDto: UserDto,
) {
val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
val libraries =
allLibraries
.map {
val destination =
if (it.isRecordingFolder) {
Destination.Recordings(it.itemId)
} else {
Destination.MediaItem(
it.itemId,
it.type,
it.collectionType,
)
}
ServerNavDrawerItem(
itemId = it.itemId,
name = it.name,
destination = destination,
type = it.collectionType,
)
}
val allItems = builtins + libraries
val navDrawerPins =
withContext(Dispatchers.IO) {
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
}
val items = mutableListOf<NavDrawerItem>()
val moreItems = mutableListOf<NavDrawerItem>()
allItems
// Sort by order if non-default, existing items before customize will have -1 value
// New items from the server will get Int.MAX_VALUE
// Items the user doesn't have access to anymore will be skipped
.sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE }
.forEach {
// Assume pinned if unknown
val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
if (pinned == NavPinType.PINNED) {
items.add(it)
} else {
moreItems.add(it)
}
}
_state.update {
it.copy(
items = items,
moreItems = moreItems,
)
}
}
}
data class NavDrawerItemState(
val items: List<NavDrawerItem>,
val moreItems: List<NavDrawerItem>,
val discoverEnabled: Boolean,
) {
companion object {
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
}
}
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.letNotEmpty
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import javax.inject.Inject
@ -12,6 +14,7 @@ import javax.inject.Singleton
class PeopleFavorites
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
) {
suspend fun getPeopleFor(item: BaseItem): List<Person> =
@ -27,7 +30,14 @@ class PeopleFavorites
val people =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, favorites[it.id] ?: false, api) }
people.map {
Person.fromDto(
context,
it,
favorites[it.id] ?: false,
api,
)
}
}.orEmpty()
people
}

View file

@ -21,7 +21,10 @@ class PlaybackLifecycleObserver
override fun onStart(owner: LifecycleOwner) {
val lastDest = navigationManager.backStack.lastOrNull()
if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) {
if (lastDest is Destination.Playback ||
lastDest is Destination.PlaybackList ||
lastDest is Destination.Slideshow
) {
navigationManager.goBack()
}
wasPlaying = null

View file

@ -6,17 +6,17 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.Display
import androidx.lifecycle.LiveData
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt
@ -28,25 +28,6 @@ class RefreshRateService
constructor(
@param:ApplicationContext private val context: Context,
) {
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
private val originalMode = display.mode
val supportedDisplayModes get() = display.supportedModes.orEmpty()
private val displayModes: List<DisplayMode> by lazy {
display.supportedModes
.orEmpty()
.map { DisplayMode(it) }
.sortedWith(
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
.thenBy { it.refreshRateRounded },
)
}
private val _refreshRateMode = EqualityMutableLiveData<Int>(originalMode.modeId)
val refreshRateMode: LiveData<Int> = _refreshRateMode
/**
* Find the best display mode for the given stream and signal to change to it
*/
@ -54,11 +35,23 @@ class RefreshRateService
stream: MediaStream,
switchRefreshRate: Boolean,
switchResolution: Boolean,
) {
) = withContext(Dispatchers.IO) {
if (!switchRefreshRate && !switchResolution) {
Timber.v("Not switching either refresh rate nor resolution")
return
return@withContext
}
val displayManager =
MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
val displayModes =
display.supportedModes
.orEmpty()
.map { DisplayMode(it) }
.sortedWith(
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
.thenBy { it.refreshRateRounded },
)
val currentDisplayMode = display.mode
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
val width = stream.width
@ -67,7 +60,7 @@ class RefreshRateService
if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate
if (width == null || height == null || frameRate == null) {
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
return
return@withContext
}
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
val targetMode =
@ -86,14 +79,20 @@ class RefreshRateService
listener,
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
)
_refreshRateMode.setValueOnMain(targetMode.modeId)
try {
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
MainActivity.instance.changeDisplayMode(targetMode.modeId)
val result =
withTimeoutOrNull(5.seconds) {
listener.deferred.await()
}
if (result == null) {
Timber.w("Timed out waiting for display change")
showToast(context, "Refresh rate switch is taking a long time")
}
} catch (ex: InterruptedException) {
} catch (ex: Exception) {
Timber.w(ex, "Exception waiting for refresh rate switch")
} finally {
displayManager.unregisterDisplayListener(listener)
}
val targetRate = (targetMode.refreshRate * 1000).roundToInt()
val isSeamless =
@ -110,7 +109,6 @@ class RefreshRateService
// Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate)
delay(2.seconds)
}
displayManager.unregisterDisplayListener(listener)
}
}
@ -118,13 +116,13 @@ class RefreshRateService
* Reset the display mode to the original
*/
fun resetRefreshRate() {
_refreshRateMode.value = originalMode.modeId
MainActivity.instance.changeDisplayMode(0)
}
private class Listener(
val displayId: Int,
) : DisplayManager.DisplayListener {
val latch = CountDownLatch(1)
val deferred = CompletableDeferred<Unit>()
override fun onDisplayAdded(displayId: Int) {
}
@ -132,7 +130,7 @@ class RefreshRateService
override fun onDisplayChanged(displayId: Int) {
if (displayId == this.displayId) {
Timber.v("Got display change for $displayId")
latch.countDown()
deferred.complete(Unit)
}
}
@ -160,33 +158,58 @@ class RefreshRateService
if (refreshRateSwitch) {
displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
.filter {
it.refreshRateRounded % streamRate == 0 || // Exact multiple
it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
}
.filter { frameRateMatches(it.refreshRateRounded, streamRate) }
} else {
displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
}
// Timber.v("display modes candidates: %s", candidates.joinToString("\n"))
return if (!resolutionSwitch) {
candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
candidates
.filter { it.refreshRateRounded == streamRate }
.maxByOrNull { it.physicalWidth * it.physicalHeight }
?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
} else {
// Exact resolution & frame rate
candidates.firstOrNull {
it.physicalWidth == streamWidth &&
it.physicalHeight == streamHeight &&
it.refreshRateRounded == streamRate
}
?: candidates.firstOrNull {
it.physicalWidth == streamWidth &&
// Next highest resolution, exact frame rate
?: candidates.lastOrNull {
it.physicalWidth >= streamWidth &&
it.physicalHeight >= streamHeight &&
it.refreshRateRounded == streamRate
}
// Exact resolution, acceptable frame rate
?: candidates.lastOrNull {
it.physicalWidth == streamWidth &&
it.physicalHeight == streamHeight &&
frameRateMatches(it.refreshRateRounded, streamRate)
}
// Next highest resolution, acceptable frame rate
?: candidates.lastOrNull {
it.physicalWidth >= streamWidth &&
it.physicalHeight >= streamHeight &&
frameRateMatches(it.refreshRateRounded, streamRate)
}
// Fall back to largest resolution, exact frame rate
?: candidates
.filter { it.refreshRateRounded == streamRate }
.maxByOrNull { it.physicalWidth * it.physicalHeight }
// Finally, just the highest resolution
?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
}
}
fun frameRateMatches(
refreshRateRounded: Int,
streamRate: Int,
): Boolean {
return refreshRateRounded % streamRate == 0 || // Exact multiple
refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
}
}
}

View file

@ -0,0 +1,218 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import coil3.imageLoader
import coil3.request.ImageRequest
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.components.CurrentItem
import com.github.damontecres.wholphin.ui.formatDate
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@Singleton
class ScreensaverService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:DefaultCoroutineScope private val scope: CoroutineScope,
private val api: ApiClient,
private val userPreferencesService: UserPreferencesService,
private val imageUrlService: ImageUrlService,
) {
private val _state = MutableStateFlow(ScreensaverState(false, false, false, false))
val state: StateFlow<ScreensaverState> = _state
val keepScreenOn = MutableStateFlow(false)
private var waitJob: Job? = null
init {
userPreferencesService.flow
.onEach { prefs ->
_state.update {
val enabled =
prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
keepScreenOnInternal(enabled)
ScreensaverState(enabled, false, false, false)
}
}.launchIn(scope)
}
fun pulse() {
waitJob?.cancel()
if (_state.value.enabled) {
// Timber.v("pulse")
_state.update {
if (!it.active) {
it.copy(active = false)
} else {
it
}
}
if (!_state.value.paused) {
waitJob =
scope.launch(ExceptionHandler()) {
val startDelay =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
delay(startDelay)
_state.update {
it.copy(active = true)
}
}
}
}
}
fun start() {
_state.update {
it.copy(
enabledTemp = true,
active = true,
)
}
}
fun stop(cancelJob: Boolean) {
_state.update {
it.copy(
enabledTemp = false,
active = false,
)
}
if (cancelJob) waitJob?.cancel()
}
fun keepScreenOn(keep: Boolean) {
scope.launchDefault {
val screensaverEnabled = _state.value.enabled
Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
if (screensaverEnabled) {
// Page is requesting to keep screen on, so we don't wait to show the screensaver
_state.update {
it.copy(active = false, paused = keep)
}
if (!keep) {
pulse()
}
} else {
keepScreenOnInternal(keep)
}
}
}
private fun keepScreenOnInternal(keep: Boolean) {
keepScreenOn.update { keep }
}
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
flow {
val prefs =
userPreferencesService.flow
.first()
.appPreferences
.interfacePreferences.screensaverPreference
val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
val request =
GetItemsRequest(
recursive = true,
includeItemTypes = itemTypes,
imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
sortBy = listOf(ItemSortBy.RANDOM),
maxOfficialRating = maxAge?.toString(),
hasParentalRating = maxAge?.let { true },
)
val pager =
ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
Timber.v("Got %s items", pager.size)
var index = 0
if (pager.isEmpty()) {
emit(null)
} else {
while (true) {
val item = pager.getBlocking(index)
Timber.v("Next index=%s, item=%s", index, item?.id)
if (item != null) {
val backdropUrl =
if (item.type == BaseItemKind.PHOTO) {
api.libraryApi.getDownloadUrl(item.id)
} else {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
}
val title =
if (item.type == BaseItemKind.PHOTO) {
item.data.premiereDate?.let {
formatDate(it.toLocalDate())
}
} else {
item.title
}
val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO)
if (backdropUrl != null) {
try {
context.imageLoader
.enqueue(
ImageRequest
.Builder(context)
.data(backdropUrl)
.build(),
).job
.await()
emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
} catch (_: CancellationException) {
break
}
val duration =
userPreferencesService
.getCurrent()
.appPreferences
.interfacePreferences.screensaverPreference.duration.milliseconds
delay(duration)
}
}
index++
}
}
}.flowOn(Dispatchers.Default).cancellable()
}
data class ScreensaverState(
val enabled: Boolean,
val enabledTemp: Boolean,
val active: Boolean,
val paused: Boolean,
) {
val show get() = (enabled || enabledTemp) && active && !paused
}

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings
import com.github.damontecres.wholphin.api.seerr.model.User
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.SeerrServer
@ -24,8 +25,10 @@ import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
@ -44,18 +47,33 @@ class SeerrServerRepository
private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
private val _current = MutableStateFlow<CurrentSeerr?>(null)
val current: StateFlow<CurrentSeerr?> = _current
val currentServer: Flow<SeerrServer?> = current.map { it?.server }
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
private val _connection =
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
val connection: StateFlow<SeerrConnectionStatus> = _connection
val current: Flow<CurrentSeerr?> =
_connection.map { (it as? SeerrConnectionStatus.Success)?.current }
val currentServer: Flow<SeerrServer?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow<SeerrUser?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
/**
* Whether Seerr integration is currently active of not
*/
val active: Flow<Boolean> = current.map { it != null && seerrApi.active }
val active: Flow<Boolean> =
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
fun clear() {
_current.update { null }
_connection.update { SeerrConnectionStatus.NotConfigured }
seerrApi.update("", null)
}
fun error(
serverUrl: String,
exception: Exception,
) {
_connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
seerrApi.update("", null)
}
@ -65,8 +83,10 @@ class SeerrServerRepository
userConfig: SeerrUserConfig,
) {
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
_current.update {
CurrentSeerr(server, user, userConfig, publicSettings)
_connection.update {
SeerrConnectionStatus.Success(
CurrentSeerr(server, user, userConfig, publicSettings),
)
}
}
@ -145,7 +165,7 @@ class SeerrServerRepository
apiKey,
okHttpClient
.newBuilder()
.connectTimeout(5.seconds)
.connectTimeout(2.seconds)
.readTimeout(6.seconds)
.build(),
)
@ -153,10 +173,11 @@ class SeerrServerRepository
return LoadingState.Success
}
suspend fun removeServer() {
val current = _current.value ?: return
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
suspend fun removeServerForCurrentUser(): Boolean {
val current = current.firstOrNull() ?: return false
val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
clear()
return rows > 0
}
}
@ -165,6 +186,19 @@ class SeerrServerRepository
*/
typealias SeerrUserConfig = User
sealed interface SeerrConnectionStatus {
data object NotConfigured : SeerrConnectionStatus
data class Error(
val serverUrl: String,
val ex: Exception,
) : SeerrConnectionStatus
data class Success(
val current: CurrentSeerr,
) : SeerrConnectionStatus
}
data class CurrentSeerr(
val server: SeerrServer,
val user: SeerrUser,
@ -226,6 +260,7 @@ class UserSwitchListener
private val seerrServerRepository: SeerrServerRepository,
private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi,
private val homeSettingsService: HomeSettingsService,
) {
init {
context as AppCompatActivity
@ -233,43 +268,58 @@ class UserSwitchListener
serverRepository.currentUser.asFlow().collect { user ->
Timber.d("New user")
seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.firstOrNull()
?.let { seerrUser ->
val server = seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
try {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} catch (ex: Exception) {
Timber.w(ex, "Error logging into %s", server.url)
seerrServerRepository.clear()
return@let
}
} else {
try {
seerrApi.api.usersApi.authMeGet()
} catch (ex: Exception) {
Timber.w(ex, "Error logging into %s", server.url)
seerrServerRepository.clear()
return@let
}
}
seerrServerRepository.set(server, seerrUser, userConfig)
}
}
switchUser(user)
}
}
}
}
private suspend fun switchUser(user: JellyfinUser) =
supervisorScope {
// Check for home settings
launchIO {
homeSettingsService.loadCurrentSettings(user.id)
}
// Check for seerr server
launchIO {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.lastOrNull()
?.let { seerrUser ->
val server =
seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
try {
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} else {
seerrApi.api.usersApi.authMeGet()
}
seerrServerRepository.set(
server,
seerrUser,
userConfig,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server.url, ex)
}
}
}
}
}
}

View file

@ -17,7 +17,7 @@ import javax.inject.Singleton
class SetupNavigationManager
@Inject
constructor() {
var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading)
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
/**
* Go to the specified [SetupDestination]

View file

@ -0,0 +1,100 @@
package com.github.damontecres.wholphin.services
import androidx.lifecycle.asFlow
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
sealed interface SuggestionsResource {
data object Loading : SuggestionsResource
data class Success(
val items: List<BaseItem>,
) : SuggestionsResource
data object Empty : SuggestionsResource
}
@Singleton
class SuggestionService
@Inject
constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val cache: SuggestionsCache,
private val workManager: WorkManager,
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun getSuggestionsFlow(
parentId: UUID,
itemKind: BaseItemKind,
): Flow<SuggestionsResource> {
return serverRepository.currentUser
.asFlow()
.flatMapLatest { user ->
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
val cachedSuggestions = cache.get(userId, parentId, itemKind)
if (cachedSuggestions == null) {
workManager
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
.map { workInfos ->
val isActive =
workInfos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
}
} else if (cachedSuggestions.ids.isEmpty()) {
flowOf(SuggestionsResource.Empty)
} else {
flow {
try {
emit(
SuggestionsResource.Success(
fetchItemsByIds(cachedSuggestions.ids, itemKind),
),
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
}
}
}
private suspend fun fetchItemsByIds(
ids: List<UUID>,
itemKind: BaseItemKind,
): List<BaseItem> {
val isSeries = itemKind == BaseItemKind.SERIES
val request =
GetItemsRequest(
ids = ids,
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW),
)
return GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem.from(it, api, isSeries) }
}
}

View file

@ -0,0 +1,108 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.ui.toServerString
import com.mayakapps.kache.InMemoryKache
import com.mayakapps.kache.ObjectKache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import timber.log.Timber
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Serializable
data class CachedSuggestions(
val ids: List<UUID>,
)
@Singleton
class SuggestionsCache
@Inject
constructor(
@param:ApplicationContext private val context: Context,
) {
private val cacheDir: File
get() = File(context.cacheDir, "suggestions")
private val json = Json { ignoreUnknownKeys = true }
private val mutex = Mutex()
private val memoryCache =
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
creationScope = CoroutineScope(Dispatchers.IO)
}
private fun cacheKey(
userId: UUID,
libraryId: UUID,
itemKind: BaseItemKind,
) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}"
@OptIn(ExperimentalSerializationApi::class)
suspend fun get(
userId: UUID,
libraryId: UUID,
itemKind: BaseItemKind,
): CachedSuggestions? {
val key = cacheKey(userId, libraryId, itemKind)
return memoryCache.getOrPut(key) {
mutex.withLock {
try {
val cacheFile = File(cacheDir, "$key.json")
if (!cacheFile.exists()) {
return@withLock null
}
cacheFile.inputStream().use {
json.decodeFromStream<CachedSuggestions>(it)
}
} catch (ex: Exception) {
Timber.e(ex, "Exception reading from disk cache")
null
}
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun put(
userId: UUID,
libraryId: UUID,
itemKind: BaseItemKind,
ids: List<UUID>,
) {
val key = cacheKey(userId, libraryId, itemKind)
val suggestions = CachedSuggestions(ids)
memoryCache.put(key, suggestions)
try {
cacheDir.mkdirs()
mutex.withLock {
File(cacheDir, "$key.json").outputStream().use {
json.encodeToStream(suggestions, it)
}
}
} catch (ex: Exception) {
Timber.e(ex, "Exception writing to disk cache")
}
}
}
fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L
fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty()
suspend fun <T : Any> ObjectKache<T, *>.containsKey(key: T): Boolean = get(key) != null

View file

@ -0,0 +1,122 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@ActivityScoped
class SuggestionsSchedulerService
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val workManager: WorkManager,
) {
private val activity =
(context as? AppCompatActivity)
?: throw IllegalStateException(
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
)
// Exposed for testing
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
init {
serverRepository.current.observe(activity) { user ->
Timber.v("New user %s", user?.user?.id)
if (user == null) {
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
} else {
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
scheduleWork(user.user.id, user.server.id)
}
}
}
}
private suspend fun scheduleWork(
userId: UUID,
serverId: UUID,
) {
val workInfos =
withContext(dispatcher) {
workManager
.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME)
.get()
}
val activeWork =
workInfos.firstOrNull {
!it.state.isFinished
}
val scheduledUserId =
activeWork
?.tags
?.firstOrNull { it.startsWith("user:") }
?.removePrefix("user:")
val isAlreadyScheduledForUser = scheduledUserId == userId.toString()
if (isAlreadyScheduledForUser) {
Timber.d("SuggestionsWorker already scheduled for user %s", userId)
return
}
val constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val inputData =
workDataOf(
SuggestionsWorker.PARAM_USER_ID to userId.toString(),
SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(),
)
val periodicWorkRequestBuilder =
PeriodicWorkRequestBuilder<SuggestionsWorker>(
repeatInterval = 12.hours.toJavaDuration(),
flexTimeInterval = 1.hours.toJavaDuration(),
).setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(),
).setInputData(inputData)
.addTag("user:$userId")
val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L)
periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration())
Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay")
workManager.enqueueUniquePeriodicWork(
uniqueWorkName = SuggestionsWorker.WORK_NAME,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
request = periodicWorkRequestBuilder.build(),
)
}
}

View file

@ -0,0 +1,281 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import kotlin.coroutines.CoroutineContext
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
@HiltWorker
class SuggestionsWorker
@AssistedInject
constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val serverRepository: ServerRepository,
private val preferences: DataStore<AppPreferences>,
private val api: ApiClient,
private val cache: SuggestionsCache,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Timber.d("Start")
val serverId = inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
val userId = inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
var currentUser = serverRepository.current.value
if (currentUser == null) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
Timber.w("No user found during run")
return Result.failure()
}
}
try {
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val itemsPerRow =
prefs.homePagePreferences.maxItemsPerRow
.takeIf { it > 0 }
?: AppPreference.HomePageItems.defaultValue.toInt()
val views =
api.userViewsApi
.getUserViews(userId = userId)
.content.items
.orEmpty()
if (views.isEmpty()) {
return Result.success()
}
val results =
supervisorScope {
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
views
.mapNotNull { view ->
val itemKind =
getTypeForCollection(view.collectionType)
?: return@mapNotNull null
async(Dispatchers.IO) {
runCatching {
Timber.v("Fetching suggestions for view %s", view.id)
val suggestions =
fetchSuggestions(
context,
view.id,
userId,
itemKind,
itemsPerRow,
)
ensureActive()
val newIds = suggestions.map { it.id }
val cachedIds = cache.get(userId, view.id, itemKind)?.ids
if (cachedIds == newIds) {
Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id)
return@runCatching
}
cache.put(
userId,
view.id,
itemKind,
newIds,
)
}.onFailure { e ->
Timber.e(
e,
"Failed to fetch suggestions for view %s",
view.id,
)
}
}
}.awaitAll()
}
val successCount = results.count { it.isSuccess }
val failureCount = results.count { it.isFailure }
if (failureCount > 0 && successCount == 0) {
Timber.w("All attempts failed ($failureCount views), scheduling retry")
return Result.retry()
}
Timber.d("Completed with $successCount successes and $failureCount failures")
return Result.success()
} catch (ex: ApiClientException) {
Timber.w(ex, "SuggestionsWorker ApiClientException, will retry")
return Result.retry()
} catch (e: Exception) {
Timber.e(e, "SuggestionsWorker failed")
return Result.failure()
}
}
private suspend fun fetchSuggestions(
coroutineContext: CoroutineContext,
parentId: UUID,
userId: UUID,
itemKind: BaseItemKind,
itemsPerRow: Int,
): List<BaseItemDto> =
coroutineScope {
val isSeries = itemKind == BaseItemKind.SERIES
val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind
val contextualLimit = (itemsPerRow * 0.4).toInt().coerceAtLeast(1)
val randomLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
val historyDeferred =
async(coroutineContext) {
fetchItems(
parentId = parentId,
userId = userId,
itemKind = historyItemType,
sortBy = ItemSortBy.DATE_PLAYED,
isPlayed = true,
limit = 20,
extraFields = listOf(ItemFields.GENRES),
).distinctBy { it.relevantId }.take(3)
}
val seedItems = historyDeferred.await()
val allGenreIds =
seedItems
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
.distinct()
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
val contextualDeferred =
async(coroutineContext) {
if (allGenreIds.isEmpty()) {
emptyList()
} else {
fetchItems(
parentId = parentId,
userId = userId,
itemKind = itemKind,
sortBy = ItemSortBy.RANDOM,
isPlayed = false,
limit = contextualLimit,
genreIds = allGenreIds,
excludeItemIds = excludeIds.toList(),
)
}
}
val randomDeferred =
async(coroutineContext) {
fetchItems(
parentId = parentId,
userId = userId,
itemKind = itemKind,
sortBy = ItemSortBy.RANDOM,
isPlayed = false,
limit = randomLimit,
)
}
val freshDeferred =
async(coroutineContext) {
fetchItems(
parentId = parentId,
userId = userId,
itemKind = itemKind,
sortBy = ItemSortBy.DATE_CREATED,
sortOrder = SortOrder.DESCENDING,
isPlayed = false,
limit = freshLimit,
)
}
withContext(Dispatchers.Default) {
val contextual = contextualDeferred.await()
val random = randomDeferred.await()
val fresh = freshDeferred.await()
(contextual + fresh + random)
.asSequence()
.distinctBy { it.id }
.filterNot { excludeIds.contains(it.relevantId) }
.toList()
.shuffled()
.take(itemsPerRow)
}
}
private suspend fun fetchItems(
parentId: UUID,
userId: UUID,
itemKind: BaseItemKind,
sortBy: ItemSortBy,
isPlayed: Boolean,
limit: Int,
sortOrder: SortOrder? = null,
genreIds: List<UUID>? = null,
excludeItemIds: List<UUID>? = null,
extraFields: List<ItemFields> = emptyList(),
): List<BaseItemDto> {
val request =
GetItemsRequest(
parentId = parentId,
userId = userId,
fields = extraFields,
includeItemTypes = listOf(itemKind),
genreIds = genreIds,
recursive = true,
isPlayed = isPlayed,
excludeItemIds = excludeItemIds,
sortBy = listOf(sortBy),
sortOrder = sortOrder?.let { listOf(it) },
limit = limit,
enableTotalRecordCount = false,
imageTypeLimit = 0,
)
return GetItemsRequestHandler
.execute(api, request)
.content.items
.orEmpty()
}
companion object {
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId"
fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
when (collectionType) {
CollectionType.MOVIES -> BaseItemKind.MOVIE
CollectionType.TVSHOWS -> BaseItemKind.SERIES
else -> null
}
}
}

View file

@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@ -15,6 +16,8 @@ class UserPreferencesService
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
) {
val flow = preferencesDataStore.data.map { UserPreferences(it) }
suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services.hilt
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
@ -9,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module
@ -16,6 +18,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -43,6 +46,18 @@ annotation class StandardOkHttpClient
@Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@ -56,12 +71,6 @@ object AppModule {
version = BuildConfig.VERSION_NAME,
)
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = androidDevice(context)
@StandardOkHttpClient
@Provides
@Singleton
@ -106,12 +115,12 @@ object AppModule {
@Singleton
fun okHttpFactory(
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = OkHttpFactory(okHttpClient)
) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
@Provides
@Singleton
fun jellyfin(
okHttpFactory: OkHttpFactory,
okHttpFactory: CoroutineContextApiClientFactory,
@ApplicationContext context: Context,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
@ -171,10 +180,35 @@ object AppModule {
}
}
@Provides
@Singleton
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Singleton
@IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun ioCoroutineScope(
@IoDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides
@Singleton
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides
@Singleton
@DefaultCoroutineScope
fun defaultCoroutineScope(
@DefaultDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides
@Singleton
fun workManager(
@ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context)
@Provides
@Singleton
@ -182,3 +216,13 @@ object AppModule {
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient)
}
@Module
@InstallIn(SingletonComponent::class)
object DeviceModule {
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = androidDevice(context)
}

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
@ -66,6 +67,10 @@ object DatabaseModule {
@Singleton
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
@Provides
@Singleton
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
@Provides
@Singleton
fun userPreferencesDataStore(

View file

@ -21,6 +21,7 @@ import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@ActivityScoped
@ -29,9 +30,9 @@ class TvProviderSchedulerService
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val workManager: WorkManager,
) {
private val activity = (context as AppCompatActivity)
private val workManager = WorkManager.getInstance(context)
private val supportsTvProvider =
// TODO <=25 has limited support
@ -60,7 +61,8 @@ class TvProviderSchedulerService
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
),
).build(),
).setInitialDelay(60.seconds.toJavaDuration())
.build(),
).await()
}
}

View file

@ -80,7 +80,7 @@ class TvProviderWorker
getPotentialItems(
userId,
prefs.homePagePreferences.enableRewatchingNextUp,
prefs.homePagePreferences.combineContinueNext,
prefs.homePagePreferences.maxDaysNextUp,
)
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
@ -144,19 +144,15 @@ class TvProviderWorker
private suspend fun getPotentialItems(
userId: UUID,
enableRewatching: Boolean,
combineContinueNext: Boolean,
maxDaysNextUp: Int,
): List<BaseItem> {
val resumeItems = latestNextUpService.getResume(userId, 10, true)
val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
val nextUpItems =
latestNextUpService
.getNextUp(userId, 10, enableRewatching, false)
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
return if (combineContinueNext) {
latestNextUpService.buildCombined(resumeItems, nextUpItems)
} else {
resumeItems + nextUpItems
}
return latestNextUpService.buildCombined(resumeItems, nextUpItems)
}
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =

View file

@ -5,7 +5,6 @@ import android.content.Context
import android.content.ContextWrapper
import android.media.AudioManager
import android.view.KeyEvent
import android.view.WindowManager
import android.widget.Toast
import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.basicMarquee
@ -46,7 +45,6 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
@ -296,7 +294,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) =
}
/**
* Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn].
* Tries to find the [Activity] for the given [Context]
*/
fun Context.findActivity(): Activity? {
if (this is Activity) {
@ -310,18 +308,6 @@ fun Context.findActivity(): Activity? {
return null
}
/**
* Keep the screen on for an [Activity]. Often used with [findActivity].
*/
fun Activity.keepScreenOn(keep: Boolean) {
Timber.v("Keep screen on: $keep")
if (keep) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
/**
* Selectively log errors from Coil image loading.
*
@ -391,6 +377,15 @@ fun CoroutineScope.launchIO(
block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
/**
* Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
*/
fun CoroutineScope.launchDefault(
context: CoroutineContext = ExceptionHandler(),
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = Dispatchers.Default + context, start = start, block = block)
/**
* Converts a UUID to the format used server-side (ie without hyphens).
*
@ -433,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
@Composable
fun rememberBackDropImage(item: BaseItem): String? {
val imageUrlService = LocalImageUrlService.current
return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) }
}
/**
* Check if this, coalescing nulls to zero, is greater than that
*/

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber
@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String?
append(productionYear.toString())
if (status == "Continuing") {
append(" - ")
append("Present")
append(WholphinApplication.instance.getString(R.string.series_continueing))
} else if (status == "Ended") {
endDate?.let {
if (it.year != productionYear) {
@ -167,19 +168,19 @@ fun listToDotString(
strings.forEachIndexed { index, string ->
append(string)
if (index != strings.lastIndex) dot()
communityRating?.let {
dot()
append(String.format(Locale.getDefault(), "%.1f", it))
appendInlineContent(id = "star")
}
criticRating?.let {
dot()
append("${it.toInt()}%")
if (it >= 60f) {
appendInlineContent(id = "fresh")
} else {
appendInlineContent(id = "rotten")
}
}
communityRating?.let {
dot()
append(String.format(Locale.getDefault(), "%.1f", it))
appendInlineContent(id = "star")
}
criticRating?.let {
dot()
append("${it.toInt()}%")
if (it >= 60f) {
appendInlineContent(id = "fresh")
} else {
appendInlineContent(id = "rotten")
}
}
}

View file

@ -57,6 +57,8 @@ val DefaultItemFields =
ItemFields.CHAPTERS,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
)
/**
@ -70,11 +72,22 @@ val SlimItemFields =
ItemFields.OVERVIEW,
ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
)
val PhotoItemFields =
DefaultItemFields +
listOf(
ItemFields.WIDTH,
ItemFields.HEIGHT,
)
object Cards {
val height2x3 = 172.dp
val heightEpisode = height2x3 * .75f
const val HEIGHT_2X3_DP = 172
val height2x3 = HEIGHT_2X3_DP.dp
const val HEIGHT_EPISODE = 128
val heightEpisode = HEIGHT_EPISODE.dp
val playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f
}

View file

@ -1,15 +1,19 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -41,6 +46,7 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.enableMarquee
import org.jellyfin.sdk.model.api.ImageType
/**
@ -60,6 +66,9 @@ fun BannerCard(
cardHeight: Dp = 120.dp,
aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null,
imageType: ImageType = ImageType.PRIMARY,
imageContentScale: ContentScale = ContentScale.FillBounds,
useSeriesForPrimary: Boolean = true,
) {
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
@ -74,19 +83,21 @@ fun BannerCard(
}
}
val imageUrl =
remember(item, fillHeight) {
remember(item, fillHeight, imageType, useSeriesForPrimary) {
if (item != null) {
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
fillWidth = null,
fillHeight = fillHeight,
)
item.imageUrlOverride
?: imageUrlService.getItemImageUrl(
item,
imageType,
fillWidth = null,
fillHeight = fillHeight,
useSeriesForPrimary = useSeriesForPrimary,
)
} else {
null
}
}
var imageError by remember { mutableStateOf(false) }
var imageError by remember(imageUrl) { mutableStateOf(false) }
Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick,
@ -107,7 +118,7 @@ fun BannerCard(
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.FillBounds,
contentScale = imageContentScale,
onError = { imageError = true },
modifier = Modifier.fillMaxSize(),
)
@ -115,7 +126,7 @@ fun BannerCard(
Text(
text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier =
Modifier
@ -181,3 +192,84 @@ fun BannerCard(
}
}
}
@Composable
fun BannerCardWithTitle(
title: String?,
subtitle: String?,
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
cornerText: String? = null,
played: Boolean = false,
favorite: Boolean = false,
playPercent: Double = 0.0,
cardHeight: Dp = 120.dp,
aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
imageType: ImageType = ImageType.PRIMARY,
imageContentScale: ContentScale = ContentScale.FillBounds,
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = cardHeight * aspectRationToUse
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.width(width),
) {
BannerCard(
name = null,
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier,
cornerText = cornerText,
played = played,
favorite = favorite,
playPercent = playPercent,
cardHeight = cardHeight,
aspectRatio = aspectRatio,
interactionSource = interactionSource,
imageType = imageType,
imageContentScale = imageContentScale,
useSeriesForPrimary = useSeriesForPrimary,
)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = title ?: "",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = subtitle ?: "",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -34,6 +34,7 @@ fun ChapterRow(
text = stringResource(R.string.chapters),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = rememberLazyListState(),
@ -41,7 +42,6 @@ fun ChapterRow(
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(),
) {

View file

@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -183,6 +184,8 @@ fun DiscoverItemCard(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@ -193,6 +196,8 @@ fun DiscoverItemCard(
text = item?.releaseDate?.year?.toString() ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()

View file

@ -22,11 +22,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
@ -130,6 +132,8 @@ fun EpisodeCard(
text = dto?.seriesName ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@ -140,6 +144,8 @@ fun EpisodeCard(
text = item?.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()

View file

@ -42,11 +42,29 @@ fun GenreCard(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = GenreCard(
genreId = genre?.id,
name = genre?.name,
imageUrl = genre?.imageUrl,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = interactionSource,
)
@Composable
fun GenreCard(
genreId: UUID?,
name: String?,
imageUrl: String?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val background = rememberIdColor(genre?.id).copy(alpha = .6f)
val background = rememberIdColor(genreId).copy(alpha = .6f)
Card(
modifier =
modifier,
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
@ -63,12 +81,12 @@ fun GenreCard(
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
) {
if (genre?.imageUrl.isNotNullOrBlank()) {
if (imageUrl != null) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(genre.imageUrl)
.data(imageUrl)
.crossfade(true)
.build(),
contentScale = ContentScale.FillBounds,
@ -88,7 +106,7 @@ fun GenreCard(
.background(background),
) {
Text(
text = genre?.name ?: "",
text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
@ -112,7 +130,6 @@ private fun GenreCardPreview() {
UUID.randomUUID(),
"Adventure",
null,
Color.Black,
)
GenreCard(
genre = genre,

View file

@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -113,6 +114,8 @@ fun GridCard(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
@ -124,6 +127,8 @@ fun GridCard(
text = item?.subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@ -57,6 +58,7 @@ fun <T> ItemRow(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = state,

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -174,6 +175,8 @@ fun PersonCard(
text = name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@ -185,6 +188,8 @@ fun PersonCard(
text = role,
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()

View file

@ -46,6 +46,7 @@ fun PersonRow(
text = stringResource(title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = rememberLazyListState(),
@ -53,7 +54,6 @@ fun PersonRow(
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(firstFocus),
) {

View file

@ -16,15 +16,16 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
@ -126,21 +127,7 @@ fun SeasonCard(
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / aspectRationToUse)
@ -193,6 +180,8 @@ fun SeasonCard(
text = title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@ -203,6 +192,8 @@ fun SeasonCard(
text = subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
@ -212,3 +203,22 @@ fun SeasonCard(
}
}
}
/**
* Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while
*/
@Composable
fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State<Boolean> {
val focused by interactionSource.collectIsFocusedAsState()
val state = remember { mutableStateOf(false) }
LaunchedEffect(focused) {
if (!focused) {
state.value = false
return@LaunchedEffect
}
delay(500L)
state.value = true
}
return state
}

View file

@ -0,0 +1,215 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel
class ScreensaverViewModel
@Inject
constructor(
private val screensaverService: ScreensaverService,
val preferencesDataStore: DataStore<AppPreferences>,
) : ViewModel() {
val currentItem = screensaverService.createItemFlow(viewModelScope)
}
data class CurrentItem(
val item: BaseItem,
val backdropUrl: String,
val logoUrl: String?,
val title: String,
)
@Composable
fun AppScreensaver(
prefs: AppPreferences,
modifier: Modifier = Modifier,
viewModel: ScreensaverViewModel = hiltViewModel(),
) {
val currentItem by viewModel.currentItem.collectAsState(null)
AppScreensaverContent(
currentItem = currentItem,
showClock = prefs.interfacePreferences.screensaverPreference.showClock,
duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds,
animate = prefs.interfacePreferences.screensaverPreference.animate,
modifier = modifier,
)
}
@OptIn(ExperimentalCoilApi::class)
@Composable
fun AppScreensaverContent(
currentItem: CurrentItem?,
showClock: Boolean,
duration: Duration,
animate: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier
.background(Color.Black),
) {
val infiniteTransition = rememberInfiniteTransition()
val scale by infiniteTransition.animateFloat(
1f,
if (animate) 1.1f else 1f,
infiniteRepeatable(
tween(
durationMillis = duration.inWholeMilliseconds.toInt(),
delayMillis = 500,
easing = LinearEasing,
),
repeatMode = RepeatMode.Reverse,
),
)
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(currentItem?.backdropUrl)
.transitionFactory(CrossFadeFactory(2000.milliseconds))
.useExistingImageAsPlaceholder(true)
.build(),
contentDescription = null,
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
},
)
var logoError by remember(currentItem) { mutableStateOf(false) }
val alignment = listOf(Alignment.BottomStart, Alignment.BottomEnd).random()
if (!logoError) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(currentItem?.logoUrl)
.transitionFactory(CrossFadeFactory(750.milliseconds))
.build(),
contentDescription = "Logo",
onError = {
logoError = true
},
modifier =
Modifier
.align(alignment)
.size(width = 240.dp, height = 120.dp)
.padding(16.dp),
)
} else {
Box(
modifier =
Modifier
.align(alignment)
.padding(16.dp)
.fillMaxWidth(.5f)
.fillMaxHeight(.3f),
) {
Text(
text = currentItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displaySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(alignment),
)
}
}
val largeRadialGradient =
remember {
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
val biggerDimension = maxOf(size.height, size.width)
return RadialGradientShader(
colors = listOf(Color.Transparent, AppColors.TransparentBlack25),
center = size.center,
radius = biggerDimension / 1.5f,
colorStops = listOf(0f, 0.85f),
)
}
}
}
Canvas(Modifier.fillMaxSize()) {
drawRect(
brush = largeRadialGradient,
blendMode = BlendMode.Multiply,
)
if (showClock) {
// Add scrim to make clock more readable
drawRect(
brush =
Brush.verticalGradient(
colorStops =
arrayOf(
0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
TOP_SCRIM_END_FRACTION to Color.Transparent,
),
),
blendMode = BlendMode.Multiply,
)
}
}
if (showClock) {
TimeDisplay()
}
}
}

View file

@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -33,6 +33,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -41,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -55,10 +57,16 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.SlimItemFields
@ -71,15 +79,19 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -92,6 +104,9 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
@ -119,7 +134,11 @@ class CollectionFolderViewModel
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
val navigationManager: NavigationManager,
private val navigationManager: NavigationManager,
private val themeSongPlayer: ThemeSongPlayer,
private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
val mediaReportService: MediaReportService,
@Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?,
@Assisted("recursive") private val recursive: Boolean,
@ -155,9 +174,10 @@ class CollectionFolderViewModel
viewModelScope.launchIO {
super.itemId = itemId
try {
itemId.toUUIDOrNull()?.let {
fetchItem(it)
}
val item =
itemId.toUUIDOrNull()?.let {
fetchItem(it)
}
val libraryDisplayInfo =
serverRepository.currentUser.value?.let { user ->
@ -182,11 +202,42 @@ class CollectionFolderViewModel
}
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
.join()
// onResumePage()
} catch (ex: Exception) {
Timber.e(ex, "Error during init")
loading.setValueOnMain(DataLoadingState.Error(ex))
}
}
mediaManagementService.deletedItemFlow
.onEach { deletedItem ->
refreshAfterDelete(position, deletedItem.item)
}.catch { ex ->
Timber.e(ex, "Error refreshing after deleted item")
}.launchIn(viewModelScope)
}
private suspend fun refreshAfterDelete(
position: Int,
deletedItem: BaseItem,
) {
try {
val pager =
((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
position.let {
Timber.v("Item deleted: position=%s, id=%s", it, itemId)
val item = pager?.get(it)
// Exact item deleted (eg a movie) or deleted item was within the series
if (item?.id == deletedItem.id ||
equalsNotNull(item?.data?.id, deletedItem.data.seriesId)
) {
pager?.refreshPagesAfter(position)
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error refreshing after deleted item %s", itemId)
showToast(context, "Error refreshing after item deleted")
}
}
private fun saveLibraryDisplayInfo(
@ -252,34 +303,32 @@ class CollectionFolderViewModel
recursive: Boolean,
filter: GetItemsFilter,
useSeriesForPrimary: Boolean,
) {
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
if (resetState) {
loading.value = DataLoadingState.Loading
}
backgroundLoading.value = LoadingState.Loading
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
this@CollectionFolderViewModel.filter.value = filter
) = viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
if (resetState) {
loading.value = DataLoadingState.Loading
}
try {
val newPager =
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) {
loading.value = DataLoadingState.Success(newPager)
backgroundLoading.value = LoadingState.Success
}
} catch (ex: Exception) {
Timber.e(
ex,
"Exception while loading data: sort=%s, filter=%s",
sortAndDirection,
filter,
)
withContext(Dispatchers.Main) {
loading.value = DataLoadingState.Error(ex)
}
backgroundLoading.value = LoadingState.Loading
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
this@CollectionFolderViewModel.filter.value = filter
}
try {
val newPager =
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) {
loading.value = DataLoadingState.Success(newPager)
backgroundLoading.value = LoadingState.Success
}
} catch (ex: Exception) {
Timber.e(
ex,
"Exception while loading data: sort=%s, filter=%s",
sortAndDirection,
filter,
)
withContext(Dispatchers.Main) {
loading.value = DataLoadingState.Error(ex)
}
}
}
@ -344,7 +393,12 @@ class CollectionFolderViewModel
filter.applyTo(
GetItemsRequest(
parentId = item?.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
enableImageTypes =
listOf(
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BACKDROP,
),
includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = item?.let { listOf(item.id) },
@ -436,6 +490,46 @@ class CollectionFolderViewModel
backdropService.submit(item)
}
}
fun navigateTo(destination: Destination) {
release()
navigationManager.navigateTo(destination)
}
fun release() {
themeSongPlayer.stop()
}
fun onResumePage() {
viewModelScope.launchIO {
item.value?.let {
Timber.v("onResumePage: %s", loading.value!!::class)
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
val volume =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.playThemeSongs
themeSongPlayer.playThemeFor(it.id, volume)
}
}
}
}
fun deleteItem(
index: Int,
item: BaseItem,
) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchDefault {
refreshAfterDelete(index, item)
}
}
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
}
/**
@ -523,13 +617,14 @@ fun CollectionFolderGrid(
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
when (val state = loading) {
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
is DataLoadingState.Error,
@ -541,6 +636,13 @@ fun CollectionFolderGrid(
?: item?.data?.collectionType?.name
?: stringResource(R.string.collection)
Box(modifier = modifier) {
LifecycleResumeEffect(itemId) {
viewModel.onResumePage()
onPauseOrDispose {
viewModel.release()
}
}
CollectionFolderGridContent(
preferences = preferences,
initialPosition = viewModel.position,
@ -577,21 +679,45 @@ fun CollectionFolderGrid(
onSaveViewOptions = { viewModel.saveViewOptions(it) },
onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled,
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
onClickPlay = { index, item ->
val destination =
if (item.type == BaseItemKind.PHOTO_ALBUM) {
Destination.Slideshow(
parentId = item.id,
index = index,
filter = CollectionFolderFilter(filter = filter),
sortAndDirection = sortAndDirection,
recursive = true,
startSlideshow = true,
)
} else {
Destination.Playback(item)
}
viewModel.navigateTo(destination)
},
onClickPlayAll = { shuffle ->
itemId.toUUIDOrNull()?.let {
viewModel.navigationManager.navigateTo(
Destination.PlaybackList(
itemId = it,
startIndex = 0,
shuffle = shuffle,
recursive = recursive,
sortAndDirection = sortAndDirection,
filter = filter,
),
)
val destination =
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
Destination.Slideshow(
parentId = it,
index = 0,
filter = CollectionFolderFilter(filter = filter),
sortAndDirection = sortAndDirection,
recursive = true,
startSlideshow = true,
)
} else {
Destination.PlaybackList(
itemId = it,
startIndex = 0,
shuffle = shuffle,
recursive = recursive,
sortAndDirection = sortAndDirection,
filter = filter,
)
}
viewModel.navigateTo(destination)
}
},
)
@ -627,9 +753,10 @@ fun CollectionFolderGrid(
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
navigateTo = { viewModel.navigateTo(it) },
onClickWatch = { itemId, watched ->
viewModel.setWatched(position, itemId, watched)
},
@ -640,6 +767,10 @@ fun CollectionFolderGrid(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(it)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = PositionItem(position, item)
},
),
),
onDismissRequest = { moreDialog.makeAbsent() },
@ -664,8 +795,19 @@ fun CollectionFolderGrid(
elevation = 3.dp,
)
}
showDeleteDialog?.let { (position, item) ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(position, item)
showDeleteDialog = null
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CollectionFolderGridContent(
preferences: UserPreferences,
@ -728,8 +870,8 @@ fun CollectionFolderGridContent(
) {
AnimatedVisibility(
showHeader || loadingState !is DataLoadingState.Success,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
enter = expandVertically(),
exit = shrinkVertically(),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -810,14 +952,16 @@ fun CollectionFolderGridContent(
}
}
}
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
val density = LocalDensity.current
AnimatedVisibility(viewOptions.showDetails) {
HomePageHeader(
item = focusedItem,
modifier =
Modifier
.fillMaxWidth()
.height(140.dp)
.padding(16.dp),
.height(200.dp)
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
)
}
when (val state = loadingState) {
@ -825,7 +969,7 @@ fun CollectionFolderGridContent(
DataLoadingState.Loading,
-> {
// This shouldn't happen, so just show placeholder
Text("Loading")
Text(stringResource(R.string.loading))
}
is DataLoadingState.Error -> {
@ -863,6 +1007,15 @@ fun CollectionFolderGridContent(
},
columns = viewOptions.columns,
spacing = viewOptions.spacing.dp,
bringIntoViewSpec =
remember(viewOptions) {
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
if (viewOptions.showDetails) {
ScrollToTopBringIntoViewSpec(spacingPx)
} else {
defaultBringIntoViewSpec
}
},
)
AnimatedVisibility(showViewOptions) {
ViewOptionsDialog(

View file

@ -6,6 +6,8 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
@ -56,8 +64,12 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
import com.github.damontecres.wholphin.ui.playback.isDown
import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -259,31 +271,65 @@ fun DialogPopupContent(
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } }
LazyColumn(
state = listState,
modifier = Modifier,
) {
items(dialogItems) {
when (it) {
itemsIndexed(dialogItems) { index, item ->
when (item) {
is DialogItemDivider -> {
HorizontalDivider(Modifier.height(16.dp))
}
is DialogItem -> {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
ListItem(
selected = it.selected,
enabled = !waiting && it.enabled,
selected = item.selected,
enabled = !waiting && item.enabled,
onClick = {
if (dismissOnClick) {
onDismissRequest.invoke()
}
it.onClick.invoke()
item.onClick.invoke()
},
headlineContent = it.headlineContent,
overlineContent = it.overlineContent,
supportingContent = it.supportingContent,
leadingContent = it.leadingContent,
trailingContent = it.trailingContent,
modifier = Modifier,
headlineContent = item.headlineContent,
overlineContent = item.overlineContent,
supportingContent = item.supportingContent,
leadingContent = item.leadingContent,
trailingContent = item.trailingContent,
interactionSource = interactionSource,
modifier =
Modifier
.focusRequester(focusRequesters[index])
.ifElse(
index == 0,
Modifier.onKeyEvent {
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
listState.animateScrollToItem(dialogItems.lastIndex)
focusRequesters[dialogItems.lastIndex].tryRequestFocus()
}
return@onKeyEvent true
}
false
},
).ifElse(
index == dialogItems.lastIndex,
Modifier.onKeyEvent {
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
listState.animateScrollToItem(0)
focusRequesters[0].tryRequestFocus()
}
return@onKeyEvent true
}
false
},
),
)
}
}
@ -408,12 +454,13 @@ fun ConfirmDialog(
onConfirm: () -> Unit,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) = BasicDialog(
onDismissRequest = onCancel,
properties = properties,
elevation = elevation,
content = {
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier)
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
},
)
@ -427,6 +474,7 @@ fun ConfirmDialogContent(
onCancel: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -446,7 +494,7 @@ fun ConfirmDialogContent(
item {
Text(
text = body,
color = MaterialTheme.colorScheme.onSurface,
color = bodyColor,
)
}
}
@ -469,6 +517,80 @@ fun ConfirmDialogContent(
}
}
@Composable
fun ConfirmDeleteDialog(
itemTitle: String,
onCancel: () -> Unit,
onConfirm: () -> Unit,
) {
BasicDialog(
onDismissRequest = onCancel,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.wrapContentWidth(),
) {
item {
Text(
text = stringResource(R.string.delete_item),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
item {
Text(
text = itemTitle,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(bottom = 8.dp),
)
}
item {
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
modifier = Modifier,
) {
Button(
onClick = onCancel,
modifier = Modifier.width(72.dp),
) {
Text(
text = stringResource(R.string.cancel),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
Button(
onClick = onConfirm,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp),
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = Color.Red.copy(alpha = .8f),
modifier = Modifier,
)
Text(
text = stringResource(R.string.confirm),
)
}
}
}
}
}
}
}
fun chooseVersionParams(
context: Context,
sources: List<MediaSourceInfo>,
@ -534,6 +656,9 @@ fun chooseStream(
)
add(
DialogItem(
leadingContent = {
SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED)
},
headlineContent = {
Text(text = stringResource(R.string.only_forced_subtitles))
},

View file

@ -0,0 +1,78 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
/**
* Placeholder for [com.github.damontecres.wholphin.ui.cards.ItemRow]. It is [focusable] so it can be scrolled.
*/
@Composable
@NonRestartableComposable
fun FocusableItemRow(
title: String,
subtitle: String,
modifier: Modifier = Modifier,
isError: Boolean = false,
) = FocusableItemRow(
titleContent = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
},
subtitleContent = {
Text(
text = subtitle,
style = MaterialTheme.typography.titleMedium,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
},
modifier = modifier,
)
@Composable
fun FocusableItemRow(
titleContent: @Composable () -> Unit,
subtitleContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
val background by animateColorAsState(
if (focused) {
MaterialTheme.colorScheme.border.copy(alpha = .25f)
} else {
Color.Unspecified
},
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.padding(start = 8.dp)
.focusable(interactionSource = interactionSource)
.background(background, shape = RoundedCornerShape(8.dp))
.padding(8.dp),
) {
titleContent.invoke()
subtitleContent.invoke()
}
}

View file

@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.createGenreDestination
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -30,17 +29,18 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.mayakapps.kache.InMemoryKache
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -56,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.hours
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
class GenreViewModel
@ -102,51 +104,23 @@ class GenreViewModel
.execute(api, request)
.content.items
.map {
Genre(it.id, it.name ?: "", null, Color.Black)
Genre(it.id, it.name ?: "", null)
}
withContext(Dispatchers.Main) {
this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success
}
val genreToUrl = ConcurrentHashMap<UUID, String?>()
val semaphore = Semaphore(4)
genres
.map { genre ->
viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit {
val item =
GetItemsRequestHandler
.execute(
api,
GetItemsRequest(
parentId = itemId,
recursive = true,
limit = 1,
sortBy = listOf(ItemSortBy.RANDOM),
fields = listOf(ItemFields.GENRES),
imageTypes = listOf(ImageType.BACKDROP),
imageTypeLimit = 1,
includeItemTypes = includeItemTypes,
genreIds = listOf(genre.id),
enableTotalRecordCount = false,
),
).content.items
.firstOrNull()
if (item != null) {
genreToUrl[genre.id] =
imageUrlService.getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = null,
useSeriesForPrimary = true,
imageType = ImageType.BACKDROP,
imageTags = item.imageTags.orEmpty(),
fillWidth = cardWidthPx,
)
}
}
}
}.awaitAll()
val genreToUrl =
getGenreImageMap(
api = api,
userId = serverRepository.currentUser.value?.id,
scope = viewModelScope,
imageUrlService = imageUrlService,
genres = genres.map { it.id },
parentId = itemId,
includeItemTypes = includeItemTypes,
cardWidthPx = cardWidthPx,
)
val genresWithImages =
genres.map {
it.copy(
@ -171,11 +145,85 @@ class GenreViewModel
}
}
data class GenreCacheKey(
val userId: UUID?,
val parentId: UUID,
)
private val genreCache by lazy {
InMemoryKache<GenreCacheKey, Map<UUID, String?>>(8) {
expireAfterWriteDuration = 2.hours
}
}
suspend fun getGenreImageMap(
api: ApiClient,
userId: UUID?,
scope: CoroutineScope,
imageUrlService: ImageUrlService,
genres: List<UUID>,
parentId: UUID,
includeItemTypes: List<BaseItemKind>?,
cardWidthPx: Int?,
useCache: Boolean = true,
): Map<UUID, String?> {
val key = GenreCacheKey(userId, parentId)
if (useCache) {
genreCache.getIfAvailable(key)?.let {
Timber.v("Got cached entry")
return it
}
}
val genreToUrl = ConcurrentHashMap<UUID, String?>()
val semaphore = Semaphore(4)
genres
.map { genreId ->
scope.async(Dispatchers.IO) {
semaphore.withPermit {
val item =
GetItemsRequestHandler
.execute(
api,
GetItemsRequest(
userId = userId,
parentId = parentId,
recursive = true,
limit = 1,
sortBy = listOf(ItemSortBy.RANDOM),
fields = listOf(ItemFields.GENRES),
imageTypes = listOf(ImageType.BACKDROP),
imageTypeLimit = 1,
includeItemTypes = includeItemTypes,
genreIds = listOf(genreId),
enableTotalRecordCount = false,
),
).content.items
.firstOrNull()
if (item != null) {
genreToUrl[genreId] =
imageUrlService.getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = null,
useSeriesForPrimary = true,
imageType = ImageType.BACKDROP,
imageTags = item.imageTags.orEmpty(),
fillWidth = cardWidthPx,
backdropTags = item.backdropImageTags.orEmpty(),
)
}
}
}
}.awaitAll()
genreCache.put(key, genreToUrl)
return genreToUrl
}
@Stable
data class Genre(
val id: UUID,
val name: String,
val imageUrl: String?,
val color: Color,
) : CardGridItem {
override val gridId: String get() = id.toString()
override val playable: Boolean = false
@ -233,23 +281,12 @@ fun GenreCardGrid(
pager = genres,
onClickItem = { _, genre ->
viewModel.navigationManager.navigateTo(
Destination.FilteredCollection(
itemId = itemId,
filter =
CollectionFolderFilter(
nameOverride =
listOfNotNull(
genre.name,
item?.title,
).joinToString(" "),
filter =
GetItemsFilter(
genres = listOf(genre.id),
includeItemTypes = includeItemTypes,
),
useSavedLibraryDisplayInfo = false,
),
recursive = true,
createGenreDestination(
genreId = genre.id,
genreName = genre.name,
parentId = itemId,
parentName = item?.title,
includeItemTypes = includeItemTypes,
),
)
},

View file

@ -95,13 +95,13 @@ fun ItemGrid(
val items by viewModel.items.observeAsState(listOf())
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {

View file

@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
@ -34,12 +39,16 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
@ -63,12 +72,14 @@ fun ExpandablePlayButtons(
resumePosition: Duration,
watched: Boolean,
favorite: Boolean,
canDelete: Boolean,
trailers: List<Trailer>?,
playOnClick: (position: Duration) -> Unit,
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
deleteOnClick: () -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
@ -149,6 +160,16 @@ fun ExpandablePlayButtons(
)
}
}
if (canDelete) {
item("delete") {
DeleteButton(
onClick = deleteOnClick,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
}
// More button
item("more") {
@ -180,6 +201,63 @@ fun ExpandablePlayButton(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
mirrorIcon: Boolean = false,
enabled: Boolean = true,
) = ExpandablePlayButton(
title = title,
resume = resume,
icon = {
Icon(
imageVector = icon,
contentDescription = null,
modifier =
Modifier
.size(28.dp)
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
)
},
onClick = onClick,
modifier = modifier,
interactionSource = interactionSource,
enabled = enabled,
)
@Composable
fun ExpandablePlayButton(
@StringRes title: Int,
resume: Duration,
icon: Painter,
onClick: (position: Duration) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
mirrorIcon: Boolean = false,
enabled: Boolean = true,
) = ExpandablePlayButton(
title = title,
resume = resume,
icon = {
Icon(
painter = icon,
contentDescription = null,
modifier =
Modifier
.size(28.dp)
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
)
},
onClick = onClick,
modifier = modifier,
interactionSource = interactionSource,
enabled = enabled,
)
@Composable
fun ExpandablePlayButton(
@StringRes title: Int,
resume: Duration,
icon: @Composable BoxScope.() -> Unit,
onClick: (position: Duration) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
enabled: Boolean = true,
) {
val isFocused = interactionSource.collectIsFocusedAsState().value
Button(
@ -195,19 +273,13 @@ fun ExpandablePlayButton(
interactionSource = interactionSource,
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.padding(start = 2.dp, top = 2.dp)
.padding(start = 2.dp)
.height(MinButtonSize),
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier =
Modifier
.size(28.dp)
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
)
icon.invoke(this)
}
AnimatedVisibility(isFocused) {
Spacer(Modifier.size(8.dp))
@ -307,6 +379,48 @@ fun TrailerButton(
}
}
@Composable
fun DeleteButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focused by interactionSource.collectIsFocusedAsState()
val iconTint by
animateColorAsState(
targetValue =
if (focused) {
Color.Red.copy(alpha = .8f)
} else {
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.8f,
)
},
animationSpec =
tween(
easing = LinearEasing,
),
)
ExpandablePlayButton(
title = R.string.delete,
resume = Duration.ZERO,
icon = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current,
modifier =
Modifier
.padding(start = 2.dp)
.size(24.dp),
)
},
onClick = { onClick.invoke() },
interactionSource = interactionSource,
modifier = modifier,
)
}
@PreviewTvSpec
@Composable
private fun ExpandablePlayButtonsPreview() {
@ -322,6 +436,8 @@ private fun ExpandablePlayButtonsPreview() {
buttonOnFocusChanged = {},
trailers = listOf(),
trailerOnClick = {},
canDelete = true,
deleteOnClick = {},
modifier = Modifier,
)
}
@ -343,6 +459,13 @@ private fun ViewOptionsPreview() {
onClick = {},
interactionSource = source,
)
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = painterResource(R.drawable.baseline_pause_24),
onClick = {},
interactionSource = source,
)
ExpandableFaButton(
title = R.string.play,
iconStringRes = R.string.fa_eye,
@ -358,12 +481,19 @@ private fun ViewOptionsPreview() {
onClick = {},
modifier = Modifier,
)
DeleteButton(
onClick = {},
)
SortByButton(
sortOptions = listOf(),
current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
onSortChange = {},
)
}
DeleteButton(
onClick = {},
interactionSource = source,
)
}
}
}

View file

@ -19,10 +19,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
@ -30,15 +34,18 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID
@ -46,7 +53,9 @@ abstract class RecommendedViewModel(
val context: Context,
val navigationManager: NavigationManager,
val favoriteWatchManager: FavoriteWatchManager,
val mediaReportService: MediaReportService,
private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
) : ViewModel() {
abstract fun init()
@ -97,13 +106,13 @@ abstract class RecommendedViewModel(
abstract fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
)
): HomeRowLoadingState
fun update(
@StringRes title: Int,
block: suspend () -> List<BaseItem>,
) {
viewModelScope.launch(Dispatchers.IO) {
): Deferred<HomeRowLoadingState> =
viewModelScope.async(Dispatchers.IO) {
val titleStr = context.getString(title)
val row =
try {
@ -113,7 +122,25 @@ abstract class RecommendedViewModel(
}
update(title, row)
}
fun deleteItem(
position: RowColumn,
item: BaseItem,
) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchDefault {
val row = rows.value.getOrNull(position.row)
if (row is HomeRowLoadingState.Success) {
(row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column)
}
}
}
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
}
@Composable
@ -127,6 +154,7 @@ fun RecommendedContent(
val context = LocalContext.current
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
OneTimeLaunchedEffect {
@ -137,18 +165,20 @@ fun RecommendedContent(
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {
var position by rememberPosition()
HomePageContent(
homeRows = rows,
position = position,
onClickItem = { _, item ->
viewModel.navigationManager.navigateTo(item.destination())
},
@ -158,7 +188,21 @@ fun RecommendedContent(
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
},
onFocusPosition = onFocusPosition,
onFocusPosition = {
position = it
val nonEmptyRowBefore =
rows
.subList(0, it.row)
.count {
it is HomeRowLoadingState.Success && it.items.isEmpty()
}
onFocusPosition?.invoke(
RowColumn(
it.row - nonEmptyRowBefore,
it.column,
),
)
},
showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
modifier = modifier,
@ -177,6 +221,7 @@ fun RecommendedContent(
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
@ -190,6 +235,8 @@ fun RecommendedContent(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(it)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
),
),
onDismissRequest = { moreDialog.makeAbsent() },
@ -214,9 +261,19 @@ fun RecommendedContent(
elevation = 3.dp,
)
}
showDeleteDialog?.let { (position, item) ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(position, item)
showDeleteDialog = null
},
)
}
}
private data class RowColumnItem(
data class RowColumnItem(
val position: RowColumn,
val item: BaseItem,
)

View file

@ -14,7 +14,11 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService
import com.github.damontecres.wholphin.services.SuggestionsResource
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain
@ -22,7 +26,6 @@ import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
@ -30,6 +33,8 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
@ -42,7 +47,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.UUID
@ -54,11 +58,21 @@ class RecommendedMovieViewModel
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
private val suggestionService: SuggestionService,
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
mediaReportService: MediaReportService,
backdropService: BackdropService,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
mediaManagementService: MediaManagementService,
) : RecommendedViewModel(
context,
navigationManager,
favoriteWatchManager,
mediaReportService,
backdropService,
mediaManagementService,
) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel
@ -114,8 +128,10 @@ class RecommendedMovieViewModel
}
}
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
update(R.string.recently_released) {
val recentlyReleasedRequest =
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -128,13 +144,11 @@ class RecommendedMovieViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler
.execute(api, recentlyReleasedRequest)
.toBaseItems(api, false)
}
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}.also(jobs::add)
update(R.string.recently_added) {
val recentlyAddedRequest =
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -147,27 +161,11 @@ class RecommendedMovieViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler
.execute(api, recentlyAddedRequest)
.toBaseItems(api, false)
}
update(R.string.suggestions) {
val suggestionsRequest =
GetSuggestionsRequest(
userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.MOVIE),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetSuggestionsRequestHandler
.execute(api, suggestionsRequest)
.toBaseItems(api, false)
}
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}.also(jobs::add)
update(R.string.top_unwatched) {
val unwatchedTopRatedRequest =
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -181,13 +179,63 @@ class RecommendedMovieViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler
.execute(api, unwatchedTopRatedRequest)
.toBaseItems(api, false)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}.also(jobs::add)
viewModelScope.launch(Dispatchers.IO) {
try {
suggestionService
.getSuggestionsFlow(parentId, BaseItemKind.MOVIE)
.collect { resource ->
val state =
when (resource) {
is SuggestionsResource.Loading -> {
HomeRowLoadingState.Loading(
context.getString(R.string.suggestions),
)
}
is SuggestionsResource.Success -> {
HomeRowLoadingState.Success(
context.getString(R.string.suggestions),
resource.items,
)
}
is SuggestionsResource.Empty -> {
HomeRowLoadingState.Success(
context.getString(R.string.suggestions),
emptyList(),
)
}
}
update(R.string.suggestions, state)
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Failed to fetch suggestions")
update(
R.string.suggestions,
HomeRowLoadingState.Error(
title = context.getString(R.string.suggestions),
exception = ex,
),
)
}
}
// If the continue watching row is empty, then wait until the first successful row
// is loaded before telling the UI that the page is loaded
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
loading.setValueOnMain(LoadingState.Success)
for (i in 0..<jobs.size) {
val result = jobs[i].await()
if (result.completed) {
Timber.v("First success")
loading.setValueOnMain(LoadingState.Success)
}
break
}
}
}
}
@ -195,10 +243,11 @@ class RecommendedMovieViewModel
override fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
) {
): HomeRowLoadingState {
rows.update { current ->
current.toMutableList().apply { set(rowTitles[title]!!, row) }
}
return row
}
companion object {

View file

@ -14,7 +14,11 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService
import com.github.damontecres.wholphin.services.SuggestionsResource
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain
@ -23,7 +27,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
@ -31,6 +34,8 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
@ -45,7 +50,6 @@ import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.UUID
@ -58,11 +62,21 @@ class RecommendedTvShowViewModel
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
private val lastestNextUpService: LatestNextUpService,
private val suggestionService: SuggestionService,
@Assisted val parentId: UUID,
navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager,
mediaReportService: MediaReportService,
backdropService: BackdropService,
) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) {
mediaManagementService: MediaManagementService,
) : RecommendedViewModel(
context,
navigationManager,
favoriteWatchManager,
mediaReportService,
backdropService,
mediaManagementService,
) {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel
@ -86,7 +100,7 @@ class RecommendedTvShowViewModel
val userId = serverRepository.currentUser.value?.id
try {
val resumeItemsDeferred =
viewModelScope.async(Dispatchers.IO) {
async(Dispatchers.IO) {
val resumeItemsRequest =
GetResumeItemsRequest(
userId = userId,
@ -104,7 +118,7 @@ class RecommendedTvShowViewModel
}
val nextUpItemsDeferred =
viewModelScope.async(Dispatchers.IO) {
async(Dispatchers.IO) {
val nextUpRequest =
GetNextUpRequest(
userId = userId,
@ -116,19 +130,16 @@ class RecommendedTvShowViewModel
enableUserData = true,
enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp,
)
GetNextUpRequestHandler
.execute(api, nextUpRequest)
.toBaseItems(api, true)
}
val resumeItems = resumeItemsDeferred.await()
val nextUpItems = nextUpItemsDeferred.await()
if (combineNextUp) {
val combined =
lastestNextUpService.buildCombined(
resumeItems,
nextUpItems,
)
val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems)
update(
R.string.continue_watching,
HomeRowLoadingState.Success(
@ -138,10 +149,7 @@ class RecommendedTvShowViewModel
)
update(
R.string.next_up,
HomeRowLoadingState.Success(
context.getString(R.string.next_up),
listOf(),
),
HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()),
)
} else {
update(
@ -153,10 +161,7 @@ class RecommendedTvShowViewModel
)
update(
R.string.next_up,
HomeRowLoadingState.Success(
context.getString(R.string.next_up),
nextUpItems,
),
HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems),
)
}
@ -170,8 +175,10 @@ class RecommendedTvShowViewModel
}
}
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
update(R.string.recently_released) {
val recentlyReleasedRequest =
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -184,14 +191,11 @@ class RecommendedTvShowViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler
.execute(api, recentlyReleasedRequest)
.toBaseItems(api, true)
}
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
}.also(jobs::add)
update(R.string.recently_added) {
val recentlyAddedRequest =
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -204,29 +208,11 @@ class RecommendedTvShowViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler
.execute(api, recentlyAddedRequest)
.toBaseItems(api, true)
}
update(R.string.suggestions) {
val suggestionsRequest =
GetSuggestionsRequest(
userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.SERIES),
startIndex = 0,
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetSuggestionsRequestHandler
.execute(api, suggestionsRequest)
.toBaseItems(api, true)
}
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
}.also(jobs::add)
update(R.string.top_unwatched) {
val unwatchedTopRatedRequest =
val request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -240,13 +226,61 @@ class RecommendedTvShowViewModel
limit = itemsPerRow,
enableTotalRecordCount = false,
)
GetItemsRequestHandler
.execute(api, unwatchedTopRatedRequest)
.toBaseItems(api, true)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
}.also(jobs::add)
viewModelScope.launch(Dispatchers.IO) {
try {
suggestionService
.getSuggestionsFlow(parentId, BaseItemKind.SERIES)
.collect { resource ->
val state =
when (resource) {
is SuggestionsResource.Loading -> {
HomeRowLoadingState.Loading(
context.getString(R.string.suggestions),
)
}
is SuggestionsResource.Success -> {
HomeRowLoadingState.Success(
context.getString(R.string.suggestions),
resource.items,
)
}
is SuggestionsResource.Empty -> {
HomeRowLoadingState.Success(
context.getString(R.string.suggestions),
emptyList(),
)
}
}
update(R.string.suggestions, state)
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Failed to fetch suggestions")
update(
R.string.suggestions,
HomeRowLoadingState.Error(
title = context.getString(R.string.suggestions),
exception = ex,
),
)
}
}
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
loading.setValueOnMain(LoadingState.Success)
for (i in 0..<jobs.size) {
val result = jobs[i].await()
if (result is HomeRowLoadingState.Success) {
Timber.v("First success")
loading.setValueOnMain(LoadingState.Success)
}
break
}
}
}
}
@ -254,10 +288,11 @@ class RecommendedTvShowViewModel
override fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
) {
): HomeRowLoadingState {
rows.update { current ->
current.toMutableList().apply { set(rowTitles[title]!!, row) }
}
return row
}
companion object {

View file

@ -40,7 +40,7 @@ fun SliderBar(
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
interval: Int = 1,
color: Color = MaterialTheme.colorScheme.border,
colors: SliderColors = SliderColors.default(),
) {
val isFocused by interactionSource.collectIsFocusedAsState()
val animatedIndicatorHeight by animateDpAsState(
@ -49,9 +49,6 @@ fun SliderBar(
var currentValue by remember(value) { mutableLongStateOf(value) }
val percent = (currentValue - min).toFloat() / (max - min)
val activeColor = SliderActiveColor(isFocused)
val inactiveColor = SliderInactiveColor(isFocused)
val handleSeekEventModifier =
Modifier.handleDPadKeyEvents(
triggerOnAction = KeyEvent.ACTION_DOWN,
@ -91,14 +88,14 @@ fun SliderBar(
onDraw = {
val yOffset = size.height.div(2)
drawLine(
color = inactiveColor,
color = if (isFocused) colors.inactiveFocused else colors.inactiveUnfocused,
start = Offset(x = 0f, y = yOffset),
end = Offset(x = size.width, y = yOffset),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawLine(
color = activeColor,
color = if (isFocused) colors.activeFocused else colors.activeUnfocused,
start = Offset(x = 0f, y = yOffset),
end =
Offset(
@ -119,6 +116,24 @@ fun SliderBar(
}
}
data class SliderColors(
val activeFocused: Color,
val activeUnfocused: Color,
val inactiveFocused: Color,
val inactiveUnfocused: Color,
) {
companion object {
@Composable
fun default() =
SliderColors(
activeFocused = SliderActiveColor(true),
activeUnfocused = SliderActiveColor(false),
inactiveFocused = SliderInactiveColor(true),
inactiveUnfocused = SliderInactiveColor(false),
)
}
}
@Composable
fun SliderActiveColor(focused: Boolean): Color {
val theme = LocalTheme.current

View file

@ -4,6 +4,8 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -112,6 +115,7 @@ fun <T : CardGridItem> CardGrid(
},
columns: Int = 6,
spacing: Dp = 16.dp,
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
@ -269,100 +273,102 @@ fun <T : CardGridItem> CardGrid(
Box(
modifier = Modifier.weight(1f),
) {
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(spacing),
verticalArrangement = Arrangement.spacedBy(spacing),
state = gridState,
contentPadding = PaddingValues(16.dp),
modifier =
Modifier
.fillMaxSize()
.focusGroup()
.focusRestorer(firstFocus)
.focusProperties {
onExit = {
// Leaving the grid, so "forget" the position
CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) {
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(spacing),
verticalArrangement = Arrangement.spacedBy(spacing),
state = gridState,
contentPadding = PaddingValues(vertical = 16.dp),
modifier =
Modifier
.fillMaxSize()
.focusGroup()
.focusRestorer(firstFocus)
.focusProperties {
onExit = {
// Leaving the grid, so "forget" the position
// focusedIndex = -1
}
onEnter = {
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
focusedIndex = startPosition
}
}
},
) {
items(pager.size) { index ->
val mod =
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
Modifier
.focusRequester(firstFocus)
.focusRequester(gridFocusRequester)
.focusRequester(alphabetFocusRequester)
} else {
Modifier
}
val item = pager[index]
cardContent(
item,
{
if (item != null) {
focusedIndex = index
onClickItem.invoke(index, item)
}
},
{ if (item != null) onLongClickItem.invoke(index, item) },
mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
.onFocusChanged { focusState ->
if (DEBUG) {
Timber.v(
"$index isFocused=${focusState.isFocused}",
)
onEnter = {
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
focusedIndex = startPosition
}
}
if (focusState.isFocused) {
// Focused, so set that up
focusOn(index)
positionCallback?.invoke(columns, index)
} else if (focusedIndex == index) {
},
) {
items(pager.size) { index ->
val mod =
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
Modifier
.focusRequester(firstFocus)
.focusRequester(gridFocusRequester)
.focusRequester(alphabetFocusRequester)
} else {
Modifier
}
val item = pager[index]
cardContent(
item,
{
if (item != null) {
focusedIndex = index
onClickItem.invoke(index, item)
}
},
{ if (item != null) onLongClickItem.invoke(index, item) },
mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
.onFocusChanged { focusState ->
if (DEBUG) {
Timber.v(
"$index isFocused=${focusState.isFocused}",
)
}
if (focusState.isFocused) {
// Focused, so set that up
focusOn(index)
positionCallback?.invoke(columns, index)
} else if (focusedIndex == index) {
// savedFocusedIndex = index
// // Was focused on this, so mark unfocused
// focusedIndex = -1
}
},
)
}
},
)
}
}
}
if (pager.isEmpty()) {
if (pager.isEmpty()) {
// focusedIndex = -1
Box(modifier = Modifier.fillMaxSize()) {
Text(
text = stringResource(R.string.no_results),
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.align(Alignment.Center),
)
Box(modifier = Modifier.fillMaxSize()) {
Text(
text = stringResource(R.string.no_results),
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
if (showFooter) {
// Footer
Box(
modifier =
Modifier
.align(Alignment.BottomCenter)
.background(AppColors.TransparentBlack50),
) {
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
if (showFooter) {
// Footer
Box(
modifier =
Modifier
.align(Alignment.BottomCenter)
.background(AppColors.TransparentBlack50),
) {
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
// if (focusedIndex >= 0) {
// focusedIndex + 1
// } else {
// max(savedFocusedIndex, focusedIndexOnExit) + 1
// }
Text(
modifier = Modifier.padding(4.dp),
color = MaterialTheme.colorScheme.onBackground,
text = "$index / ${pager.size}",
)
Text(
modifier = Modifier.padding(4.dp),
color = MaterialTheme.colorScheme.onBackground,
text = "$index / ${pager.size}",
)
}
}
}
}
@ -394,7 +400,7 @@ fun <T : CardGridItem> CardGrid(
modifier =
Modifier
.align(Alignment.CenterVertically)
.padding(end = 16.dp),
.padding(start = 16.dp),
// Add end padding to push away from edge
letterClicked = { letter ->
scope.launch(ExceptionHandler()) {

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -40,9 +38,7 @@ fun CollectionFolderBoxSet(
recursive = recursive,
sortOptions = BoxSetSortOptions,
initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
modifier =
modifier
.padding(start = 16.dp),
modifier = modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
@ -45,15 +43,15 @@ fun CollectionFolderGeneric(
}
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
onClickItem = { index, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination(index))
},
itemId = itemId,
initialFilter = filter,
showTitle = showHeader,
recursive = recursive,
sortOptions = sortOptions,
modifier =
modifier
.padding(start = 16.dp),
modifier = modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -127,14 +125,14 @@ fun CollectionFolderLiveTv(
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
.padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs.map { it.title },
onClick = { selectedTabIndex = it },
@ -176,9 +174,7 @@ fun CollectionFolderLiveTv(
showTitle = false,
recursive = false,
sortOptions = VideoSortOptions,
modifier =
Modifier
.padding(start = 16.dp),
modifier = Modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -76,14 +74,14 @@ fun CollectionFolderMovie(
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
.padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs,
onClick = { selectedTabIndex = it },
@ -101,7 +99,6 @@ fun CollectionFolderMovie(
},
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)
@ -129,7 +126,6 @@ fun CollectionFolderMovie(
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
@ -162,7 +158,6 @@ fun CollectionFolderMovie(
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
@ -180,7 +175,6 @@ fun CollectionFolderMovie(
includeItemTypes = listOf(BaseItemKind.MOVIE),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)

View file

@ -0,0 +1,84 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.toServerString
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import java.util.UUID
@Composable
fun CollectionFolderPhotoAlbum(
preferences: UserPreferences,
itemId: UUID,
recursive: Boolean,
modifier: Modifier = Modifier,
filter: CollectionFolderFilter = CollectionFolderFilter(),
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
sortOptions: List<ItemSortBy> = VideoSortOptions,
// Note: making the VM here and passing down is bad practice, but we need the grid state when clicking on items
// TODO refactor this
viewModel: CollectionFolderViewModel =
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
key = itemId.toServerString(),
) {
it.create(
itemId = itemId.toServerString(),
initialSortAndDirection = null,
recursive = recursive,
collectionFilter = filter,
useSeriesForPrimary = false,
defaultViewOptions = ViewOptionsWide,
)
},
) {
var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid(
preferences = preferences,
onClickItem = { index, item ->
val destination =
if (item.type == BaseItemKind.PHOTO) {
Destination.Slideshow(
parentId = itemId,
index = index,
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
recursive = true,
startSlideshow = false,
)
} else {
item.destination(index)
}
viewModel.navigateTo(destination)
},
itemId = itemId.toServerString(),
initialFilter = filter,
showTitle = showHeader,
recursive = recursive,
sortOptions = sortOptions,
modifier = modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},
defaultViewOptions = ViewOptionsWide,
playEnabled = true,
filterOptions = filterOptions,
viewModel = viewModel,
)
}

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -35,9 +33,7 @@ fun CollectionFolderPlaylist(
showTitle = showHeader,
recursive = recursive,
sortOptions = PlaylistSortOptions,
modifier =
modifier
.padding(start = 16.dp),
modifier = modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -35,9 +33,7 @@ fun CollectionFolderRecordings(
showTitle = showHeader,
recursive = recursive,
sortOptions = MovieSortOptions,
modifier =
modifier
.padding(start = 16.dp),
modifier = modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -80,14 +78,14 @@ fun CollectionFolderTv(
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
.padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs,
onClick = { selectedTabIndex = it },
@ -105,7 +103,6 @@ fun CollectionFolderTv(
},
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)
@ -129,7 +126,6 @@ fun CollectionFolderTv(
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
@ -150,7 +146,6 @@ fun CollectionFolderTv(
includeItemTypes = listOf(BaseItemKind.SERIES),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
import android.util.Log
import android.view.Display
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
@ -32,11 +34,11 @@ import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -49,6 +51,7 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.clientLogApi
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStreamReader
import javax.inject.Inject
@ -59,13 +62,19 @@ class DebugViewModel
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
val refreshRateService: RefreshRateService,
val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo,
) : ViewModel() {
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
val supportedModes by lazy {
val displayManager =
MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
display.supportedModes.orEmpty()
}
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.rowId?.let {
@ -137,9 +146,13 @@ class DebugViewModel
Send App Logs
clientInfo=$clientInfo
deviceInfo=$deviceInfo
manufacturer=${Build.MANUFACTURER}
model=${Build.MODEL}
apiLevel=${Build.VERSION.SDK_INT}
""".trimIndent() + logcat
val response by api.clientLogApi.logFile(body)
""".trimIndent()
Timber.w(body)
val response by api.clientLogApi.logFile(body + logcat)
showToast(context, "Sent! Filename=${response.fileName}")
}
}
@ -253,8 +266,9 @@ fun DebugPage(
"DeviceInfo: ${viewModel.deviceInfo}",
"Manufacturer: ${Build.MANUFACTURER}",
"Model: ${Build.MODEL}",
"API Level: ${Build.VERSION.SDK_INT}",
"Display Modes:",
*viewModel.refreshRateService.supportedDisplayModes,
*viewModel.supportedModes,
).forEach {
Text(
text = it.toString(),

View file

@ -25,9 +25,11 @@ import kotlin.time.Duration.Companion.seconds
data class MoreDialogActions(
val navigateTo: (Destination) -> Unit,
var onClickWatch: (UUID, Boolean) -> Unit,
var onClickFavorite: (UUID, Boolean) -> Unit,
var onClickAddPlaylist: (UUID) -> Unit,
val onClickWatch: (UUID, Boolean) -> Unit,
val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit,
val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit,
)
enum class ClearChosenStreams {
@ -61,6 +63,7 @@ fun buildMoreDialogItems(
watched: Boolean,
favorite: Boolean,
canClearChosenStreams: Boolean,
canDelete: Boolean,
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
@ -138,6 +141,17 @@ fun buildMoreDialogItems(
actions.onClickAddPlaylist.invoke(item.id)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
actions.onClickDelete.invoke(item)
},
)
}
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
@ -205,6 +219,14 @@ fun buildMoreDialogItems(
)
},
)
add(
DialogItem(
text = R.string.send_media_info_log_to_server,
iconStringRes = R.string.fa_file_video,
) {
actions.onSendMediaInfo.invoke(item.id)
},
)
}
fun buildMoreDialogItemsForHome(
@ -214,6 +236,7 @@ fun buildMoreDialogItemsForHome(
playbackPosition: Duration,
watched: Boolean,
favorite: Boolean,
canDelete: Boolean,
actions: MoreDialogActions,
): List<DialogItem> =
buildList {
@ -281,6 +304,17 @@ fun buildMoreDialogItemsForHome(
actions.onClickAddPlaylist.invoke(itemId)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
actions.onClickDelete.invoke(item)
},
)
}
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
@ -314,6 +348,14 @@ fun buildMoreDialogItemsForHome(
},
)
}
add(
DialogItem(
text = R.string.send_media_info_log_to_server,
iconStringRes = R.string.fa_file_video,
) {
actions.onSendMediaInfo.invoke(itemId)
},
)
}
fun buildMoreDialogItemsForPerson(

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -98,8 +96,8 @@ fun FavoritesPage(
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,

View file

@ -298,9 +298,7 @@ fun PersonPageContent(
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
userScrollEnabled = !focusedOnHeader,
modifier =
modifier
.padding(start = 16.dp),
modifier = modifier,
) {
item {
PersonHeader(

View file

@ -373,7 +373,6 @@ fun PlaylistDetailsContent(
horizontalArrangement = Arrangement.spacedBy(24.dp),
modifier =
Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth(),
) {
PlaylistDetailsHeader(
@ -386,7 +385,7 @@ fun PlaylistDetailsContent(
getPossibleFilterValues = getPossibleFilterValues,
modifier =
Modifier
.padding(start = 16.dp, top = 80.dp)
.padding(top = 80.dp)
.fillMaxWidth(.25f),
)
when (loadingState) {

View file

@ -60,7 +60,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -102,23 +101,15 @@ fun DiscoverMovieDetails(
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
val moreActions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
onClickWatch = { itemId, watched -> },
onClickFavorite = { itemId, favorite -> },
onClickAddPlaylist = { itemId -> },
)
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {

View file

@ -65,7 +65,7 @@ fun DiscoverMovieDetailsHeader(
) {
val padding = 4.dp
val details =
remember(movie) {
remember(movie, rating) {
buildList {
movie.releaseDate?.let(::add)
movie.runtime
@ -89,6 +89,7 @@ fun DiscoverMovieDetailsHeader(
?.releaseDates
?.firstOrNull()
?.certification
?.takeIf { it.isNotNullOrBlank() }
?.let(::add)
}.let {
listToDotString(

View file

@ -73,7 +73,8 @@ class DiscoverMovieViewModel
val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config }
val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
val request4kEnabled =
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
init {
init()

View file

@ -105,13 +105,13 @@ fun DiscoverSeriesDetails(
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {
@ -463,7 +463,7 @@ fun DiscoverSeriesDetailsHeader(
) {
val padding = 4.dp
val details =
remember(series) {
remember(series, rating) {
buildList {
series.firstAirDate?.let(::add)
series.episodeRunTime

View file

@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -82,6 +83,7 @@ fun EpisodeDetails(
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val moreActions =
@ -97,17 +99,19 @@ fun EpisodeDetails(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it },
)
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {
@ -214,6 +218,7 @@ fun EpisodeDetails(
onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams)
},
canDelete = viewModel.canDelete,
),
)
},
@ -223,6 +228,8 @@ fun EpisodeDetails(
favoriteOnClick = {
viewModel.setFavorite(ep.id, !ep.favorite)
},
canDelete = viewModel.canDelete,
deleteOnClick = { showDeleteDialog = ep },
modifier = modifier,
)
}
@ -275,6 +282,16 @@ fun EpisodeDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
}
private const val HEADER_ROW = 0
@ -289,6 +306,8 @@ fun EpisodeDetailsContent(
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -303,7 +322,7 @@ fun EpisodeDetailsContent(
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
@ -346,6 +365,8 @@ fun EpisodeDetailsContent(
},
trailers = null,
trailerOnClick = {},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
modifier =
Modifier
.fillMaxWidth()

View file

@ -12,10 +12,13 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
@ -48,10 +51,12 @@ class EpisodeViewModel
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
val streamChoiceService: StreamChoiceService,
val mediaReportService: MediaReportService,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@ -63,6 +68,9 @@ class EpisodeViewModel
val item = MutableLiveData<BaseItem?>(null)
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
var canDelete: Boolean = false
private set
init {
init()
}
@ -93,6 +101,7 @@ class EpisodeViewModel
) {
val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await()
canDelete = mediaManagementService.canDelete(item)
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item
@ -197,4 +206,10 @@ class EpisodeViewModel
}
}
}
fun deleteItem(item: BaseItem) {
deleteItem(context, mediaManagementService, item) {
navigationManager.goBack()
}
}
}

View file

@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
@ -140,7 +142,11 @@ fun TvGuideGrid(
.fillMaxHeight(.30f),
)
}
AnimatedVisibility(focusedPosition.row < 1) {
AnimatedVisibility(
focusedPosition.row < 1,
enter = expandVertically(),
exit = shrinkVertically(),
) {
ExpandableFaButton(
title = R.string.view_options,
iconStringRes = R.string.fa_sliders,

View file

@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -111,6 +112,7 @@ fun MovieDetails(
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val moreActions =
MoreDialogActions(
@ -125,17 +127,19 @@ fun MovieDetails(
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it },
)
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {
@ -200,6 +204,7 @@ fun MovieDetails(
seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
canDelete = viewModel.canDelete,
actions = moreActions,
onChooseVersion = {
chooseVersion =
@ -290,6 +295,7 @@ fun MovieDetails(
playbackPosition = similar.playbackPosition,
watched = similar.played,
favorite = similar.favorite,
canDelete = false,
actions = moreActions,
)
moreDialog =
@ -309,6 +315,8 @@ fun MovieDetails(
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
canDelete = viewModel.canDelete,
deleteOnClick = { showDeleteDialog = movie },
modifier = modifier,
)
}
@ -361,6 +369,16 @@ fun MovieDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
}
private const val HEADER_ROW = 0
@ -394,6 +412,8 @@ fun MovieDetailsContent(
onLongClickSimilar: (Int, BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -410,7 +430,7 @@ fun MovieDetailsContent(
Box(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
@ -430,7 +450,7 @@ fun MovieDetailsContent(
modifier =
Modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 16.dp),
.padding(top = 40.dp, bottom = 16.dp),
)
ExpandablePlayButtons(
resumePosition = resumePosition,
@ -456,6 +476,8 @@ fun MovieDetailsContent(
position = TRAILER_ROW
trailerOnClick.invoke(it)
},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
modifier =
Modifier
.fillMaxWidth()

View file

@ -46,46 +46,47 @@ fun MovieDetailsHeader(
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
// Title
Text(
text = movie.name ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
modifier =
Modifier
.fillMaxWidth(.75f)
.padding(start = 8.dp),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
val padding = 4.dp
QuickDetails(
movie.ui.quickDetails,
movie.timeRemainingOrRuntime,
Modifier.padding(bottom = padding),
Modifier.padding(start = 8.dp),
)
dto.genres?.letNotEmpty {
GenreText(it, Modifier.padding(bottom = padding))
GenreText(it, Modifier.padding(start = 8.dp))
}
VideoStreamDetails(
chosenStreams = chosenStreams,
numberOfVersions = movie.data.mediaSourceCount ?: 0,
modifier = Modifier.padding(bottom = padding),
modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp),
)
dto.taglines?.firstOrNull()?.let { tagline ->
Text(
text = tagline,
style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic,
modifier = Modifier,
modifier = Modifier.padding(start = 8.dp),
)
}
@ -112,6 +113,7 @@ fun MovieDetailsHeader(
movie.data.people
?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() }
?.joinToString(", ") { it.name!! }
?.takeIf { it.isNotNullOrBlank() }
}
directorName
@ -120,6 +122,7 @@ fun MovieDetailsHeader(
text = stringResource(R.string.directed_by, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 8.dp),
)
}
}

View file

@ -18,6 +18,8 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrService
@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
@ -64,6 +67,7 @@ class MovieViewModel
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
val streamChoiceService: StreamChoiceService,
val mediaReportService: MediaReportService,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites,
@ -71,6 +75,7 @@ class MovieViewModel
private val extrasService: ExtrasService,
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@ -88,6 +93,9 @@ class MovieViewModel
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
var canDelete: Boolean = false
private set
init {
init()
}
@ -104,6 +112,7 @@ class MovieViewModel
api.userLibraryApi.getItem(itemId).content.let {
BaseItem.from(it, api)
}
canDelete = mediaManagementService.canDelete(item)
this@MovieViewModel.item.setValueOnMain(item)
item
}
@ -272,4 +281,10 @@ class MovieViewModel
}
}
}
fun deleteItem(item: BaseItem) {
deleteItem(context, mediaManagementService, item) {
navigationManager.goBack()
}
}
}

View file

@ -0,0 +1,256 @@
package com.github.damontecres.wholphin.ui.detail.search
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
import com.github.damontecres.wholphin.ui.main.SearchResult
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
fun SearchForContent(
searchType: BaseItemKind,
onClick: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchForViewModel = hiltViewModel(key = searchType.serialName),
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val state by viewModel.state.collectAsState()
var query by rememberSaveable { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
val focusRequester = remember { FocusRequester() }
var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
LifecycleResumeEffect(Unit) {
onPauseOrDispose {
viewModel.voiceInputManager.stopListening()
}
}
fun triggerImmediateSearch(searchQuery: String) {
immediateSearchQuery = searchQuery
viewModel.search(searchType, searchQuery)
}
LaunchedEffect(query) {
when {
immediateSearchQuery == query -> {
immediateSearchQuery = null
}
else -> {
delay(750L)
viewModel.search(searchType, query)
}
}
}
val titleRes =
remember {
when (searchType) {
BaseItemKind.BOX_SET -> R.string.collections
BaseItemKind.PLAYLIST -> R.string.playlists
else -> null
}
}
val title = titleRes?.let { stringResource(it) } ?: ""
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = stringResource(R.string.search_for, title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth(),
) {
var isSearchActive by remember { mutableStateOf(false) }
var isTextFieldFocused by remember { mutableStateOf(false) }
val textFieldFocusRequester = remember { FocusRequester() }
BackHandler(isTextFieldFocused) {
when {
isSearchActive -> {
isSearchActive = false
keyboardController?.hide()
}
else -> {
focusManager.moveFocus(FocusDirection.Next)
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.focusGroup()
.focusRestorer(textFieldFocusRequester)
.focusRequester(searchFocusRequester),
) {
VoiceSearchButton(
onSpeechResult = { spokenText ->
query = spokenText
triggerImmediateSearch(spokenText)
},
voiceInputManager = viewModel.voiceInputManager,
)
SearchEditTextBox(
value = query,
onValueChange = {
isSearchActive = true
query = it
},
onSearchClick = { triggerImmediateSearch(query) },
readOnly = !isSearchActive,
modifier =
Modifier
.focusRequester(textFieldFocusRequester)
.onFocusChanged { state ->
isTextFieldFocused = state.isFocused
if (!state.isFocused) isSearchActive = false
}.onPreviewKeyEvent { event ->
val isActivationKey =
event.key in listOf(Key.DirectionCenter, Key.Enter)
if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) {
isSearchActive = true
keyboardController?.show()
true
} else {
false
}
},
)
}
}
when (val st = state.results) {
is SearchResult.Error -> {
ErrorMessage("Error", st.ex)
}
SearchResult.NoQuery -> {
// no-op
}
SearchResult.Searching -> {
Text(
text = stringResource(R.string.searching),
)
}
is SearchResult.SuccessSeerr -> {
Text(
text = "Not supported",
color = MaterialTheme.colorScheme.error,
)
}
is SearchResult.Success -> {
if (st.items.isEmpty()) {
Text(
text = stringResource(R.string.no_results),
)
} else {
ItemRow(
title = "",
items = st.items,
onClickItem = { _, item -> onClick.invoke(item) },
onLongClickItem = { _, _ -> },
modifier = Modifier.focusRequester(focusRequester),
cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
modifier = mod,
)
},
)
}
}
}
}
}
@Composable
fun SearchForDialog(
onDismissRequest: () -> Unit,
searchType: BaseItemKind,
onClick: (BaseItem) -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
SearchForContent(
searchType = searchType,
onClick = onClick,
modifier =
Modifier
.padding(8.dp)
.fillMaxWidth(.8f)
.fillMaxHeight(.66f),
)
}
}

View file

@ -0,0 +1,71 @@
package com.github.damontecres.wholphin.ui.detail.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.SearchResult
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class SearchForViewModel
@Inject
constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
val navigationManager: NavigationManager,
val voiceInputManager: VoiceInputManager,
) : ViewModel() {
val state = MutableStateFlow(SearchForState())
init {
state.value = SearchForState()
}
fun search(
searchType: BaseItemKind,
query: String,
) {
viewModelScope.launchIO {
if (state.value.query != query) {
if (query.isBlank()) {
state.update { SearchForState(query, SearchResult.NoQuery) }
return@launchIO
}
state.update { SearchForState(query, SearchResult.Searching) }
try {
val request =
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
searchTerm = query,
includeItemTypes = listOf(searchType),
recursive = true,
fields = SlimItemFields,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
state.update { SearchForState(query, SearchResult.Success(pager)) }
} catch (ex: Exception) {
Timber.e(ex)
state.update { SearchForState(query, SearchResult.Error(ex)) }
}
}
}
}
}
data class SearchForState(
val query: String = "",
val results: SearchResult = SearchResult.NoQuery,
)

Some files were not shown because too many files have changed in this diff Show more