mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into develop/customize-home
This commit is contained in:
commit
2d77249099
54 changed files with 5411 additions and 185 deletions
|
|
@ -223,6 +223,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)
|
||||
|
|
@ -265,6 +266,7 @@ dependencies {
|
|||
implementation(libs.androidx.preference.ktx)
|
||||
implementation(libs.androidx.room.testing)
|
||||
implementation(libs.androidx.palette.ktx)
|
||||
implementation(libs.androidx.media3.effect)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
ksp(libs.hilt.android.compiler)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
|
|
@ -298,5 +300,7 @@ dependencies {
|
|||
|
||||
testImplementation(libs.mockk.android)
|
||||
testImplementation(libs.mockk.agent)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.androidx.core.testing)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ 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.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.core.DataStore
|
||||
|
|
@ -51,12 +52,14 @@ import com.github.damontecres.wholphin.services.RefreshRateService
|
|||
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.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||
|
|
@ -70,6 +73,7 @@ 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.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
|
|
@ -116,6 +120,9 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
|
||||
|
||||
@Inject
|
||||
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
||||
|
||||
// Note: unused but injected to ensure it is created
|
||||
@Inject
|
||||
lateinit var serverEventListener: ServerEventListener
|
||||
|
|
@ -133,7 +140,9 @@ class MainActivity : AppCompatActivity() {
|
|||
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
||||
lifecycle.addObserver(playbackLifecycleObserver)
|
||||
if (savedInstanceState == null) {
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
lifecycleScope.launchIO {
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
}
|
||||
}
|
||||
viewModel.serverRepository.currentUser.observe(this) { user ->
|
||||
if (user?.hasPin == true) {
|
||||
|
|
@ -148,6 +157,25 @@ class MainActivity : AppCompatActivity() {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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 = 30,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(3, 4),
|
||||
|
|
@ -50,6 +53,7 @@ import java.util.UUID
|
|||
AutoMigration(10, 11),
|
||||
AutoMigration(11, 12),
|
||||
AutoMigration(12, 20),
|
||||
AutoMigration(20, 30),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
@ -65,6 +69,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao
|
||||
|
||||
abstract fun seerrServerDao(): SeerrServerDao
|
||||
|
||||
abstract fun playbackEffectDao(): PlaybackEffectDao
|
||||
}
|
||||
|
||||
class Converters {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -107,6 +107,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()) }
|
||||
}
|
||||
|
|
@ -155,7 +161,7 @@ data class BaseItem(
|
|||
it.dayOfMonth.toString().padStart(2, '0')
|
||||
}?.toIntOrNull()
|
||||
|
||||
fun destination(): Destination {
|
||||
fun destination(index: Int? = null): Destination {
|
||||
val result =
|
||||
// Redirect episodes & seasons to their series if possible
|
||||
when (type) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -716,7 +716,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 =
|
||||
|
|
@ -909,6 +909,46 @@ sealed interface AppPreference<Pref, T> {
|
|||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1022,6 +1062,8 @@ val advancedPreferences =
|
|||
// AppPreference.NavDrawerSwitchOnFocus,
|
||||
AppPreference.ControllerTimeout,
|
||||
AppPreference.BackdropStylePref,
|
||||
AppPreference.SlideshowDuration,
|
||||
AppPreference.SlideshowPlayVideos,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -100,6 +100,12 @@ class AppPreferencesSerializer
|
|||
.apply {
|
||||
resetSubtitles()
|
||||
}.build()
|
||||
hdrSubtitlesPreferences =
|
||||
SubtitlePreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
resetSubtitles()
|
||||
}.build()
|
||||
|
||||
liveTvPreferences =
|
||||
LiveTvPreferences
|
||||
|
|
@ -122,6 +128,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 +194,11 @@ 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()
|
||||
}
|
||||
|
||||
fun SubtitlePreferences.Builder.resetSubtitles() {
|
||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
||||
|
|
@ -193,4 +212,5 @@ fun SubtitlePreferences.Builder.resetSubtitles() {
|
|||
backgroundStyle = SubtitleSettings.BackgroundStylePref.defaultValue
|
||||
margin = SubtitleSettings.Margin.defaultValue.toInt()
|
||||
edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt()
|
||||
imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
|||
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.updateSubtitlePreferences
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
|
|
@ -213,4 +214,27 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
|||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
|
@ -16,12 +19,16 @@ class PlaybackLifecycleObserver
|
|||
private val navigationManager: NavigationManager,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val suggestionsCache: SuggestionsCache,
|
||||
) : DefaultLifecycleObserver {
|
||||
private var wasPlaying: Boolean? = null
|
||||
|
||||
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
|
||||
|
|
@ -45,5 +52,6 @@ class PlaybackLifecycleObserver
|
|||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
themeSongPlayer.stop()
|
||||
CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class RefreshRateService
|
|||
@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 display get() = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
|
||||
val supportedDisplayModes get() = display.supportedModes.orEmpty()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
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.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
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)
|
||||
|
||||
cache.cacheVersion
|
||||
.map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { cachedIds ->
|
||||
if (cachedIds.isNotEmpty()) {
|
||||
flow {
|
||||
try {
|
||||
emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind)))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch items")
|
||||
emit(SuggestionsResource.Empty)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
@file:UseSerializers(UUIDSerializer::class)
|
||||
|
||||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
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 json = Json { ignoreUnknownKeys = true }
|
||||
private val _cacheVersion = MutableStateFlow(0L)
|
||||
val cacheVersion: StateFlow<Long> = _cacheVersion.asStateFlow()
|
||||
|
||||
private val memoryCache: MutableMap<String, CachedSuggestions> =
|
||||
LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true)
|
||||
|
||||
@Volatile
|
||||
private var diskCacheLoadedUserId: UUID? = null
|
||||
private val dirtyKeys: MutableSet<String> = mutableSetOf()
|
||||
private val mutex = Mutex()
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private fun writeEntryToDisk(
|
||||
key: String,
|
||||
cached: CachedSuggestions,
|
||||
) {
|
||||
runCatching {
|
||||
val suggestionsDir = cacheDir.apply { mkdirs() }
|
||||
File(suggestionsDir, "$key.json")
|
||||
.outputStream()
|
||||
.use { json.encodeToStream(cached, it) }
|
||||
}.onFailure { Timber.w(it, "Failed to write evicted cache: $key") }
|
||||
}
|
||||
|
||||
private fun checkForEviction(newKey: String): Pair<String, CachedSuggestions>? {
|
||||
if (memoryCache.containsKey(newKey) || memoryCache.size < MAX_MEMORY_CACHE_SIZE) {
|
||||
return null
|
||||
}
|
||||
val eldest = memoryCache.entries.firstOrNull() ?: return null
|
||||
memoryCache.remove(eldest.key)
|
||||
return if (dirtyKeys.remove(eldest.key)) eldest.key to eldest.value else null
|
||||
}
|
||||
|
||||
private fun cacheKey(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}"
|
||||
|
||||
private val cacheDir: File
|
||||
get() = File(context.cacheDir, "suggestions")
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private suspend fun loadFromDisk(userId: UUID) {
|
||||
if (diskCacheLoadedUserId == userId) return
|
||||
mutex.withLock {
|
||||
if (diskCacheLoadedUserId == userId) return@withLock
|
||||
withContext(Dispatchers.IO) {
|
||||
val suggestionsDir = cacheDir
|
||||
if (!suggestionsDir.exists()) {
|
||||
diskCacheLoadedUserId = userId
|
||||
return@withContext
|
||||
}
|
||||
memoryCache.clear()
|
||||
suggestionsDir
|
||||
.listFiles {
|
||||
it.name.startsWith(userId.toServerString())
|
||||
}.orEmpty()
|
||||
.take(MAX_MEMORY_CACHE_SIZE)
|
||||
.forEach { file ->
|
||||
runCatching {
|
||||
val key = file.nameWithoutExtension
|
||||
val cached =
|
||||
file
|
||||
.inputStream()
|
||||
.use { json.decodeFromStream<CachedSuggestions>(it) }
|
||||
memoryCache[key] = cached
|
||||
}.onFailure { Timber.w(it, "Failed to read cache file: ${file.name}") }
|
||||
}
|
||||
diskCacheLoadedUserId = userId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun get(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
): CachedSuggestions? {
|
||||
loadFromDisk(userId)
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
memoryCache[key]?.let { return it }
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
File(cacheDir, "$key.json")
|
||||
.takeIf { it.exists() }
|
||||
?.inputStream()
|
||||
?.use {
|
||||
json.decodeFromStream<CachedSuggestions>(it)
|
||||
}?.also { memoryCache[key] = it }
|
||||
}.onFailure { Timber.w(it, "Failed to read cache: $key") }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
ids: List<UUID>,
|
||||
) {
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
val cached = CachedSuggestions(ids)
|
||||
val evictedEntry =
|
||||
mutex.withLock {
|
||||
val evicted = checkForEviction(key)
|
||||
memoryCache[key] = cached
|
||||
dirtyKeys.add(key)
|
||||
_cacheVersion.update { it + 1 }
|
||||
evicted
|
||||
}
|
||||
evictedEntry?.let { (evictedKey, evictedValue) ->
|
||||
withContext(Dispatchers.IO) {
|
||||
writeEntryToDisk(evictedKey, evictedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isEmpty(): Boolean =
|
||||
mutex.withLock {
|
||||
if (memoryCache.isNotEmpty() || dirtyKeys.isNotEmpty()) {
|
||||
return@withLock false
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
val files = cacheDir.listFiles()
|
||||
files == null || files.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun save() {
|
||||
val entriesToSave =
|
||||
mutex.withLock {
|
||||
if (dirtyKeys.isEmpty()) return
|
||||
val entries =
|
||||
dirtyKeys.mapNotNull { key ->
|
||||
memoryCache[key]?.let { key to it }
|
||||
}
|
||||
dirtyKeys.clear()
|
||||
entries
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val suggestionsDir =
|
||||
cacheDir.apply {
|
||||
if (!mkdirs() && !exists()) Timber.w("Failed to create suggestions cache directory")
|
||||
}
|
||||
entriesToSave.forEach { (key, value) ->
|
||||
runCatching {
|
||||
File(suggestionsDir, "$key.json")
|
||||
.outputStream()
|
||||
.use { json.encodeToStream(value, it) }
|
||||
}.onFailure { Timber.w(it, "Failed to write cache: $key") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
mutex.withLock {
|
||||
memoryCache.clear()
|
||||
dirtyKeys.clear()
|
||||
_cacheVersion.update { it + 1 }
|
||||
diskCacheLoadedUserId = null
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { cacheDir.deleteRecursively() }
|
||||
.onFailure { Timber.w(it, "Failed to clear suggestions cache") }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_MEMORY_CACHE_SIZE = 8
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
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.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
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 timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@ActivityScoped
|
||||
class SuggestionsSchedulerService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val cache: SuggestionsCache,
|
||||
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
|
||||
|
||||
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 constraints =
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val inputData =
|
||||
workDataOf(
|
||||
SuggestionsWorker.PARAM_USER_ID to userId.toString(),
|
||||
SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(),
|
||||
)
|
||||
|
||||
if (cache.isEmpty()) {
|
||||
Timber.i("Suggestions cache empty, scheduling immediate fetch")
|
||||
workManager.enqueue(
|
||||
OneTimeWorkRequestBuilder<SuggestionsWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(inputData)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
Timber.i("Scheduling periodic SuggestionsWorker")
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
||||
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request =
|
||||
PeriodicWorkRequestBuilder<SuggestionsWorker>(
|
||||
repeatInterval = 12.hours.toJavaDuration(),
|
||||
).setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
15.minutes.toJavaDuration(),
|
||||
).setInputData(inputData)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
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 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
|
||||
|
||||
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 {
|
||||
views
|
||||
.mapNotNull { view ->
|
||||
val itemKind =
|
||||
when (view.collectionType) {
|
||||
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
||||
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
async(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Timber.v("Fetching suggestions for view %s", view.id)
|
||||
val suggestions = fetchSuggestions(view.id, userId, itemKind, itemsPerRow)
|
||||
ensureActive()
|
||||
cache.put(
|
||||
userId,
|
||||
view.id,
|
||||
itemKind,
|
||||
suggestions.map { it.id },
|
||||
)
|
||||
}.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 }
|
||||
cache.save()
|
||||
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(
|
||||
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 historyDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
itemKind = historyItemType,
|
||||
sortBy = ItemSortBy.DATE_PLAYED,
|
||||
isPlayed = true,
|
||||
limit = 10,
|
||||
extraFields = listOf(ItemFields.GENRES),
|
||||
).distinctBy { it.relevantId }.take(3)
|
||||
}
|
||||
|
||||
val randomDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
itemKind = itemKind,
|
||||
sortBy = ItemSortBy.RANDOM,
|
||||
isPlayed = false,
|
||||
limit = itemsPerRow,
|
||||
)
|
||||
}
|
||||
|
||||
val freshDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
itemKind = itemKind,
|
||||
sortBy = ItemSortBy.DATE_CREATED,
|
||||
sortOrder = SortOrder.DESCENDING,
|
||||
isPlayed = false,
|
||||
limit = (itemsPerRow * FRESH_CONTENT_RATIO).toInt().coerceAtLeast(1),
|
||||
)
|
||||
}
|
||||
|
||||
val seedItems = historyDeferred.await()
|
||||
val random = randomDeferred.await()
|
||||
val fresh = freshDeferred.await()
|
||||
|
||||
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
|
||||
|
||||
(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,
|
||||
extraFields: List<ItemFields> = emptyList(),
|
||||
): List<BaseItemDto> {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields,
|
||||
includeItemTypes = listOf(itemKind),
|
||||
genreIds = genreIds,
|
||||
recursive = true,
|
||||
isPlayed = isPlayed,
|
||||
sortBy = listOf(sortBy),
|
||||
sortOrder = sortOrder?.let { listOf(it) },
|
||||
limit = limit,
|
||||
enableTotalRecordCount = false,
|
||||
imageTypeLimit = 1,
|
||||
)
|
||||
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"
|
||||
private const val FRESH_CONTENT_RATIO = 0.4
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -176,6 +177,12 @@ object AppModule {
|
|||
@IoCoroutineScope
|
||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun workManager(
|
||||
@ApplicationContext context: Context,
|
||||
): WorkManager = WorkManager.getInstance(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun seerrApi(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -29,9 +29,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
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ val DefaultItemFields =
|
|||
ItemFields.CHAPTERS,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
ItemFields.PARENT_ID,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -70,8 +71,16 @@ val SlimItemFields =
|
|||
ItemFields.OVERVIEW,
|
||||
ItemFields.SORT_NAME,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
ItemFields.PARENT_ID,
|
||||
)
|
||||
|
||||
val PhotoItemFields =
|
||||
DefaultItemFields +
|
||||
listOf(
|
||||
ItemFields.WIDTH,
|
||||
ItemFields.HEIGHT,
|
||||
)
|
||||
|
||||
object Cards {
|
||||
const val HEIGHT_2X3_DP = 172
|
||||
val height2x3 = HEIGHT_2X3_DP.dp
|
||||
|
|
|
|||
|
|
@ -577,21 +577,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.navigationManager.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.navigationManager.navigateTo(destination)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ 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.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
|
||||
|
|
@ -180,6 +182,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 () -> Unit,
|
||||
onClick: (position: Duration) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
Button(
|
||||
|
|
@ -200,14 +259,7 @@ fun ExpandablePlayButton(
|
|||
.padding(start = 2.dp, top = 2.dp)
|
||||
.height(MinButtonSize),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
|
||||
)
|
||||
icon.invoke()
|
||||
}
|
||||
AnimatedVisibility(isFocused) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
|
@ -343,6 +395,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,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ 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.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 +24,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
|
||||
|
|
@ -42,7 +43,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,6 +54,7 @@ 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,
|
||||
|
|
@ -115,7 +116,7 @@ class RecommendedMovieViewModel
|
|||
}
|
||||
|
||||
update(R.string.recently_released) {
|
||||
val recentlyReleasedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -128,13 +129,11 @@ class RecommendedMovieViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, recentlyReleasedRequest)
|
||||
.toBaseItems(api, false)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}
|
||||
|
||||
update(R.string.recently_added) {
|
||||
val recentlyAddedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -147,27 +146,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)
|
||||
}
|
||||
|
||||
update(R.string.top_unwatched) {
|
||||
val unwatchedTopRatedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -181,9 +164,48 @@ class RecommendedMovieViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, unwatchedTopRatedRequest)
|
||||
.toBaseItems(api, false)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}
|
||||
|
||||
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: 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) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ 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.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 +25,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
|
||||
|
|
@ -45,7 +46,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,6 +58,7 @@ 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,
|
||||
|
|
@ -86,7 +87,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 +105,7 @@ class RecommendedTvShowViewModel
|
|||
}
|
||||
|
||||
val nextUpItemsDeferred =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
async(Dispatchers.IO) {
|
||||
val nextUpRequest =
|
||||
GetNextUpRequest(
|
||||
userId = userId,
|
||||
|
|
@ -116,19 +117,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 +136,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 +148,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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +163,7 @@ class RecommendedTvShowViewModel
|
|||
}
|
||||
|
||||
update(R.string.recently_released) {
|
||||
val recentlyReleasedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -184,14 +176,11 @@ class RecommendedTvShowViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
|
||||
GetItemsRequestHandler
|
||||
.execute(api, recentlyReleasedRequest)
|
||||
.toBaseItems(api, true)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}
|
||||
|
||||
update(R.string.recently_added) {
|
||||
val recentlyAddedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -204,29 +193,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)
|
||||
}
|
||||
|
||||
update(R.string.top_unwatched) {
|
||||
val unwatchedTopRatedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -240,9 +211,48 @@ class RecommendedTvShowViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, unwatchedTopRatedRequest)
|
||||
.toBaseItems(api, true)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}
|
||||
|
||||
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: 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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,7 +45,9 @@ 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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
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
|
||||
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.navigationManager.navigateTo(destination)
|
||||
},
|
||||
itemId = itemId.toServerString(),
|
||||
initialFilter = filter,
|
||||
showTitle = showHeader,
|
||||
recursive = recursive,
|
||||
sortOptions = sortOptions,
|
||||
modifier =
|
||||
modifier
|
||||
.padding(start = 16.dp),
|
||||
positionCallback = { columns, position ->
|
||||
showHeader = position < columns
|
||||
},
|
||||
defaultViewOptions = ViewOptionsWide,
|
||||
playEnabled = true,
|
||||
filterOptions = filterOptions,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
|
|
@ -41,6 +41,11 @@ sealed class Destination(
|
|||
val screen: PreferenceScreenOption,
|
||||
) : Destination(true)
|
||||
|
||||
@Serializable
|
||||
data class SubtitleSettings(
|
||||
val hdr: Boolean,
|
||||
) : Destination(true)
|
||||
|
||||
@Serializable
|
||||
data object Search : Destination()
|
||||
|
||||
|
|
@ -103,6 +108,16 @@ sealed class Destination(
|
|||
val itemIds: List<UUID>,
|
||||
) : Destination(false)
|
||||
|
||||
@Serializable
|
||||
data class Slideshow(
|
||||
val parentId: UUID,
|
||||
val index: Int,
|
||||
val filter: CollectionFolderFilter,
|
||||
val sortAndDirection: SortAndDirection,
|
||||
val recursive: Boolean,
|
||||
val startSlideshow: Boolean,
|
||||
) : Destination(true)
|
||||
|
||||
@Serializable
|
||||
data object Favorites : Destination(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet
|
|||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
|
||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
|
||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
|
||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum
|
||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist
|
||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
|
||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
|
||||
|
|
@ -35,7 +36,9 @@ import com.github.damontecres.wholphin.ui.main.SearchPage
|
|||
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
|
||||
import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import timber.log.Timber
|
||||
|
|
@ -83,6 +86,14 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
|
||||
is Destination.SubtitleSettings -> {
|
||||
SubtitleStylePage(
|
||||
preferences.appPreferences,
|
||||
destination.hdr,
|
||||
modifier,
|
||||
)
|
||||
}
|
||||
|
||||
is Destination.SeriesOverview -> {
|
||||
SeriesOverview(
|
||||
preferences = preferences,
|
||||
|
|
@ -191,6 +202,16 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
|
||||
BaseItemKind.PHOTO_ALBUM -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
CollectionFolderPhotoAlbum(
|
||||
preferences = preferences,
|
||||
itemId = destination.itemId,
|
||||
recursive = true,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.w("Unsupported item type: ${destination.type}")
|
||||
Text("Unsupported item type: ${destination.type}")
|
||||
|
|
@ -230,6 +251,12 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
|
||||
is Destination.Slideshow -> {
|
||||
SlideshowPage(
|
||||
slideshow = destination,
|
||||
)
|
||||
}
|
||||
|
||||
Destination.Favorites -> {
|
||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
FavoritesPage(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ 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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
|
@ -70,6 +71,7 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
|||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
|
||||
|
|
@ -85,6 +87,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
|
@ -176,6 +179,7 @@ fun PlaybackPageContent(
|
|||
LaunchedEffect(player) {
|
||||
if (playerBackend == PlayerBackend.MPV) {
|
||||
scope.launch(Dispatchers.IO + ExceptionHandler()) {
|
||||
// MPV can't play HDR, so always use regular settings
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
|
||||
configuration,
|
||||
density,
|
||||
|
|
@ -401,13 +405,26 @@ fun PlaybackPageContent(
|
|||
)
|
||||
}
|
||||
|
||||
val subtitleSettings =
|
||||
remember(mediaInfo) {
|
||||
Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}")
|
||||
if (mediaInfo?.videoStream?.hdr == true) {
|
||||
preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences
|
||||
} else {
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
||||
}
|
||||
}
|
||||
val subtitleImageOpacity =
|
||||
remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f }
|
||||
|
||||
// Subtitles
|
||||
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
|
||||
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
|
||||
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SubtitleView(context).apply {
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let {
|
||||
subtitleSettings.let {
|
||||
setStyle(it.toSubtitleStyle())
|
||||
setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
|
||||
setBottomPaddingFraction(it.margin.toFloat() / 100f)
|
||||
|
|
@ -416,10 +433,8 @@ fun PlaybackPageContent(
|
|||
},
|
||||
update = {
|
||||
it.setCues(cues)
|
||||
Media3SubtitleOverride(
|
||||
preferences.appPreferences.interfacePreferences.subtitlesPreferences
|
||||
.calculateEdgeSize(density),
|
||||
).apply(it)
|
||||
Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
|
||||
.apply(it)
|
||||
},
|
||||
onReset = {
|
||||
it.setCues(null)
|
||||
|
|
@ -428,7 +443,8 @@ fun PlaybackPageContent(
|
|||
Modifier
|
||||
.fillMaxSize(maxSize)
|
||||
.align(Alignment.TopCenter)
|
||||
.background(Color.Transparent),
|
||||
.background(Color.Transparent)
|
||||
.ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.preferences
|
|||
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
|
|
@ -11,12 +10,12 @@ import kotlinx.serialization.Serializable
|
|||
data class PreferenceGroup<T>(
|
||||
@param:StringRes val title: Int,
|
||||
val preferences: List<AppPreference<T, out Any?>>,
|
||||
val conditionalPreferences: List<ConditionalPreferences> = listOf(),
|
||||
val conditionalPreferences: List<ConditionalPreferences<T>> = listOf(),
|
||||
)
|
||||
|
||||
data class ConditionalPreferences(
|
||||
val condition: (AppPreferences) -> Boolean,
|
||||
val preferences: List<AppPreference<AppPreferences, out Any?>>,
|
||||
data class ConditionalPreferences<T>(
|
||||
val condition: (T) -> Boolean,
|
||||
val preferences: List<AppPreference<T, out Any?>>,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -34,7 +33,6 @@ sealed interface PreferenceValidation {
|
|||
enum class PreferenceScreenOption {
|
||||
BASIC,
|
||||
ADVANCED,
|
||||
SUBTITLES,
|
||||
EXO_PLAYER,
|
||||
MPV,
|
||||
;
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.ui.playOnClickSound
|
||||
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
|
||||
|
|
@ -124,7 +123,6 @@ fun PreferencesContent(
|
|||
when (preferenceScreenOption) {
|
||||
PreferenceScreenOption.BASIC -> basicPreferences
|
||||
PreferenceScreenOption.ADVANCED -> advancedPreferences
|
||||
PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences
|
||||
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
|
||||
PreferenceScreenOption.MPV -> MpvPreferences
|
||||
}
|
||||
|
|
@ -132,7 +130,6 @@ fun PreferencesContent(
|
|||
when (preferenceScreenOption) {
|
||||
PreferenceScreenOption.BASIC -> R.string.settings
|
||||
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
|
||||
PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style
|
||||
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
|
||||
PreferenceScreenOption.MPV -> R.string.mpv_options
|
||||
}
|
||||
|
|
@ -535,12 +532,6 @@ fun PreferencesPage(
|
|||
.align(Alignment.TopEnd),
|
||||
)
|
||||
}
|
||||
|
||||
PreferenceScreenOption.SUBTITLES -> {
|
||||
SubtitleStylePage(
|
||||
initialPreferences,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,6 @@ fun SliderPreference(
|
|||
max = preference.max,
|
||||
interval = preference.interval,
|
||||
onChange = onChange,
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
enableWrapAround = false,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.weight(1f),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
package com.github.damontecres.wholphin.ui.preferences.subtitle
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.SubtitlePreferences
|
||||
import com.github.damontecres.wholphin.preferences.resetSubtitles
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.preferences.ClickPreference
|
||||
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SubtitlePreferencesContent(
|
||||
title: String,
|
||||
preferences: SubtitlePreferences,
|
||||
prefList: List<PreferenceGroup<SubtitlePreferences>>,
|
||||
onPreferenceChange: suspend (SubtitlePreferences) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PreferencesViewModel = hiltViewModel(),
|
||||
onFocus: (Int, Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
|
||||
val state = rememberLazyListState()
|
||||
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
// Forces the animated to trigger
|
||||
visible = true
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + slideInHorizontally { it / 2 },
|
||||
exit = fadeOut() + slideOutHorizontally { it / 2 },
|
||||
modifier = modifier,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
LazyColumn(
|
||||
state = state,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
||||
) {
|
||||
stickyHeader {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
prefList.forEachIndexed { groupIndex, group ->
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(group.title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
val groupPreferences =
|
||||
group.preferences +
|
||||
group.conditionalPreferences
|
||||
.filter { it.condition.invoke(preferences) }
|
||||
.map { it.preferences }
|
||||
.flatten()
|
||||
groupPreferences.forEachIndexed { prefIndex, pref ->
|
||||
pref as AppPreference<SubtitlePreferences, Any>
|
||||
item {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focused = interactionSource.collectIsFocusedAsState().value
|
||||
LaunchedEffect(focused) {
|
||||
if (focused) {
|
||||
focusedIndex = Pair(groupIndex, prefIndex)
|
||||
onFocus.invoke(groupIndex, prefIndex)
|
||||
}
|
||||
}
|
||||
when (pref) {
|
||||
SubtitleSettings.Reset -> {
|
||||
ClickPreference(
|
||||
title = stringResource(pref.title),
|
||||
onClick = {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
val newValue =
|
||||
SubtitlePreferences
|
||||
.newBuilder()
|
||||
.apply { resetSubtitles() }
|
||||
.build()
|
||||
onPreferenceChange.invoke(newValue)
|
||||
}
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val value = pref.getter.invoke(preferences)
|
||||
ComposablePreference(
|
||||
preference = pref,
|
||||
value = value,
|
||||
onNavigate = viewModel.navigationManager::navigateTo,
|
||||
onValueChange = { newValue ->
|
||||
val validation = pref.validate(newValue)
|
||||
when (validation) {
|
||||
is PreferenceValidation.Invalid -> {
|
||||
// TODO?
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
validation.message,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
|
||||
PreferenceValidation.Valid -> {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
onPreferenceChange.invoke(
|
||||
pref.setter(
|
||||
preferences,
|
||||
newValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
Modifier
|
||||
.ifElse(
|
||||
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.ui.preferences.subtitle
|
|||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.view.Display
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
|
|
@ -13,14 +15,14 @@ import androidx.media3.ui.CaptionStyleCompat
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.AppChoicePreference
|
||||
import com.github.damontecres.wholphin.preferences.AppClickablePreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppDestinationPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppSliderPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
||||
import com.github.damontecres.wholphin.preferences.BackgroundStyle
|
||||
import com.github.damontecres.wholphin.preferences.EdgeStyle
|
||||
import com.github.damontecres.wholphin.preferences.SubtitlePreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||
import com.github.damontecres.wholphin.util.mpv.MPVLib
|
||||
import com.github.damontecres.wholphin.util.mpv.setPropertyColor
|
||||
|
|
@ -28,18 +30,17 @@ import timber.log.Timber
|
|||
|
||||
object SubtitleSettings {
|
||||
val FontSize =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
AppSliderPreference<SubtitlePreferences>(
|
||||
title = R.string.font_size,
|
||||
defaultValue = 24,
|
||||
min = 8,
|
||||
max = 70,
|
||||
interval = 2,
|
||||
getter = {
|
||||
it.interfacePreferences.subtitlesPreferences.fontSize
|
||||
.toLong()
|
||||
it.fontSize.toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { fontSize = value.toInt() }
|
||||
prefs.update { fontSize = value.toInt() }
|
||||
},
|
||||
summarizer = { value -> value?.toString() },
|
||||
)
|
||||
|
|
@ -59,12 +60,12 @@ object SubtitleSettings {
|
|||
)
|
||||
|
||||
val FontColor =
|
||||
AppChoicePreference<AppPreferences, Color>(
|
||||
AppChoicePreference<SubtitlePreferences, Color>(
|
||||
title = R.string.font_color,
|
||||
defaultValue = Color.White,
|
||||
getter = { Color(it.interfacePreferences.subtitlesPreferences.fontColor) },
|
||||
getter = { Color(it.fontColor) },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { fontColor = value.toArgb().and(0x00FFFFFF) }
|
||||
prefs.update { fontColor = value.toArgb().and(0x00FFFFFF) }
|
||||
},
|
||||
displayValues = R.array.font_colors,
|
||||
indexToValue = { colorList.getOrNull(it) ?: Color.White },
|
||||
|
|
@ -75,49 +76,49 @@ object SubtitleSettings {
|
|||
)
|
||||
|
||||
val FontBold =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
AppSwitchPreference<SubtitlePreferences>(
|
||||
title = R.string.bold_font,
|
||||
defaultValue = false,
|
||||
getter = { it.interfacePreferences.subtitlesPreferences.fontBold },
|
||||
getter = { it.fontBold },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { fontBold = value }
|
||||
prefs.update { fontBold = value }
|
||||
},
|
||||
)
|
||||
val FontItalic =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
AppSwitchPreference<SubtitlePreferences>(
|
||||
title = R.string.italic_font,
|
||||
defaultValue = false,
|
||||
getter = { it.interfacePreferences.subtitlesPreferences.fontItalic },
|
||||
getter = { it.fontItalic },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { fontItalic = value }
|
||||
prefs.update { fontItalic = value }
|
||||
},
|
||||
)
|
||||
|
||||
val FontOpacity =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
AppSliderPreference<SubtitlePreferences>(
|
||||
title = R.string.font_opacity,
|
||||
defaultValue = 100,
|
||||
min = 10,
|
||||
max = 100,
|
||||
interval = 10,
|
||||
getter = {
|
||||
it.interfacePreferences.subtitlesPreferences.fontOpacity
|
||||
it.fontOpacity
|
||||
.toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { fontOpacity = value.toInt() }
|
||||
prefs.update { fontOpacity = value.toInt() }
|
||||
},
|
||||
summarizer = { value -> value?.let { "$it%" } },
|
||||
)
|
||||
|
||||
val EdgeStylePref =
|
||||
AppChoicePreference<AppPreferences, EdgeStyle>(
|
||||
AppChoicePreference<SubtitlePreferences, EdgeStyle>(
|
||||
title =
|
||||
R.string.edge_style,
|
||||
defaultValue = EdgeStyle.EDGE_SOLID,
|
||||
getter = { it.interfacePreferences.subtitlesPreferences.edgeStyle },
|
||||
getter = { it.edgeStyle },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { edgeStyle = value }
|
||||
prefs.update { edgeStyle = value }
|
||||
},
|
||||
displayValues = R.array.subtitle_edge,
|
||||
indexToValue = { EdgeStyle.forNumber(it) },
|
||||
|
|
@ -125,12 +126,12 @@ object SubtitleSettings {
|
|||
)
|
||||
|
||||
val EdgeColor =
|
||||
AppChoicePreference<AppPreferences, Color>(
|
||||
AppChoicePreference<SubtitlePreferences, Color>(
|
||||
title = R.string.edge_color,
|
||||
defaultValue = Color.Black,
|
||||
getter = { Color(it.interfacePreferences.subtitlesPreferences.edgeColor) },
|
||||
getter = { Color(it.edgeColor) },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { edgeColor = value.toArgb().and(0x00FFFFFF) }
|
||||
prefs.update { edgeColor = value.toArgb().and(0x00FFFFFF) }
|
||||
},
|
||||
displayValues = R.array.font_colors,
|
||||
indexToValue = { colorList.getOrNull(it) ?: Color.White },
|
||||
|
|
@ -141,29 +142,29 @@ object SubtitleSettings {
|
|||
)
|
||||
|
||||
val EdgeThickness =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
AppSliderPreference<SubtitlePreferences>(
|
||||
title = R.string.edge_size,
|
||||
defaultValue = 4,
|
||||
min = 1,
|
||||
max = 32,
|
||||
interval = 1,
|
||||
getter = {
|
||||
it.interfacePreferences.subtitlesPreferences.edgeThickness
|
||||
it.edgeThickness
|
||||
.toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { edgeThickness = value.toInt() }
|
||||
prefs.update { edgeThickness = value.toInt() }
|
||||
},
|
||||
summarizer = { value -> value?.let { "${it / 2.0}" } },
|
||||
)
|
||||
|
||||
val BackgroundColor =
|
||||
AppChoicePreference<AppPreferences, Color>(
|
||||
AppChoicePreference<SubtitlePreferences, Color>(
|
||||
title = R.string.background_color,
|
||||
defaultValue = Color.Transparent,
|
||||
getter = { Color(it.interfacePreferences.subtitlesPreferences.backgroundColor) },
|
||||
getter = { Color(it.backgroundColor) },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { backgroundColor = value.toArgb().and(0x00FFFFFF) }
|
||||
prefs.update { backgroundColor = value.toArgb().and(0x00FFFFFF) }
|
||||
},
|
||||
displayValues = R.array.font_colors,
|
||||
indexToValue = { colorList.getOrNull(it) ?: Color.White },
|
||||
|
|
@ -174,30 +175,30 @@ object SubtitleSettings {
|
|||
)
|
||||
|
||||
val BackgroundOpacity =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
AppSliderPreference<SubtitlePreferences>(
|
||||
title = R.string.background_opacity,
|
||||
defaultValue = 50,
|
||||
min = 10,
|
||||
max = 100,
|
||||
interval = 10,
|
||||
getter = {
|
||||
it.interfacePreferences.subtitlesPreferences.backgroundOpacity
|
||||
it.backgroundOpacity
|
||||
.toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { backgroundOpacity = value.toInt() }
|
||||
prefs.update { backgroundOpacity = value.toInt() }
|
||||
},
|
||||
summarizer = { value -> value?.let { "$it%" } },
|
||||
)
|
||||
|
||||
val BackgroundStylePref =
|
||||
AppChoicePreference<AppPreferences, BackgroundStyle>(
|
||||
AppChoicePreference<SubtitlePreferences, BackgroundStyle>(
|
||||
title =
|
||||
R.string.background_style,
|
||||
defaultValue = BackgroundStyle.BG_NONE,
|
||||
getter = { it.interfacePreferences.subtitlesPreferences.backgroundStyle },
|
||||
getter = { it.backgroundStyle },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { backgroundStyle = value }
|
||||
prefs.update { backgroundStyle = value }
|
||||
},
|
||||
displayValues = R.array.background_style,
|
||||
indexToValue = { BackgroundStyle.forNumber(it) },
|
||||
|
|
@ -205,29 +206,51 @@ object SubtitleSettings {
|
|||
)
|
||||
|
||||
val Margin =
|
||||
AppSliderPreference<AppPreferences>(
|
||||
AppSliderPreference<SubtitlePreferences>(
|
||||
title = R.string.subtitle_margin,
|
||||
defaultValue = 8,
|
||||
min = 0,
|
||||
max = 100,
|
||||
interval = 1,
|
||||
getter = {
|
||||
it.interfacePreferences.subtitlesPreferences.margin
|
||||
it.margin
|
||||
.toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.updateSubtitlePreferences { margin = value.toInt() }
|
||||
prefs.update { margin = value.toInt() }
|
||||
},
|
||||
summarizer = { value -> value?.let { "$it%" } },
|
||||
)
|
||||
|
||||
val ImageOpacity =
|
||||
AppSliderPreference<SubtitlePreferences>(
|
||||
title = R.string.image_subtitle_opacity,
|
||||
defaultValue = 100,
|
||||
min = 10,
|
||||
max = 100,
|
||||
interval = 5,
|
||||
getter = {
|
||||
it.imageSubtitleOpacity.toLong()
|
||||
},
|
||||
setter = { prefs, value ->
|
||||
prefs.update { imageSubtitleOpacity = value.toInt() }
|
||||
},
|
||||
summarizer = { value -> value?.let { "$it%" } },
|
||||
)
|
||||
|
||||
val Reset =
|
||||
AppClickablePreference<AppPreferences>(
|
||||
AppClickablePreference<SubtitlePreferences>(
|
||||
title = R.string.reset,
|
||||
getter = { },
|
||||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
|
||||
val HdrSettings =
|
||||
AppDestinationPreference<SubtitlePreferences>(
|
||||
title = R.string.hdr_subtitle_style,
|
||||
destination = Destination.SubtitleSettings(true),
|
||||
)
|
||||
|
||||
val preferences =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
|
|
@ -264,11 +287,25 @@ object SubtitleSettings {
|
|||
preferences =
|
||||
listOf(
|
||||
Margin,
|
||||
ImageOpacity,
|
||||
Reset,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val hdrPreferenceGroup =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
title = R.string.hdr,
|
||||
preferences =
|
||||
listOf(
|
||||
HdrSettings,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun shouldShowHdr(display: Display): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && display.isHdr
|
||||
|
||||
private fun combine(
|
||||
color: Int,
|
||||
opacity: Int,
|
||||
|
|
@ -361,3 +398,5 @@ object SubtitleSettings {
|
|||
MPVLib.setPropertyString("sub-border-style", borderStyle)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun SubtitlePreferences.update(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = toBuilder().apply(block).build()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.preferences.subtitle
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Build
|
||||
import androidx.annotation.Dimension
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.Image
|
||||
|
|
@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -19,9 +22,13 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
|
|
@ -30,20 +37,25 @@ import androidx.media3.common.util.UnstableApi
|
|||
import androidx.media3.ui.SubtitleView
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesContent
|
||||
import com.github.damontecres.wholphin.preferences.SubtitlePreferences
|
||||
import com.github.damontecres.wholphin.preferences.resetSubtitles
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.ui.findActivity
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
|
||||
import com.github.damontecres.wholphin.util.Media3SubtitleOverride
|
||||
import timber.log.Timber
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun SubtitleStylePage(
|
||||
initialPreferences: AppPreferences,
|
||||
hdrSettings: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PreferencesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
var preferences by remember { mutableStateOf(initialPreferences) }
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -51,8 +63,27 @@ fun SubtitleStylePage(
|
|||
preferences = it
|
||||
}
|
||||
}
|
||||
val prefs = preferences.interfacePreferences.subtitlesPreferences
|
||||
val display = LocalView.current.display
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
DisposableEffect(context) {
|
||||
if (hdrSettings) {
|
||||
Timber.v("Switching color mode to HDR")
|
||||
context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_HDR
|
||||
}
|
||||
onDispose {
|
||||
context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
val prefs =
|
||||
if (hdrSettings) {
|
||||
preferences.interfacePreferences.hdrSubtitlesPreferences
|
||||
} else {
|
||||
preferences.interfacePreferences.subtitlesPreferences
|
||||
}
|
||||
var focusedOnMargin by remember { mutableStateOf(false) }
|
||||
var focusedOnImageOpacity by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
|
|
@ -72,7 +103,7 @@ fun SubtitleStylePage(
|
|||
Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
if (!focusedOnMargin) {
|
||||
if (!focusedOnMargin && !focusedOnImageOpacity) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier =
|
||||
|
|
@ -109,7 +140,7 @@ fun SubtitleStylePage(
|
|||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (focusedOnMargin) {
|
||||
// Margin
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
|
|
@ -129,17 +160,79 @@ fun SubtitleStylePage(
|
|||
Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
} else if (focusedOnImageOpacity) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SubtitleView(context)
|
||||
},
|
||||
update = {
|
||||
it.setStyle(
|
||||
SubtitlePreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
resetSubtitles()
|
||||
}.build()
|
||||
.toSubtitleStyle(),
|
||||
)
|
||||
it.setCues(
|
||||
listOf(
|
||||
Cue
|
||||
.Builder()
|
||||
.setText("ExoPlayer only:\nImage based subtitles can be dimmed.")
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.alpha(prefs.imageSubtitleOpacity / 100f),
|
||||
)
|
||||
}
|
||||
}
|
||||
PreferencesContent(
|
||||
initialPreferences = preferences,
|
||||
preferenceScreenOption = PreferenceScreenOption.SUBTITLES,
|
||||
val display = LocalView.current.display
|
||||
val prefList =
|
||||
remember(hdrSettings, display) {
|
||||
if (!hdrSettings && SubtitleSettings.shouldShowHdr(display)) {
|
||||
// If not on HDR page and display is HDR capable, then show the HDR button
|
||||
SubtitleSettings.preferences + SubtitleSettings.hdrPreferenceGroup
|
||||
} else {
|
||||
SubtitleSettings.preferences
|
||||
}
|
||||
}
|
||||
SubtitlePreferencesContent(
|
||||
title =
|
||||
if (hdrSettings) {
|
||||
stringResource(R.string.hdr_subtitle_style)
|
||||
} else {
|
||||
stringResource(R.string.subtitle_style)
|
||||
},
|
||||
preferences =
|
||||
if (hdrSettings) {
|
||||
preferences.interfacePreferences.hdrSubtitlesPreferences
|
||||
} else {
|
||||
preferences.interfacePreferences.subtitlesPreferences
|
||||
},
|
||||
prefList = prefList,
|
||||
onPreferenceChange = { newSubtitlePrefs ->
|
||||
viewModel.preferenceDataStore.updateData {
|
||||
it.updateInterfacePreferences {
|
||||
if (hdrSettings) {
|
||||
hdrSubtitlesPreferences = newSubtitlePrefs
|
||||
} else {
|
||||
subtitlesPreferences = newSubtitlePrefs
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onFocus = { groupIndex, prefIndex ->
|
||||
|
||||
focusedOnMargin =
|
||||
SubtitleSettings.preferences.getOrNull(groupIndex)?.preferences?.getOrNull(
|
||||
prefIndex,
|
||||
) == SubtitleSettings.Margin
|
||||
val focusedPref =
|
||||
SubtitleSettings.preferences
|
||||
.getOrNull(groupIndex)
|
||||
?.preferences
|
||||
?.getOrNull(prefIndex)
|
||||
focusedOnMargin = focusedPref == SubtitleSettings.Margin
|
||||
focusedOnImageOpacity = focusedPref == SubtitleSettings.ImageOpacity
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
package com.github.damontecres.wholphin.ui.slideshow
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.FocusState
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.ButtonDefaults
|
||||
import androidx.tv.material3.Icon
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun ImageControlsOverlay(
|
||||
slideshowEnabled: Boolean,
|
||||
slideshowControls: SlideshowControls,
|
||||
onDismiss: () -> Unit,
|
||||
isImageClip: Boolean,
|
||||
onZoom: (Float) -> Unit,
|
||||
onRotate: (Int) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
isPlaying: Boolean,
|
||||
playPauseOnClick: () -> Unit,
|
||||
onShowFilterDialogClick: () -> Unit,
|
||||
bringIntoViewRequester: BringIntoViewRequester?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
val onFocused = { focusState: FocusState ->
|
||||
if (focusState.isFocused && bringIntoViewRequester != null) {
|
||||
scope.launch(ExceptionHandler()) { bringIntoViewRequester.bringIntoView() }
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
modifier =
|
||||
modifier
|
||||
.focusGroup(),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
item {
|
||||
ExpandablePlayButton(
|
||||
title = if (slideshowEnabled) R.string.stop_slideshow else R.string.play_slideshow,
|
||||
icon =
|
||||
painterResource(
|
||||
if (slideshowEnabled) {
|
||||
R.drawable.baseline_pause_24
|
||||
} else {
|
||||
R.drawable.baseline_play_arrow_24
|
||||
},
|
||||
),
|
||||
resume = Duration.ZERO,
|
||||
onClick = {
|
||||
if (slideshowEnabled) {
|
||||
slideshowControls.stopSlideshow()
|
||||
} else {
|
||||
slideshowControls.startSlideshow()
|
||||
onDismiss.invoke()
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged(onFocused),
|
||||
)
|
||||
}
|
||||
if (isImageClip) {
|
||||
item {
|
||||
Button(
|
||||
onClick = playPauseOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(onFocused),
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
painter =
|
||||
painterResource(
|
||||
if (isPlaying) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24,
|
||||
),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular image
|
||||
item {
|
||||
ExpandableFaButton(
|
||||
title = R.string.rotate_left,
|
||||
iconStringRes = R.string.fa_rotate_left,
|
||||
onClick = { onRotate(-90) },
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(onFocused),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ExpandableFaButton(
|
||||
title = R.string.rotate_right,
|
||||
iconStringRes = R.string.fa_rotate_right,
|
||||
onClick = { onRotate(90) },
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(onFocused),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ExpandableFaButton(
|
||||
title = R.string.zoom_in,
|
||||
iconStringRes = R.string.fa_magnifying_glass_plus,
|
||||
onClick = { onZoom(.15f) },
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(onFocused),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ExpandableFaButton(
|
||||
title = R.string.zoom_out,
|
||||
iconStringRes = R.string.fa_magnifying_glass_minus,
|
||||
onClick = { onZoom(-.15f) },
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(onFocused),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ExpandableFaButton(
|
||||
title = R.string.reset,
|
||||
iconStringRes = R.string.fa_arrows_rotate,
|
||||
onClick = onReset,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(onFocused),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ExpandableFaButton(
|
||||
title = R.string.filter,
|
||||
iconStringRes = R.string.fa_sliders,
|
||||
onClick = onShowFilterDialogClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(onFocused),
|
||||
)
|
||||
}
|
||||
}
|
||||
// More button
|
||||
// item {
|
||||
// ExpandablePlayButton(
|
||||
// title = R.string.more,
|
||||
// resume = Duration.ZERO,
|
||||
// icon = Icons.Default.MoreVert,
|
||||
// onClick = { moreOnClick.invoke() },
|
||||
// modifier = Modifier.onFocusChanged(onFocused),
|
||||
// )
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 800)
|
||||
@Composable
|
||||
private fun ImageControlsOverlayPreview() {
|
||||
WholphinTheme {
|
||||
ImageControlsOverlay(
|
||||
slideshowEnabled = true,
|
||||
slideshowControls =
|
||||
object : SlideshowControls {
|
||||
override fun startSlideshow() {
|
||||
}
|
||||
|
||||
override fun stopSlideshow() {
|
||||
}
|
||||
},
|
||||
isImageClip = false,
|
||||
onZoom = {},
|
||||
onRotate = {},
|
||||
onReset = {},
|
||||
moreOnClick = {},
|
||||
isPlaying = false,
|
||||
playPauseOnClick = {},
|
||||
bringIntoViewRequester = null,
|
||||
onShowFilterDialogClick = {},
|
||||
onDismiss = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package com.github.damontecres.wholphin.ui.slideshow
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.components.StreamLabel
|
||||
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun ImageDetailsHeader(
|
||||
slideshowEnabled: Boolean,
|
||||
slideshowControls: SlideshowControls,
|
||||
player: Player,
|
||||
image: ImageState,
|
||||
position: Int,
|
||||
count: Int,
|
||||
moreOnClick: () -> Unit,
|
||||
onZoom: (Float) -> Unit,
|
||||
onRotate: (Int) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onShowFilterDialogClick: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.bringIntoViewRequester(bringIntoViewRequester),
|
||||
) {
|
||||
image.image.title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(.75f),
|
||||
)
|
||||
}
|
||||
if (image.image.ui.quickDetails
|
||||
.isNotNullOrBlank()
|
||||
) {
|
||||
QuickDetails(image.image.ui.quickDetails, null)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StreamLabel("${position + 1} of $count")
|
||||
if (image.image.data.mediaType == MediaType.VIDEO) {
|
||||
VideoStreamDetails(
|
||||
chosenStreams = image.chosenStreams,
|
||||
numberOfVersions = 0,
|
||||
)
|
||||
} else {
|
||||
image.image.data.let {
|
||||
if (it.width != null && it.height != null) {
|
||||
StreamLabel("${it.width}x${it.height}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OverviewText(
|
||||
overview = image.image.data.overview ?: "",
|
||||
maxLines = 3,
|
||||
onClick = {},
|
||||
modifier = Modifier.fillMaxWidth(.75f),
|
||||
)
|
||||
val playPauseState = rememberPlayPauseButtonState(player)
|
||||
ImageControlsOverlay(
|
||||
slideshowEnabled = slideshowEnabled,
|
||||
slideshowControls = slideshowControls,
|
||||
isImageClip = image.image.data.mediaType == MediaType.VIDEO,
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
onZoom = onZoom,
|
||||
onRotate = onRotate,
|
||||
onReset = onReset,
|
||||
moreOnClick = moreOnClick,
|
||||
playPauseOnClick = playPauseState::onClick,
|
||||
isPlaying = playPauseState.showPlay,
|
||||
onShowFilterDialogClick = onShowFilterDialogClick,
|
||||
onDismiss = {},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
package com.github.damontecres.wholphin.ui.slideshow
|
||||
|
||||
import android.view.Gravity
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.VideoFilter
|
||||
import com.github.damontecres.wholphin.ui.components.SliderBar
|
||||
import com.github.damontecres.wholphin.ui.components.SliderColors
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
|
||||
const val DRAG_THROTTLE_DELAY = 50L
|
||||
|
||||
@Composable
|
||||
fun ImageFilterSliders(
|
||||
filter: VideoFilter,
|
||||
showVideoOptions: Boolean,
|
||||
showSaveButton: Boolean,
|
||||
showSaveGalleryButton: Boolean,
|
||||
onChange: (VideoFilter) -> Unit,
|
||||
onClickSave: () -> Unit,
|
||||
onClickSaveGallery: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
item {
|
||||
SliderBarRow(
|
||||
title = R.string.brightness,
|
||||
value = filter.brightness,
|
||||
min = 0,
|
||||
max = 200,
|
||||
onChange = { onChange.invoke(filter.copy(brightness = it)) },
|
||||
valueFormater = { "$it%" },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SliderBarRow(
|
||||
title = R.string.contrast,
|
||||
value = filter.contrast,
|
||||
min = 0,
|
||||
max = 200,
|
||||
onChange = { onChange.invoke(filter.copy(contrast = it)) },
|
||||
valueFormater = { "$it%" },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SliderBarRow(
|
||||
title = R.string.saturation,
|
||||
value = filter.saturation,
|
||||
min = 0,
|
||||
max = 200,
|
||||
onChange = { onChange.invoke(filter.copy(saturation = it)) },
|
||||
valueFormater = { "$it%" },
|
||||
)
|
||||
}
|
||||
if (showVideoOptions) {
|
||||
item {
|
||||
SliderBarRow(
|
||||
title = R.string.hue,
|
||||
value = filter.hue,
|
||||
min = 0,
|
||||
max = 360,
|
||||
onChange = { onChange.invoke(filter.copy(hue = it)) },
|
||||
valueFormater = { "$it\u00b0" },
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
SliderBarRow(
|
||||
title = R.string.red,
|
||||
value = filter.red,
|
||||
min = 0,
|
||||
max = 200,
|
||||
onChange = { onChange.invoke(filter.copy(red = it)) },
|
||||
valueFormater = { "$it%" },
|
||||
colors =
|
||||
SliderColors(
|
||||
activeFocused = Color.Red.copy(alpha = .75f),
|
||||
activeUnfocused = Color.Red.copy(alpha = .75f),
|
||||
inactiveFocused = Color.Red.copy(alpha = .33f),
|
||||
inactiveUnfocused = Color.Red.copy(alpha = .33f),
|
||||
),
|
||||
)
|
||||
}
|
||||
item {
|
||||
SliderBarRow(
|
||||
title = R.string.green,
|
||||
value = filter.green,
|
||||
min = 0,
|
||||
max = 200,
|
||||
onChange = { onChange.invoke(filter.copy(green = it)) },
|
||||
valueFormater = { "$it%" },
|
||||
colors =
|
||||
SliderColors(
|
||||
activeFocused = Color.Green.copy(alpha = .75f),
|
||||
activeUnfocused = Color.Green.copy(alpha = .75f),
|
||||
inactiveFocused = Color.Green.copy(alpha = .33f),
|
||||
inactiveUnfocused = Color.Green.copy(alpha = .33f),
|
||||
),
|
||||
)
|
||||
}
|
||||
item {
|
||||
SliderBarRow(
|
||||
title = R.string.blue,
|
||||
value = filter.blue,
|
||||
min = 0,
|
||||
max = 200,
|
||||
onChange = { onChange.invoke(filter.copy(blue = it)) },
|
||||
valueFormater = { "$it%" },
|
||||
colors =
|
||||
SliderColors(
|
||||
activeFocused = Color.Blue.copy(alpha = .75f),
|
||||
activeUnfocused = Color.Blue.copy(alpha = .75f),
|
||||
inactiveFocused = Color.Blue.copy(alpha = .33f),
|
||||
inactiveUnfocused = Color.Blue.copy(alpha = .33f),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (showVideoOptions) {
|
||||
item {
|
||||
SliderBarRow(
|
||||
title = R.string.blur,
|
||||
value = filter.blur,
|
||||
min = 0,
|
||||
max = 250,
|
||||
onChange = { onChange.invoke(filter.copy(blur = it)) },
|
||||
valueFormater = { "${it}px" },
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
if (showSaveButton) {
|
||||
Button(
|
||||
onClick = onClickSave,
|
||||
) {
|
||||
Text(text = stringResource(R.string.save))
|
||||
}
|
||||
}
|
||||
if (showSaveGalleryButton) {
|
||||
Button(
|
||||
onClick = onClickSaveGallery,
|
||||
) {
|
||||
Text(text = stringResource(R.string.save_for_album))
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { onChange(VideoFilter()) },
|
||||
) {
|
||||
Text(text = stringResource(R.string.reset))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SliderBarRow(
|
||||
@StringRes title: Int,
|
||||
value: Int,
|
||||
min: Int,
|
||||
max: Int,
|
||||
onChange: (Int) -> Unit,
|
||||
valueFormater: (Int) -> String,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
interval: Int = 1,
|
||||
colors: SliderColors = SliderColors.default(),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.width(96.dp),
|
||||
)
|
||||
SliderBar(
|
||||
value = value.toLong(),
|
||||
min = min.toLong(),
|
||||
max = max.toLong(),
|
||||
interval = interval,
|
||||
onChange = {
|
||||
onChange.invoke(it.toInt())
|
||||
},
|
||||
colors = colors,
|
||||
interactionSource = interactionSource,
|
||||
enableWrapAround = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = valueFormater(value),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.width(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageFilterDialog(
|
||||
filter: VideoFilter,
|
||||
showVideoOptions: Boolean,
|
||||
showSaveGalleryButton: Boolean,
|
||||
onChange: (VideoFilter) -> Unit,
|
||||
onClickSave: () -> Unit,
|
||||
onClickSaveGallery: () -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||
dialogWindowProvider?.window?.let { window ->
|
||||
window.setGravity(Gravity.TOP or Gravity.END)
|
||||
window.setDimAmount(0f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.wrapContentSize()
|
||||
.padding(8.dp)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .4f))
|
||||
.fillMaxWidth(.4f),
|
||||
) {
|
||||
ImageFilterSliders(
|
||||
filter = filter,
|
||||
showVideoOptions = showVideoOptions,
|
||||
showSaveButton = true,
|
||||
showSaveGalleryButton = showSaveGalleryButton,
|
||||
onChange = onChange,
|
||||
onClickSave = onClickSave,
|
||||
onClickSaveGallery = onClickSaveGallery,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ImageFilterSlidersPreview() {
|
||||
WholphinTheme {
|
||||
ImageFilterSliders(
|
||||
filter = VideoFilter(),
|
||||
showVideoOptions = true,
|
||||
onChange = {},
|
||||
onClickSave = {},
|
||||
onClickSaveGallery = {},
|
||||
showSaveButton = true,
|
||||
showSaveGalleryButton = true,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.github.damontecres.wholphin.ui.slideshow
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
|
||||
@Composable
|
||||
fun ImageLoadingPlaceholder(
|
||||
thumbnailUrl: String?,
|
||||
showThumbnail: Boolean,
|
||||
colorFilter: ColorFilter?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
if (showThumbnail && thumbnailUrl.isNotNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(thumbnailUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
colorFilter = colorFilter,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center)
|
||||
.alpha(.75f)
|
||||
.blur(4.dp),
|
||||
)
|
||||
}
|
||||
CircularProgress(
|
||||
Modifier
|
||||
.size(80.dp)
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
package com.github.damontecres.wholphin.ui.slideshow
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.tv.material3.Icon
|
||||
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.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun ImageOverlay(
|
||||
onDismiss: () -> Unit,
|
||||
player: Player,
|
||||
slideshowControls: SlideshowControls,
|
||||
slideshowEnabled: Boolean,
|
||||
position: Int,
|
||||
count: Int,
|
||||
image: ImageState,
|
||||
onClickItem: (BaseItem) -> Unit,
|
||||
onLongClickItem: (BaseItem) -> Unit,
|
||||
onZoom: (Float) -> Unit,
|
||||
onRotate: (Int) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onShowFilterDialogClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
|
||||
val moreDialogParams =
|
||||
remember {
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = "TODO",
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text =
|
||||
if (slideshowEnabled) {
|
||||
stringResource(R.string.stop_slideshow)
|
||||
} else {
|
||||
stringResource(R.string.play_slideshow)
|
||||
},
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
val icon =
|
||||
if (slideshowEnabled) {
|
||||
R.drawable.baseline_pause_24
|
||||
} else {
|
||||
R.drawable.baseline_play_arrow_24
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (slideshowEnabled) {
|
||||
slideshowControls.stopSlideshow()
|
||||
} else {
|
||||
slideshowControls.startSlideshow()
|
||||
}
|
||||
},
|
||||
),
|
||||
DialogItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.filter),
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_sliders),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onShowFilterDialogClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val horizontalPadding = 16.dp
|
||||
LazyColumn(
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = horizontalPadding,
|
||||
end = horizontalPadding,
|
||||
top = 16.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
item {
|
||||
ImageDetailsHeader(
|
||||
onDismiss = onDismiss,
|
||||
slideshowEnabled = slideshowEnabled,
|
||||
slideshowControls = slideshowControls,
|
||||
player = player,
|
||||
image = image,
|
||||
position = position,
|
||||
count = count,
|
||||
moreOnClick = {
|
||||
showDialog = moreDialogParams
|
||||
},
|
||||
onZoom = onZoom,
|
||||
onRotate = onRotate,
|
||||
onReset = onReset,
|
||||
onShowFilterDialogClick = onShowFilterDialogClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
showDialog?.let { params ->
|
||||
DialogPopup(
|
||||
showDialog = true,
|
||||
title = params.title,
|
||||
dialogItems = params.items,
|
||||
onDismissRequest = { showDialog = null },
|
||||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,509 @@
|
|||
package com.github.damontecres.wholphin.ui.slideshow
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
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.draw.rotate
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorMatrixColorFilter
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.compose.PlayerSurface
|
||||
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
||||
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
|
||||
import androidx.media3.ui.compose.state.rememberPresentationState
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import coil3.size.Size
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.VideoFilter
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.findActivity
|
||||
import com.github.damontecres.wholphin.ui.keepScreenOn
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
|
||||
import com.github.damontecres.wholphin.ui.playback.isDpad
|
||||
import com.github.damontecres.wholphin.ui.playback.isEnterKey
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import timber.log.Timber
|
||||
import kotlin.math.abs
|
||||
|
||||
private const val TAG = "ImagePage"
|
||||
private const val DEBUG = false
|
||||
|
||||
@SuppressLint("ConfigurationScreenWidthHeight")
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun SlideshowPage(
|
||||
slideshow: Destination.Slideshow,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SlideshowViewModel =
|
||||
hiltViewModel<SlideshowViewModel, SlideshowViewModel.Factory>(
|
||||
creationCallback = {
|
||||
it.create(slideshow)
|
||||
},
|
||||
),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
|
||||
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
|
||||
val position by viewModel.position.observeAsState(0)
|
||||
val pager by viewModel.pager.observeAsState()
|
||||
val imageState by viewModel.image.observeAsState()
|
||||
|
||||
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
|
||||
val isZoomed = zoomFactor * 100 > 102
|
||||
var rotation by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var showOverlay by rememberSaveable { mutableStateOf(false) }
|
||||
var showFilterDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var panX by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var panY by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
|
||||
val slideshowControls =
|
||||
object : SlideshowControls {
|
||||
override fun startSlideshow() {
|
||||
showOverlay = false
|
||||
viewModel.startSlideshow()
|
||||
}
|
||||
|
||||
override fun stopSlideshow() {
|
||||
viewModel.stopSlideshow()
|
||||
}
|
||||
}
|
||||
|
||||
val rotateAnimation: Float by animateFloatAsState(
|
||||
targetValue = rotation,
|
||||
label = "image_rotation",
|
||||
)
|
||||
val zoomAnimation: Float by animateFloatAsState(
|
||||
targetValue = zoomFactor,
|
||||
label = "image_zoom",
|
||||
)
|
||||
val panXAnimation: Float by animateFloatAsState(
|
||||
targetValue = panX,
|
||||
label = "image_panX",
|
||||
)
|
||||
val panYAnimation: Float by animateFloatAsState(
|
||||
targetValue = panY,
|
||||
label = "image_panY",
|
||||
)
|
||||
|
||||
val slideshowState by viewModel.slideshow.collectAsState()
|
||||
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val screenHeight = LocalWindowInfo.current.containerSize.height
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
|
||||
val maxPanX = screenWidth * .75f
|
||||
val maxPanY = screenHeight * .75f
|
||||
|
||||
fun reset(resetRotate: Boolean) {
|
||||
zoomFactor = 1f
|
||||
panX = 0f
|
||||
panY = 0f
|
||||
if (resetRotate) rotation = 0f
|
||||
}
|
||||
|
||||
fun pan(
|
||||
xFactor: Int,
|
||||
yFactor: Int,
|
||||
) {
|
||||
if (xFactor != 0) {
|
||||
panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX)
|
||||
}
|
||||
if (yFactor != 0) {
|
||||
panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY)
|
||||
}
|
||||
}
|
||||
|
||||
fun zoom(factor: Float) {
|
||||
if (factor < 0) {
|
||||
val diffFactor = factor / (zoomFactor - 1f)
|
||||
// zooming out
|
||||
val panXDiff = abs(panX * diffFactor)
|
||||
val panYDiff = abs(panY * diffFactor)
|
||||
if (DEBUG) {
|
||||
Timber.d(
|
||||
"zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff",
|
||||
)
|
||||
}
|
||||
if (panX > 0f) {
|
||||
panX -= panXDiff
|
||||
} else if (panX < 0f) {
|
||||
panX += panXDiff
|
||||
}
|
||||
if (panY > 0f) {
|
||||
panY -= panYDiff
|
||||
} else if (panY < 0f) {
|
||||
panY += panYDiff
|
||||
}
|
||||
}
|
||||
zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f)
|
||||
if (!isZoomed) {
|
||||
// Always reset if not zoomed
|
||||
panX = 0f
|
||||
panY = 0f
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(imageState) {
|
||||
reset(true)
|
||||
}
|
||||
val player = viewModel.player
|
||||
val presentationState = rememberPresentationState(player)
|
||||
LaunchedEffect(slideshowActive) {
|
||||
player.repeatMode =
|
||||
if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE
|
||||
context.findActivity()?.keepScreenOn(slideshowActive)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
context.findActivity()?.keepScreenOn(false)
|
||||
}
|
||||
}
|
||||
|
||||
var longPressing by remember { mutableStateOf(false) }
|
||||
|
||||
val contentModifier =
|
||||
Modifier
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = {
|
||||
showOverlay = !showOverlay
|
||||
},
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.background(Color.Black)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onKeyEvent {
|
||||
val isOverlayShowing = showOverlay || showFilterDialog
|
||||
var result = false
|
||||
if (!isOverlayShowing) {
|
||||
if (longPressing && it.type == KeyEventType.KeyUp) {
|
||||
// User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image
|
||||
longPressing = false
|
||||
return@onKeyEvent true
|
||||
}
|
||||
longPressing =
|
||||
it.nativeKeyEvent.isLongPress ||
|
||||
it.nativeKeyEvent.repeatCount > 0
|
||||
if (longPressing) {
|
||||
when (it.key) {
|
||||
Key.DirectionUp -> zoom(.05f)
|
||||
Key.DirectionDown -> zoom(-.05f)
|
||||
|
||||
// These work, but feel awkward because Up/Down zoom, so you can't long press them to pan
|
||||
// Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() }
|
||||
// Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() }
|
||||
}
|
||||
return@onKeyEvent true
|
||||
}
|
||||
}
|
||||
if (it.type != KeyEventType.KeyUp) {
|
||||
result = false
|
||||
} else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) {
|
||||
// Image is zoomed in
|
||||
when (it.key) {
|
||||
Key.DirectionLeft -> pan(30, 0)
|
||||
Key.DirectionRight -> pan(-30, 0)
|
||||
Key.DirectionUp -> pan(0, 30)
|
||||
Key.DirectionDown -> pan(0, -30)
|
||||
}
|
||||
result = true
|
||||
} else if (!isOverlayShowing && isZoomed && it.key == Key.Back) {
|
||||
reset(false)
|
||||
result = true
|
||||
} else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) {
|
||||
when (it.key) {
|
||||
Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> {
|
||||
if (!viewModel.previousImage()) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
R.string.slideshow_at_beginning,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> {
|
||||
if (!viewModel.nextImage()) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
R.string.no_more_images,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isOverlayShowing && it.key == Key.Back) {
|
||||
showOverlay = false
|
||||
viewModel.unpauseSlideshow()
|
||||
result = true
|
||||
} else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) {
|
||||
showOverlay = true
|
||||
viewModel.pauseSlideshow()
|
||||
result = true
|
||||
}
|
||||
if (result) {
|
||||
// Handled the key, so reset the slideshow timer
|
||||
viewModel.pulseSlideshow()
|
||||
}
|
||||
result
|
||||
},
|
||||
) {
|
||||
when (loadingState) {
|
||||
ImageLoadingState.Error -> {
|
||||
ErrorMessage("Error loading image", null)
|
||||
}
|
||||
|
||||
ImageLoadingState.Loading -> {
|
||||
LoadingPage()
|
||||
}
|
||||
|
||||
is ImageLoadingState.Success -> {
|
||||
imageState?.let { imageState ->
|
||||
if (imageState.image.data.mediaType == MediaType.VIDEO) {
|
||||
LaunchedEffect(imageState.id) {
|
||||
val mediaItem =
|
||||
MediaItem
|
||||
.Builder()
|
||||
.setUri(imageState.url)
|
||||
.build()
|
||||
player.setMediaItem(mediaItem)
|
||||
player.repeatMode =
|
||||
if (slideshowState.enabled) {
|
||||
Player.REPEAT_MODE_OFF
|
||||
} else {
|
||||
Player.REPEAT_MODE_ONE
|
||||
}
|
||||
player.prepare()
|
||||
player.play()
|
||||
viewModel.pulseSlideshow(Long.MAX_VALUE)
|
||||
}
|
||||
LifecycleStartEffect(Unit) {
|
||||
onStopOrDispose {
|
||||
player.stop()
|
||||
}
|
||||
}
|
||||
val contentScale = ContentScale.Fit
|
||||
val scaledModifier =
|
||||
contentModifier.resizeWithContentScale(
|
||||
contentScale,
|
||||
presentationState.videoSizeDp,
|
||||
)
|
||||
PlayerSurface(
|
||||
player = player,
|
||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||
modifier =
|
||||
scaledModifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = zoomAnimation
|
||||
scaleY = zoomAnimation
|
||||
translationX = panXAnimation
|
||||
translationY = panYAnimation
|
||||
}.rotate(rotateAnimation),
|
||||
)
|
||||
if (presentationState.coverSurface) {
|
||||
Box(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(Color.Black),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val colorFilter =
|
||||
remember(imageState.id, imageFilter) {
|
||||
if (imageFilter.hasImageFilter()) {
|
||||
ColorMatrixColorFilter(imageFilter.colorMatrix)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
// If the image loading is large, show the thumbnail while waiting
|
||||
// TODO
|
||||
val showLoadingThumbnail = true
|
||||
SubcomposeAsyncImage(
|
||||
modifier =
|
||||
contentModifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = zoomAnimation
|
||||
scaleY = zoomAnimation
|
||||
translationX = panXAnimation
|
||||
translationY = panYAnimation
|
||||
|
||||
val xTransform =
|
||||
(screenWidth - panXAnimation) / (screenWidth * 2)
|
||||
val yTransform =
|
||||
(screenHeight - panYAnimation) / (screenHeight * 2)
|
||||
if (DEBUG) {
|
||||
Timber.d(
|
||||
"graphicsLayer: xTransform=$xTransform, yTransform=$yTransform",
|
||||
)
|
||||
}
|
||||
|
||||
transformOrigin = TransformOrigin(xTransform, yTransform)
|
||||
}.rotate(rotateAnimation),
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(imageState.url)
|
||||
.size(Size.ORIGINAL)
|
||||
.crossfade(!showLoadingThumbnail)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
colorFilter = colorFilter,
|
||||
error = {
|
||||
Text(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center),
|
||||
text = "Error loading image",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
},
|
||||
loading = {
|
||||
ImageLoadingPlaceholder(
|
||||
thumbnailUrl = imageState.thumbnailUrl,
|
||||
showThumbnail = showLoadingThumbnail,
|
||||
colorFilter = colorFilter,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
},
|
||||
// Ensure that if an image takes a long time to load, it won't be skipped
|
||||
onLoading = {
|
||||
viewModel.pulseSlideshow(Long.MAX_VALUE)
|
||||
},
|
||||
onSuccess = {
|
||||
viewModel.pulseSlideshow()
|
||||
},
|
||||
onError = {
|
||||
Timber.e(
|
||||
it.result.throwable,
|
||||
"Error loading image ${imageState.id}",
|
||||
)
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"Error loading image: ${it.result.throwable.localizedMessage}",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
viewModel.pulseSlideshow()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
showOverlay,
|
||||
enter = slideInVertically { it },
|
||||
exit = slideOutVertically { it },
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
) {
|
||||
imageState?.let { imageState ->
|
||||
ImageOverlay(
|
||||
modifier =
|
||||
contentModifier
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.TransparentBlack50),
|
||||
onDismiss = { showOverlay = false },
|
||||
player = player,
|
||||
slideshowControls = slideshowControls,
|
||||
slideshowEnabled = slideshowState.enabled,
|
||||
image = imageState,
|
||||
position = position,
|
||||
count = pager?.size ?: -1,
|
||||
onClickItem = {},
|
||||
onLongClickItem = {},
|
||||
onZoom = ::zoom,
|
||||
onRotate = { rotation += it },
|
||||
onReset = { reset(true) },
|
||||
onShowFilterDialogClick = {
|
||||
showFilterDialog = true
|
||||
showOverlay = false
|
||||
viewModel.pauseSlideshow()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(showFilterDialog) {
|
||||
ImageFilterDialog(
|
||||
filter = imageFilter,
|
||||
showVideoOptions = false,
|
||||
showSaveGalleryButton = true,
|
||||
onChange = viewModel::updateImageFilter,
|
||||
onClickSave = viewModel::saveImageFilter,
|
||||
onClickSaveGallery = viewModel::saveGalleryFilter,
|
||||
onDismissRequest = {
|
||||
showFilterDialog = false
|
||||
viewModel.unpauseSlideshow()
|
||||
viewModel.pulseSlideshow()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,445 @@
|
|||
package com.github.damontecres.wholphin.ui.slideshow
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.Player
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.data.PlaybackEffectDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.PlaybackEffect
|
||||
import com.github.damontecres.wholphin.data.model.VideoFilter
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.PlayerFactory
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.PhotoItemFields
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.onMain
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.util.ThrottledLiveData
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import dagger.assisted.Assisted
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
@HiltViewModel(assistedFactory = SlideshowViewModel.Factory::class)
|
||||
class SlideshowViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val playbackEffectDao: PlaybackEffectDao,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
@Assisted val slideshowSettings: Destination.Slideshow,
|
||||
) : ViewModel(),
|
||||
Player.Listener {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(slideshow: Destination.Slideshow): SlideshowViewModel
|
||||
}
|
||||
|
||||
val player by lazy {
|
||||
playerFactory.createVideoPlayer()
|
||||
}
|
||||
|
||||
private var saveFilters = true
|
||||
|
||||
/**
|
||||
* Whether slideshow mode is on or off
|
||||
*/
|
||||
private val _slideshow = MutableStateFlow<SlideshowState>(SlideshowState(false, false))
|
||||
val slideshow: StateFlow<SlideshowState> = _slideshow
|
||||
|
||||
/**
|
||||
* Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused
|
||||
*/
|
||||
val slideshowActive = slideshow.map { it.enabled && !it.paused }
|
||||
|
||||
var slideshowDelay by Delegates.notNull<Long>()
|
||||
|
||||
// private val album = MutableLiveData<BaseItem>()
|
||||
private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>()
|
||||
val pager: LiveData<List<BaseItem?>> = _pager.map { it }
|
||||
val position = MutableLiveData(0)
|
||||
|
||||
private val _image = MutableLiveData<ImageState>()
|
||||
val image: LiveData<ImageState> = _image
|
||||
|
||||
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
|
||||
private val _imageFilter = MutableLiveData(VideoFilter())
|
||||
val imageFilter = ThrottledLiveData(_imageFilter, 500L)
|
||||
|
||||
private var albumImageFilter = VideoFilter()
|
||||
|
||||
init {
|
||||
addCloseable {
|
||||
player.removeListener(this@SlideshowViewModel)
|
||||
player.release()
|
||||
}
|
||||
player.addListener(this@SlideshowViewModel)
|
||||
viewModelScope.launchIO {
|
||||
val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences
|
||||
slideshowDelay =
|
||||
photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min }
|
||||
?: AppPreference.SlideshowDuration.defaultValue
|
||||
// val album =
|
||||
// api.userLibraryApi
|
||||
// .getItem(
|
||||
// itemId = slideshowSettings.parentId,
|
||||
// ).content
|
||||
// .let { BaseItem(it, false) }
|
||||
// this@SlideshowViewModel.album.setValueOnMain(album)
|
||||
val includeItemTypes =
|
||||
if (photoPrefs.slideshowPlayVideos) {
|
||||
listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO)
|
||||
} else {
|
||||
listOf(BaseItemKind.PHOTO)
|
||||
}
|
||||
val request =
|
||||
slideshowSettings.filter.filter.applyTo(
|
||||
GetItemsRequest(
|
||||
parentId = slideshowSettings.parentId,
|
||||
includeItemTypes = includeItemTypes,
|
||||
fields = PhotoItemFields,
|
||||
recursive = true,
|
||||
sortBy = listOf(slideshowSettings.sortAndDirection.sort),
|
||||
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
|
||||
),
|
||||
)
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
val filter =
|
||||
playbackEffectDao
|
||||
.getPlaybackEffect(
|
||||
user.rowId,
|
||||
slideshowSettings.parentId,
|
||||
BaseItemKind.PHOTO_ALBUM,
|
||||
)?.videoFilter
|
||||
if (filter != null) {
|
||||
Timber.v("Got filter for album %s", slideshowSettings.parentId)
|
||||
albumImageFilter = filter
|
||||
}
|
||||
}
|
||||
val pager =
|
||||
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
||||
.init(slideshowSettings.index)
|
||||
this@SlideshowViewModel._pager.setValueOnMain(pager)
|
||||
updatePosition(slideshowSettings.index)?.join()
|
||||
if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
|
||||
}
|
||||
}
|
||||
|
||||
fun nextImage(): Boolean {
|
||||
val size = pager.value?.size
|
||||
val newPosition = position.value!! + 1
|
||||
return if (size != null && newPosition < size) {
|
||||
updatePosition(newPosition)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun previousImage(): Boolean {
|
||||
val newPosition = position.value!! - 1
|
||||
return if (newPosition >= 0) {
|
||||
updatePosition(newPosition)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePosition(position: Int): Job? =
|
||||
_pager.value?.let { pager ->
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
val image = pager.getBlocking(position)
|
||||
Timber.v("Got image for $position: ${image != null}")
|
||||
if (image != null) {
|
||||
this@SlideshowViewModel.position.setValueOnMain(position)
|
||||
|
||||
val url =
|
||||
if (image.data.mediaType == MediaType.VIDEO) {
|
||||
// TODO this assumes direct play
|
||||
api.videosApi.getVideoStreamUrl(
|
||||
itemId = image.id,
|
||||
)
|
||||
} else {
|
||||
api.libraryApi.getDownloadUrl(image.id)
|
||||
}
|
||||
val chosenStreams =
|
||||
if (image.data.mediaType == MediaType.VIDEO) {
|
||||
image.data.mediaSources?.firstOrNull()?.let { source ->
|
||||
val video =
|
||||
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||
val audio =
|
||||
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
|
||||
ChosenStreams(
|
||||
itemPlayback = null,
|
||||
plc = null,
|
||||
itemId = image.id,
|
||||
source = source,
|
||||
videoStream = video,
|
||||
audioStream = audio,
|
||||
subtitleStream = null,
|
||||
subtitlesDisabled = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val imageState =
|
||||
ImageState(
|
||||
image,
|
||||
url,
|
||||
imageUrlService.getItemImageUrl(image, ImageType.THUMB),
|
||||
chosenStreams,
|
||||
)
|
||||
// reset image filter
|
||||
updateImageFilter(albumImageFilter)
|
||||
if (saveFilters) {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
val vf =
|
||||
playbackEffectDao
|
||||
.getPlaybackEffect(
|
||||
user.rowId,
|
||||
image.id,
|
||||
BaseItemKind.PHOTO,
|
||||
)
|
||||
if (vf != null && vf.videoFilter.hasImageFilter()) {
|
||||
Timber.d(
|
||||
"Loaded VideoFilter for image ${image.id}",
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
// Pause throttling so that the image loads with the filter applied immediately
|
||||
imageFilter.stopThrottling(true)
|
||||
updateImageFilter(vf.videoFilter)
|
||||
imageFilter.startThrottling()
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
_image.value = imageState
|
||||
loadingState.value =
|
||||
ImageLoadingState.Success(imageState)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
_image.value = imageState
|
||||
loadingState.value = ImageLoadingState.Success(imageState)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loadingState.setValueOnMain(ImageLoadingState.Error)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
loadingState.setValueOnMain(ImageLoadingState.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var slideshowJob: Job? = null
|
||||
|
||||
fun startSlideshow() {
|
||||
_slideshow.update {
|
||||
SlideshowState(enabled = true, paused = false)
|
||||
}
|
||||
if (_image.value
|
||||
?.image
|
||||
?.data
|
||||
?.mediaType != MediaType.VIDEO
|
||||
) {
|
||||
pulseSlideshow()
|
||||
}
|
||||
}
|
||||
|
||||
fun stopSlideshow() {
|
||||
slideshowJob?.cancel()
|
||||
_slideshow.update {
|
||||
SlideshowState(enabled = false, paused = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseSlideshow() {
|
||||
Timber.v("pauseSlideshow")
|
||||
_slideshow.update {
|
||||
if (it.enabled) {
|
||||
slideshowJob?.cancel()
|
||||
it.copy(paused = true)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unpauseSlideshow() {
|
||||
Timber.v("unpauseSlideshow")
|
||||
_slideshow.update {
|
||||
if (it.enabled) {
|
||||
it.copy(paused = false)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pulseSlideshow() = pulseSlideshow(slideshowDelay)
|
||||
|
||||
fun pulseSlideshow(milliseconds: Long) {
|
||||
Timber.v("pulseSlideshow $milliseconds")
|
||||
slideshowJob?.cancel()
|
||||
slideshowJob =
|
||||
viewModelScope
|
||||
.launchIO {
|
||||
delay(milliseconds)
|
||||
// Timber.v("pulseSlideshow after delay")
|
||||
if (slideshowActive.first()) {
|
||||
// Next image or loop to beginning
|
||||
if (!nextImage()) updatePosition(0)
|
||||
}
|
||||
}.apply {
|
||||
invokeOnCompletion { if (it !is CancellationException) pulseSlideshow() }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateImageFilter(newFilter: VideoFilter) {
|
||||
viewModelScope.launchIO {
|
||||
_imageFilter.setValueOnMain(newFilter)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImageFilter() {
|
||||
image.value?.let {
|
||||
viewModelScope.launchIO {
|
||||
val vf = _imageFilter.value
|
||||
if (vf != null) {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
playbackEffectDao
|
||||
.insert(
|
||||
PlaybackEffect(
|
||||
user.rowId,
|
||||
it.image.id,
|
||||
BaseItemKind.PHOTO,
|
||||
vf,
|
||||
),
|
||||
)
|
||||
Timber.d("Saved VideoFilter for image %s", it.image.id)
|
||||
withContext(Dispatchers.Main) {
|
||||
showToast(
|
||||
context,
|
||||
"Saved",
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveGalleryFilter() {
|
||||
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||
val vf = _imageFilter.value
|
||||
if (vf != null) {
|
||||
albumImageFilter = vf
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
playbackEffectDao
|
||||
.insert(
|
||||
PlaybackEffect(
|
||||
user.rowId,
|
||||
slideshowSettings.parentId,
|
||||
BaseItemKind.PHOTO_ALBUM,
|
||||
vf,
|
||||
),
|
||||
)
|
||||
Timber.d("Saved VideoFilter for album %s", slideshowSettings.parentId)
|
||||
withContext(Dispatchers.Main) {
|
||||
showToast(
|
||||
context,
|
||||
"Saved",
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
pulseSlideshow(slideshowDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SlideshowControls {
|
||||
fun startSlideshow()
|
||||
|
||||
fun stopSlideshow()
|
||||
}
|
||||
|
||||
sealed class ImageLoadingState {
|
||||
data object Loading : ImageLoadingState()
|
||||
|
||||
data object Error : ImageLoadingState()
|
||||
|
||||
data class Success(
|
||||
val image: ImageState,
|
||||
) : ImageLoadingState()
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class ImageState(
|
||||
val image: BaseItem,
|
||||
val url: String,
|
||||
val thumbnailUrl: String?,
|
||||
val chosenStreams: ChosenStreams?,
|
||||
) {
|
||||
val id: UUID get() = image.id
|
||||
}
|
||||
|
||||
data class SlideshowState(
|
||||
val enabled: Boolean,
|
||||
val paused: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.github.damontecres.wholphin.ui.util
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
|
||||
/**
|
||||
* LiveData throttling value emissions so they don't happen more often than [delayMs].
|
||||
*
|
||||
* From https://stackoverflow.com/a/62467521
|
||||
*/
|
||||
class ThrottledLiveData<T>(
|
||||
source: LiveData<T>,
|
||||
delayMs: Long,
|
||||
) : MediatorLiveData<T>() {
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
var delayMs = delayMs
|
||||
private set
|
||||
|
||||
private var isValueDelayed = false
|
||||
private var delayedValue: T? = null
|
||||
private var delayRunnable: Runnable? = null
|
||||
set(value) {
|
||||
field?.let { handler.removeCallbacks(it) }
|
||||
value?.let { handler.postDelayed(it, delayMs) }
|
||||
field = value
|
||||
}
|
||||
private val objDelayRunnable = Runnable { if (consumeDelayedValue()) startDelay() }
|
||||
|
||||
init {
|
||||
addSource(source) { newValue ->
|
||||
if (delayRunnable == null) {
|
||||
value = newValue
|
||||
startDelay()
|
||||
} else {
|
||||
isValueDelayed = true
|
||||
delayedValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start throttling or modify the delay. If [newDelay] is `0` (default) reuse previous delay value. */
|
||||
fun startThrottling(newDelay: Long = 0L) {
|
||||
require(newDelay >= 0L)
|
||||
when {
|
||||
newDelay > 0 -> delayMs = newDelay
|
||||
delayMs < 0 -> delayMs *= -1
|
||||
delayMs > 0 -> return
|
||||
else -> throw kotlin.IllegalArgumentException("newDelay cannot be zero if old delayMs is zero")
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop throttling, if [immediate] emit any pending value now. */
|
||||
fun stopThrottling(immediate: Boolean = false) {
|
||||
if (delayMs <= 0) return
|
||||
delayMs *= -1
|
||||
if (immediate) consumeDelayedValue()
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
consumeDelayedValue()
|
||||
}
|
||||
|
||||
// start counting the delay or clear it if conditions are not met
|
||||
private fun startDelay() {
|
||||
delayRunnable = if (delayMs > 0 && hasActiveObservers()) objDelayRunnable else null
|
||||
}
|
||||
|
||||
private fun consumeDelayedValue(): Boolean {
|
||||
delayRunnable = null
|
||||
return if (isValueDelayed) {
|
||||
value = delayedValue
|
||||
delayedValue = null
|
||||
isValueDelayed = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +127,7 @@ message SubtitlePreferences{
|
|||
bool font_italic = 10;
|
||||
int32 margin = 11;
|
||||
int32 edge_thickness = 12;
|
||||
int32 image_subtitle_opacity = 13;
|
||||
}
|
||||
|
||||
message LiveTvPreferences {
|
||||
|
|
@ -152,12 +153,18 @@ message InterfacePreferences {
|
|||
SubtitlePreferences subtitles_preferences = 7;
|
||||
LiveTvPreferences live_tv_preferences = 8;
|
||||
BackdropStyle backdrop_style = 9;
|
||||
SubtitlePreferences hdr_subtitles_preferences = 10;
|
||||
}
|
||||
|
||||
message AdvancedPreferences {
|
||||
int64 image_disk_cache_size_bytes = 1;
|
||||
}
|
||||
|
||||
message PhotoPreferences{
|
||||
int64 slideshow_duration = 1;
|
||||
bool slideshow_play_videos = 2;
|
||||
}
|
||||
|
||||
message AppPreferences {
|
||||
// The currently signed in server and user IDs, mostly for restoring a session
|
||||
string current_server_id = 1;
|
||||
|
|
@ -173,4 +180,5 @@ message AppPreferences {
|
|||
bool debug_logging = 9;
|
||||
AdvancedPreferences advanced_preferences = 10;
|
||||
bool sign_in_automatically = 11;
|
||||
PhotoPreferences photo_preferences = 12;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<string name="fa_images" translatable="false"></string>
|
||||
<string name="fa_rotate_right" translatable="false"></string>
|
||||
<string name="fa_rotate_left" translatable="false"></string>
|
||||
<string name="fa_arrows_rotate" translatable="false"></string>
|
||||
<string name="fa_arrow_right_arrow_left" translatable="false"></string>
|
||||
<string name="fa_magnifying_glass_plus" translatable="false"></string>
|
||||
<string name="fa_magnifying_glass_minus" translatable="false"></string>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
<string name="confirm">Confirm</string>
|
||||
<string name="continue_watching">Continue watching</string>
|
||||
<string name="critic_rating">Critic Rating</string>
|
||||
<string name="decimal_seconds">%.1f seconds</string>
|
||||
<string name="decimal_seconds">%.2f seconds</string>
|
||||
<string name="default_track">Default</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="died">Died</string>
|
||||
|
|
@ -462,6 +462,29 @@
|
|||
<string name="request_4k">Request in 4K</string>
|
||||
<string name="software_decoding_av1">AV1 software decoding</string>
|
||||
<string name="upgrade_mpv_toast">MPV is now the default player except for HDR.\nYou can change this in settings.</string>
|
||||
<string name="hdr_subtitle_style">HDR subtitle style</string>
|
||||
<string name="image_subtitle_opacity">Image subtitle opacity</string>
|
||||
|
||||
<string name="brightness">Brightness</string>
|
||||
<string name="contrast">Constrast</string>
|
||||
<string name="saturation">Saturation</string>
|
||||
<string name="hue">Hue</string>
|
||||
<string name="red">Red</string>
|
||||
<string name="green">Green</string>
|
||||
<string name="blue">Blue</string>
|
||||
<string name="blur">Blur</string>
|
||||
<string name="save_for_album">Save for album</string>
|
||||
<string name="play_slideshow">Play slideshow</string>
|
||||
<string name="stop_slideshow">Stop slideshow</string>
|
||||
<string name="slideshow_at_beginning">At beginning</string>
|
||||
<string name="no_more_images">No more photos</string>
|
||||
<string name="rotate_left">Rotate left</string>
|
||||
<string name="rotate_right">Rotate right</string>
|
||||
<string name="zoom_in">Zoom in</string>
|
||||
<string name="zoom_out">Zoom out</string>
|
||||
<string name="slideshow_duration">Slideshow duration</string>
|
||||
<string name="play_videos_during_slideshow">Play videos during slideshow</string>
|
||||
|
||||
<string name="add_row">Add row</string>
|
||||
<string name="genres_in">Genres in %1$s</string>
|
||||
<string name="recently_released_in">Recently released in %1$s</string>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
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.BaseItemKind
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SuggestionServiceTest {
|
||||
@get:Rule
|
||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
private val mockApi = mockk<ApiClient>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>()
|
||||
private val mockCache = mockk<SuggestionsCache>()
|
||||
private val mockWorkManager = mockk<WorkManager>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
io.mockk.unmockkObject(GetItemsRequestHandler)
|
||||
}
|
||||
|
||||
private fun createService() =
|
||||
SuggestionService(
|
||||
api = mockApi,
|
||||
serverRepository = mockServerRepository,
|
||||
cache = mockCache,
|
||||
workManager = mockWorkManager,
|
||||
)
|
||||
|
||||
private fun mockQueryResult(items: List<BaseItemDto>): Response<BaseItemDtoQueryResult> =
|
||||
mockk {
|
||||
every { content } returns
|
||||
mockk {
|
||||
every { this@mockk.items } returns items
|
||||
}
|
||||
}
|
||||
|
||||
private fun mockUser(id: UUID = UUID.randomUUID()): JellyfinUser =
|
||||
JellyfinUser(
|
||||
id = id,
|
||||
name = "TestUser",
|
||||
serverId = UUID.randomUUID(),
|
||||
accessToken = "token",
|
||||
)
|
||||
|
||||
private fun mockWorkInfo(state: WorkInfo.State): WorkInfo = mockk<WorkInfo> { every { this@mockk.state } returns state }
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() =
|
||||
runTest {
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(null)
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maps_active_work_states_to_Loading() =
|
||||
runTest {
|
||||
listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state ->
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||
flowOf(listOf(mockWorkInfo(state)))
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Loading, result)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maps_finished_work_states_to_Empty() =
|
||||
runTest {
|
||||
listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state ->
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||
flowOf(listOf(mockWorkInfo(state)))
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_returnsEmpty_whenCacheEmptyAndNoWorkInfo() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passes_correct_arguments_to_cache() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
val otherLibraryId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
otherLibraryId,
|
||||
BaseItemKind.MOVIE,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(UUID.randomUUID()))
|
||||
|
||||
val service = createService()
|
||||
assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first())
|
||||
|
||||
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
libraryId,
|
||||
BaseItemKind.SERIES,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(UUID.randomUUID()))
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_returnsSuccess_whenCacheHasItems() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
parentId,
|
||||
BaseItemKind.MOVIE,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(cachedId))
|
||||
|
||||
val dto =
|
||||
mockk<BaseItemDto>(relaxed = true) {
|
||||
every { id } returns cachedId
|
||||
every { type } returns BaseItemKind.MOVIE
|
||||
}
|
||||
io.mockk.mockkObject(GetItemsRequestHandler)
|
||||
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto))
|
||||
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertTrue(result is SuggestionsResource.Success)
|
||||
val items = (result as SuggestionsResource.Success).items
|
||||
assertEquals(1, items.size)
|
||||
assertEquals(cachedId, items[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_emitsEmpty_whenApiFails() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
parentId,
|
||||
BaseItemKind.MOVIE,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(cachedId))
|
||||
|
||||
io.mockk.mockkObject(GetItemsRequestHandler)
|
||||
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } throws RuntimeException("Network error")
|
||||
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import kotlin.io.path.createTempDirectory
|
||||
|
||||
class SuggestionsCacheTest {
|
||||
private val tempDir = createTempDirectory("suggestions-cache-test").toFile()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun testCacheWithTempDir(): SuggestionsCache {
|
||||
val mockContext = mockk<Context>(relaxed = true)
|
||||
every { mockContext.cacheDir } returns tempDir
|
||||
return SuggestionsCache(mockContext)
|
||||
}
|
||||
|
||||
private fun memoryCacheOf(cache: SuggestionsCache): MutableMap<String, CachedSuggestions> {
|
||||
val field = SuggestionsCache::class.java.getDeclaredField("memoryCache")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return field.get(cache) as MutableMap<String, CachedSuggestions>
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putThenGet_returnsCachedSuggestions() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val libId = UUID.randomUUID()
|
||||
|
||||
cache.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
|
||||
val loaded = cache.get(userId, libId, BaseItemKind.MOVIE)
|
||||
assertNotNull(loaded)
|
||||
assertEquals(0, loaded!!.ids.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun get_readsFromDisk_whenMemoryAbsent() =
|
||||
runTest {
|
||||
val cache1 = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val libId = UUID.randomUUID()
|
||||
|
||||
cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
cache1.save()
|
||||
|
||||
// Create a fresh instance which won't have the memory entry
|
||||
val cache2 = testCacheWithTempDir()
|
||||
// memoryCache should be empty
|
||||
assertTrue(memoryCacheOf(cache2).isEmpty())
|
||||
|
||||
val loaded = cache2.get(userId, libId, BaseItemKind.MOVIE)
|
||||
assertNotNull(loaded)
|
||||
assertEquals(0, loaded!!.ids.size)
|
||||
// After read, memory cache should contain the entry
|
||||
assertTrue(memoryCacheOf(cache2).isNotEmpty())
|
||||
}
|
||||
|
||||
// LRU behavior is not enforced in production; keep tests focused on public behavior.
|
||||
@Test
|
||||
fun memoryCache_respectsLruLimit() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
|
||||
// Insert MAX + 2 entries and ensure size never exceeds limit
|
||||
val limit = 8 // keep in sync with implementation
|
||||
val libIds = mutableListOf<UUID>()
|
||||
for (i in 0 until (limit + 2)) {
|
||||
val libId = UUID.randomUUID()
|
||||
libIds.add(libId)
|
||||
cache.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
}
|
||||
|
||||
// memoryCache should be bounded to the limit
|
||||
val mem = memoryCacheOf(cache)
|
||||
assertTrue(mem.size <= limit)
|
||||
// The oldest (first inserted) should be evicted from memory cache
|
||||
val firstKey = "${userId}_${libIds.first()}_${BaseItemKind.MOVIE.serialName}"
|
||||
assertFalse(mem.containsKey(firstKey))
|
||||
}
|
||||
|
||||
// Library isolation tests - verify different parentIds/itemKinds don't mix
|
||||
|
||||
@Test
|
||||
fun differentParentIds_returnIsolatedCacheEntries() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val movieLibraryId = UUID.randomUUID()
|
||||
val tvLibraryId = UUID.randomUUID()
|
||||
|
||||
val movieIds = List(2) { UUID.randomUUID() }
|
||||
val tvIds = List(3) { UUID.randomUUID() }
|
||||
|
||||
cache.put(userId, movieLibraryId, BaseItemKind.MOVIE, movieIds)
|
||||
cache.put(userId, tvLibraryId, BaseItemKind.MOVIE, tvIds)
|
||||
|
||||
val loadedMovies = cache.get(userId, movieLibraryId, BaseItemKind.MOVIE)
|
||||
val loadedTv = cache.get(userId, tvLibraryId, BaseItemKind.MOVIE)
|
||||
|
||||
assertNotNull(loadedMovies)
|
||||
assertNotNull(loadedTv)
|
||||
assertEquals(2, loadedMovies!!.ids.size)
|
||||
assertEquals(3, loadedTv!!.ids.size)
|
||||
assertEquals(movieIds[0], loadedMovies.ids[0])
|
||||
assertEquals(tvIds[0], loadedTv.ids[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentItemKinds_returnIsolatedCacheEntries() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
|
||||
val movieIds = listOf(UUID.randomUUID())
|
||||
val seriesIds = List(2) { UUID.randomUUID() }
|
||||
|
||||
cache.put(userId, libraryId, BaseItemKind.MOVIE, movieIds)
|
||||
cache.put(userId, libraryId, BaseItemKind.SERIES, seriesIds)
|
||||
|
||||
val loadedMovies = cache.get(userId, libraryId, BaseItemKind.MOVIE)
|
||||
val loadedSeries = cache.get(userId, libraryId, BaseItemKind.SERIES)
|
||||
|
||||
assertNotNull(loadedMovies)
|
||||
assertNotNull(loadedSeries)
|
||||
assertEquals(1, loadedMovies!!.ids.size)
|
||||
assertEquals(2, loadedSeries!!.ids.size)
|
||||
assertEquals(movieIds[0], loadedMovies.ids[0])
|
||||
assertEquals(seriesIds[0], loadedSeries.ids[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rapidLibrarySwitching_maintainsIsolation() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val lib1 = UUID.randomUUID()
|
||||
val lib2 = UUID.randomUUID()
|
||||
val lib3 = UUID.randomUUID()
|
||||
|
||||
val ids1 = listOf(UUID.randomUUID())
|
||||
val ids2 = listOf(UUID.randomUUID())
|
||||
val ids3 = listOf(UUID.randomUUID())
|
||||
|
||||
// Simulate rapid switching: put -> get -> put -> get pattern
|
||||
cache.put(userId, lib1, BaseItemKind.MOVIE, ids1)
|
||||
assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
cache.put(userId, lib2, BaseItemKind.MOVIE, ids2)
|
||||
assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
// Switch back to lib1 - should still have correct data
|
||||
assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
cache.put(userId, lib3, BaseItemKind.MOVIE, ids3)
|
||||
assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
// Verify all libraries still have correct data after rapid switching
|
||||
assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun libraryIsolation_persistsToDisk() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val lib1 = UUID.randomUUID()
|
||||
val lib2 = UUID.randomUUID()
|
||||
|
||||
val ids1 = listOf(UUID.randomUUID())
|
||||
val ids2 = listOf(UUID.randomUUID())
|
||||
|
||||
// Write with first cache instance
|
||||
val cache1 = testCacheWithTempDir()
|
||||
cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1)
|
||||
cache1.put(userId, lib2, BaseItemKind.SERIES, ids2)
|
||||
cache1.save()
|
||||
|
||||
// Read with fresh cache instance (empty memory cache, reads from disk)
|
||||
val cache2 = testCacheWithTempDir()
|
||||
assertTrue(memoryCacheOf(cache2).isEmpty())
|
||||
|
||||
val loaded1 = cache2.get(userId, lib1, BaseItemKind.MOVIE)
|
||||
val loaded2 = cache2.get(userId, lib2, BaseItemKind.SERIES)
|
||||
|
||||
assertNotNull(loaded1)
|
||||
assertNotNull(loaded2)
|
||||
assertEquals(ids1[0], loaded1!!.ids[0])
|
||||
assertEquals(ids2[0], loaded2!!.ids[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentUsers_returnIsolatedCacheEntries() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val user1 = UUID.randomUUID()
|
||||
val user2 = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
|
||||
val user1Ids = listOf(UUID.randomUUID())
|
||||
val user2Ids = List(2) { UUID.randomUUID() }
|
||||
|
||||
cache.put(user1, libraryId, BaseItemKind.MOVIE, user1Ids)
|
||||
cache.put(user2, libraryId, BaseItemKind.MOVIE, user2Ids)
|
||||
|
||||
val loadedUser1 = cache.get(user1, libraryId, BaseItemKind.MOVIE)
|
||||
val loadedUser2 = cache.get(user2, libraryId, BaseItemKind.MOVIE)
|
||||
|
||||
assertNotNull(loadedUser1)
|
||||
assertNotNull(loadedUser2)
|
||||
assertEquals(1, loadedUser1!!.ids.size)
|
||||
assertEquals(2, loadedUser2!!.ids.size)
|
||||
assertEquals(user1Ids[0], loadedUser1.ids[0])
|
||||
assertEquals(user2Ids[0], loadedUser2.ids[0])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SuggestionsSchedulerServiceTest {
|
||||
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val currentLiveData = MutableLiveData<CurrentUser?>()
|
||||
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||
private val mockCache = mockk<SuggestionsCache>(relaxed = true)
|
||||
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||
private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true))
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
every { mockActivity.lifecycle } returns lifecycleRegistry
|
||||
every { mockServerRepository.current } returns currentLiveData
|
||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private fun createService() =
|
||||
SuggestionsSchedulerService(
|
||||
context = mockActivity,
|
||||
serverRepository = mockServerRepository,
|
||||
cache = mockCache,
|
||||
workManager = mockWorkManager,
|
||||
).also { it.dispatcher = testDispatcher }
|
||||
|
||||
@Test
|
||||
fun schedules_periodic_work_when_user_present() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancels_work_when_user_null() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
currentLiveData.value = null
|
||||
advanceUntilIdle()
|
||||
verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.Data
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.api.operations.UserViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class SuggestionsWorkerTest {
|
||||
private val testUserId = UUID.randomUUID()
|
||||
private val testServerId = UUID.randomUUID()
|
||||
private val mockWorkerParams = mockk<WorkerParameters>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||
private val mockPreferences = mockk<DataStore<AppPreferences>>(relaxed = true)
|
||||
private val mockApi = mockk<ApiClient>(relaxed = true)
|
||||
private val mockCache = mockk<SuggestionsCache>(relaxed = true)
|
||||
private val mockUserViewsApi = mockk<UserViewsApi>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { mockApi.userViewsApi } returns mockUserViewsApi
|
||||
every { mockApi.baseUrl } returns "http://localhost"
|
||||
every { mockApi.accessToken } returns "test-token"
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = unmockkObject(GetItemsRequestHandler)
|
||||
|
||||
private fun createWorker(
|
||||
userId: UUID? = testUserId,
|
||||
serverId: UUID? = testServerId,
|
||||
): SuggestionsWorker {
|
||||
val inputData =
|
||||
Data
|
||||
.Builder()
|
||||
.apply {
|
||||
userId?.let { putString(SuggestionsWorker.PARAM_USER_ID, it.toString()) }
|
||||
serverId?.let { putString(SuggestionsWorker.PARAM_SERVER_ID, it.toString()) }
|
||||
}.build()
|
||||
every { mockWorkerParams.inputData } returns inputData
|
||||
return SuggestionsWorker(
|
||||
context = mockk(relaxed = true),
|
||||
workerParams = mockWorkerParams,
|
||||
serverRepository = mockServerRepository,
|
||||
preferences = mockPreferences,
|
||||
api = mockApi,
|
||||
cache = mockCache,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mockPrefs() =
|
||||
mockk<AppPreferences>(relaxed = true) {
|
||||
every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 }
|
||||
}
|
||||
|
||||
private fun mockQueryResult(items: List<BaseItemDto> = emptyList()) =
|
||||
mockk<org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult>>(relaxed = true) {
|
||||
every { content } returns mockk { every { this@mockk.items } returns items }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returns_failure_on_invalid_input() =
|
||||
runTest {
|
||||
listOf(
|
||||
createWorker(userId = null),
|
||||
createWorker(serverId = null),
|
||||
).forEach { worker ->
|
||||
assertEquals(ListenableWorker.Result.failure(), worker.doWork())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restores_session_when_api_not_configured() =
|
||||
runTest {
|
||||
every { mockApi.baseUrl } returns null
|
||||
every { mockApi.accessToken } returns null
|
||||
val mockUser = mockk<CurrentUser>()
|
||||
var restored = false
|
||||
every { mockServerRepository.current } answers { MutableLiveData(if (restored) mockUser else null) }
|
||||
coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
|
||||
restored = true
|
||||
mockUser
|
||||
}
|
||||
every { mockPreferences.data } returns flowOf(mockPrefs())
|
||||
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult()
|
||||
|
||||
val result = createWorker().doWork()
|
||||
|
||||
coVerify { mockServerRepository.restoreSession(testServerId, testUserId) }
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun caches_suggestions_for_supported_types() =
|
||||
runTest {
|
||||
listOf(
|
||||
CollectionType.MOVIES to BaseItemKind.MOVIE,
|
||||
CollectionType.TVSHOWS to BaseItemKind.SERIES,
|
||||
).forEach { (collectionType, itemKind) ->
|
||||
val viewId = UUID.randomUUID()
|
||||
val view =
|
||||
mockk<BaseItemDto>(relaxed = true) {
|
||||
every { id } returns viewId
|
||||
every { this@mockk.collectionType } returns collectionType
|
||||
}
|
||||
every { mockPreferences.data } returns flowOf(mockPrefs())
|
||||
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view))
|
||||
mockkObject(GetItemsRequestHandler)
|
||||
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(mockk(relaxed = true)))
|
||||
|
||||
val result = createWorker().doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
|
||||
coVerify { mockCache.save() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returns_retry_on_network_error() =
|
||||
runTest {
|
||||
every { mockPreferences.data } returns flowOf(mockPrefs())
|
||||
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } throws ApiClientException("Network error")
|
||||
|
||||
val result = createWorker().doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.retry(), result)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.NameGuidPair
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
/**
|
||||
* Tests for the suggestions deduplication and genre collection logic used in
|
||||
* RecommendedMovie and RecommendedTvShow ViewModels.
|
||||
*/
|
||||
class TestSuggestionsDeduplication {
|
||||
@Test
|
||||
fun `deduplication by seriesId groups episodes of same series`() {
|
||||
val seriesId = UUID.randomUUID()
|
||||
val items =
|
||||
listOf(
|
||||
episode(seriesId = seriesId, name = "Episode 1"),
|
||||
episode(seriesId = seriesId, name = "Episode 2"),
|
||||
episode(seriesId = seriesId, name = "Episode 3"),
|
||||
movie(name = "Some Movie"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
// Should have 2 items: one series entry and one movie
|
||||
assertEquals(2, deduplicated.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deduplication preserves order - most recent first`() {
|
||||
val series1 = UUID.randomUUID()
|
||||
val series2 = UUID.randomUUID()
|
||||
val items =
|
||||
listOf(
|
||||
episode(seriesId = series1, name = "S1 Episode 1"), // Most recent
|
||||
episode(seriesId = series1, name = "S1 Episode 2"),
|
||||
episode(seriesId = series2, name = "S2 Episode 1"),
|
||||
movie(name = "Old Movie"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
assertEquals(3, deduplicated.size)
|
||||
// First item should be from series1 (most recent)
|
||||
assertEquals(series1, deduplicated[0].seriesId)
|
||||
assertEquals(series2, deduplicated[1].seriesId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movies use their own id for deduplication`() {
|
||||
val items =
|
||||
listOf(
|
||||
movie(name = "Movie 1"),
|
||||
movie(name = "Movie 2"),
|
||||
movie(name = "Movie 3"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
// All movies should be kept since they have unique IDs
|
||||
assertEquals(3, deduplicated.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `take 3 limits seed items`() {
|
||||
val items =
|
||||
listOf(
|
||||
movie(name = "Movie 1"),
|
||||
movie(name = "Movie 2"),
|
||||
movie(name = "Movie 3"),
|
||||
movie(name = "Movie 4"),
|
||||
movie(name = "Movie 5"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
assertEquals(3, deduplicated.size)
|
||||
}
|
||||
}
|
||||
|
||||
class TestSuggestionsGenreCollection {
|
||||
@Test
|
||||
fun `collects genres from multiple seed items`() {
|
||||
val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action")
|
||||
val genre2 = NameGuidPair(id = UUID.randomUUID(), name = "Comedy")
|
||||
val genre3 = NameGuidPair(id = UUID.randomUUID(), name = "Drama")
|
||||
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Action Movie", genres = listOf(genre1)),
|
||||
movie(name = "Comedy Movie", genres = listOf(genre2)),
|
||||
movie(name = "Drama Movie", genres = listOf(genre3)),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
assertEquals(3, allGenreIds.size)
|
||||
assertTrue(allGenreIds.contains(genre1.id))
|
||||
assertTrue(allGenreIds.contains(genre2.id))
|
||||
assertTrue(allGenreIds.contains(genre3.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deduplicates shared genres across seed items`() {
|
||||
val sharedGenre = NameGuidPair(id = UUID.randomUUID(), name = "Action")
|
||||
val uniqueGenre = NameGuidPair(id = UUID.randomUUID(), name = "Comedy")
|
||||
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Action Movie 1", genres = listOf(sharedGenre)),
|
||||
movie(name = "Action Movie 2", genres = listOf(sharedGenre)),
|
||||
movie(name = "Action Comedy", genres = listOf(sharedGenre, uniqueGenre)),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
// Should only have 2 unique genres
|
||||
assertEquals(2, allGenreIds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles items with no genres`() {
|
||||
val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action")
|
||||
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Movie with genre", genres = listOf(genre1)),
|
||||
movie(name = "Movie without genre", genres = null),
|
||||
movie(name = "Movie with empty genres", genres = emptyList()),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
assertEquals(1, allGenreIds.size)
|
||||
assertEquals(genre1.id, allGenreIds[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty list when no seed items have genres`() {
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Movie 1", genres = null),
|
||||
movie(name = "Movie 2", genres = emptyList()),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
assertTrue(allGenreIds.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
class TestSuggestionsExcludeIds {
|
||||
@Test
|
||||
fun `excludeIds contains all seed item ids`() {
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Movie 1"),
|
||||
movie(name = "Movie 2"),
|
||||
movie(name = "Movie 3"),
|
||||
)
|
||||
|
||||
val excludeIds = seedItems.map { it.id }
|
||||
|
||||
assertEquals(3, excludeIds.size)
|
||||
seedItems.forEach { item ->
|
||||
assertTrue(excludeIds.contains(item.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class TestSuggestionsCombineAndDeduplicate(
|
||||
private val contextual: List<BaseItemDto>,
|
||||
private val random: List<BaseItemDto>,
|
||||
private val fresh: List<BaseItemDto>,
|
||||
private val expectedUniqueCount: Int,
|
||||
private val description: String,
|
||||
) {
|
||||
@Test
|
||||
fun `combine and deduplicate works correctly`() {
|
||||
val combined =
|
||||
(contextual + random + fresh)
|
||||
.distinctBy { it.id }
|
||||
|
||||
assertEquals(description, expectedUniqueCount, combined.size)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{4}")
|
||||
fun data(): Collection<Array<Any>> {
|
||||
val movie1 = movie(name = "Movie 1")
|
||||
val movie2 = movie(name = "Movie 2")
|
||||
val movie3 = movie(name = "Movie 3")
|
||||
val movie4 = movie(name = "Movie 4")
|
||||
|
||||
return listOf(
|
||||
arrayOf(
|
||||
listOf(movie1, movie2),
|
||||
listOf(movie3),
|
||||
listOf(movie4),
|
||||
4,
|
||||
"no duplicates - all 4 unique",
|
||||
),
|
||||
arrayOf(
|
||||
listOf(movie1, movie2),
|
||||
listOf(movie1, movie3),
|
||||
listOf(movie2, movie4),
|
||||
4,
|
||||
"with duplicates - deduplicates to 4",
|
||||
),
|
||||
arrayOf(
|
||||
listOf(movie1),
|
||||
listOf(movie1),
|
||||
listOf(movie1),
|
||||
1,
|
||||
"all same - deduplicates to 1",
|
||||
),
|
||||
arrayOf(
|
||||
emptyList<BaseItemDto>(),
|
||||
listOf(movie1, movie2),
|
||||
listOf(movie3),
|
||||
3,
|
||||
"empty contextual - still combines others",
|
||||
),
|
||||
arrayOf(
|
||||
emptyList<BaseItemDto>(),
|
||||
emptyList<BaseItemDto>(),
|
||||
emptyList<BaseItemDto>(),
|
||||
0,
|
||||
"all empty - returns empty",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to create test data
|
||||
|
||||
private fun movie(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
name: String = "Test Movie",
|
||||
genres: List<NameGuidPair>? = null,
|
||||
): BaseItemDto =
|
||||
BaseItemDto(
|
||||
id = id,
|
||||
type = BaseItemKind.MOVIE,
|
||||
name = name,
|
||||
seriesId = null,
|
||||
genreItems = genres,
|
||||
)
|
||||
|
||||
private fun episode(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
seriesId: UUID,
|
||||
name: String = "Test Episode",
|
||||
genres: List<NameGuidPair>? = null,
|
||||
): BaseItemDto =
|
||||
BaseItemDto(
|
||||
id = id,
|
||||
type = BaseItemKind.EPISODE,
|
||||
name = name,
|
||||
seriesId = seriesId,
|
||||
genreItems = genres,
|
||||
)
|
||||
|
|
@ -41,6 +41,8 @@ preferenceKtx = "1.2.1"
|
|||
tvprovider = "1.1.0"
|
||||
workRuntimeKtx = "2.11.1"
|
||||
paletteKtx = "1.0.0"
|
||||
kotlinxCoroutinesTest = "1.7.3"
|
||||
coreTesting = "2.2.0"
|
||||
openapi-generator = "7.19.0"
|
||||
|
||||
[libraries]
|
||||
|
|
@ -67,6 +69,7 @@ androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-com
|
|||
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
|
||||
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
|
||||
|
|
@ -97,6 +100,7 @@ androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls
|
|||
androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" }
|
||||
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
||||
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
||||
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
|
||||
|
||||
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
|
|
@ -123,6 +127,8 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
|||
androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
|
||||
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
|
||||
androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
|
||||
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue