Merge branch 'main' into develop/customize-home

This commit is contained in:
Damontecres 2026-02-04 20:54:37 -05:00
commit 2d77249099
No known key found for this signature in database
54 changed files with 5411 additions and 185 deletions

View file

@ -223,6 +223,7 @@ dependencies {
implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material) implementation(libs.androidx.tv.material)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.protobuf.kotlin.lite) implementation(libs.protobuf.kotlin.lite)
@ -265,6 +266,7 @@ dependencies {
implementation(libs.androidx.preference.ktx) implementation(libs.androidx.preference.ktx)
implementation(libs.androidx.room.testing) implementation(libs.androidx.room.testing)
implementation(libs.androidx.palette.ktx) implementation(libs.androidx.palette.ktx)
implementation(libs.androidx.media3.effect)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler) ksp(libs.androidx.hilt.compiler)
@ -298,5 +300,7 @@ dependencies {
testImplementation(libs.mockk.android) testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent) testImplementation(libs.mockk.agent)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
} }

View file

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

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore 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.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager 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.UpdateChecker
import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.UserSwitchListener
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService 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.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent 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.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -116,6 +120,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var tvProviderSchedulerService: TvProviderSchedulerService lateinit var tvProviderSchedulerService: TvProviderSchedulerService
@Inject
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
// Note: unused but injected to ensure it is created // Note: unused but injected to ensure it is created
@Inject @Inject
lateinit var serverEventListener: ServerEventListener lateinit var serverEventListener: ServerEventListener
@ -133,7 +140,9 @@ class MainActivity : AppCompatActivity() {
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver) lifecycle.addObserver(playbackLifecycleObserver)
if (savedInstanceState == null) { if (savedInstanceState == null) {
appUpgradeHandler.copySubfont(false) lifecycleScope.launchIO {
appUpgradeHandler.copySubfont(false)
}
} }
viewModel.serverRepository.currentUser.observe(this) { user -> viewModel.serverRepository.currentUser.observe(this) { user ->
if (user?.hasPin == true) { if (user?.hasPin == true) {
@ -148,6 +157,25 @@ class MainActivity : AppCompatActivity() {
viewModel.appStart() viewModel.appStart()
setContent { setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null) 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 -> appPreferences?.let { appPreferences ->
LaunchedEffect(appPreferences.signInAutomatically) { LaunchedEffect(appPreferences.signInAutomatically) {
signInAuto = appPreferences.signInAutomatically signInAuto = appPreferences.signInAutomatically

View file

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

View file

@ -0,0 +1,22 @@
package com.github.damontecres.wholphin.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.damontecres.wholphin.data.model.PlaybackEffect
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
@Dao
interface PlaybackEffectDao {
@Query("SELECT * FROM playback_effects WHERE jellyfinUserRowId=:jellyfinUserRowId AND itemId=:itemId AND type=:type")
suspend fun getPlaybackEffect(
jellyfinUserRowId: Int,
itemId: UUID,
type: BaseItemKind,
): PlaybackEffect?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(playbackEffect: PlaybackEffect)
}

View file

@ -107,6 +107,12 @@ data class BaseItem(
data.premiereDate?.let { add(DateFormatter.format(it)) } data.premiereDate?.let { add(DateFormatter.format(it)) }
} else if (type == BaseItemKind.SERIES) { } else if (type == BaseItemKind.SERIES) {
data.seriesProductionYears?.let(::add) 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 { } else {
data.productionYear?.let { add(it.toString()) } data.productionYear?.let { add(it.toString()) }
} }
@ -155,7 +161,7 @@ data class BaseItem(
it.dayOfMonth.toString().padStart(2, '0') it.dayOfMonth.toString().padStart(2, '0')
}?.toIntOrNull() }?.toIntOrNull()
fun destination(): Destination { fun destination(index: Int? = null): Destination {
val result = val result =
// Redirect episodes & seasons to their series if possible // Redirect episodes & seasons to their series if possible
when (type) { when (type) {

View file

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

View file

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

View file

@ -716,7 +716,7 @@ sealed interface AppPreference<Pref, T> {
val SubtitleStyle = val SubtitleStyle =
AppDestinationPreference<AppPreferences>( AppDestinationPreference<AppPreferences>(
title = R.string.subtitle_style, title = R.string.subtitle_style,
destination = Destination.Settings(PreferenceScreenOption.SUBTITLES), destination = Destination.SubtitleSettings(false),
) )
val RefreshRateSwitching = val RefreshRateSwitching =
@ -909,6 +909,46 @@ sealed interface AppPreference<Pref, T> {
getter = { }, getter = { },
setter = { prefs, _ -> prefs }, 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.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout, AppPreference.ControllerTimeout,
AppPreference.BackdropStylePref, AppPreference.BackdropStylePref,
AppPreference.SlideshowDuration,
AppPreference.SlideshowPlayVideos,
), ),
), ),
) )

View file

@ -100,6 +100,12 @@ class AppPreferencesSerializer
.apply { .apply {
resetSubtitles() resetSubtitles()
}.build() }.build()
hdrSubtitlesPreferences =
SubtitlePreferences
.newBuilder()
.apply {
resetSubtitles()
}.build()
liveTvPreferences = liveTvPreferences =
LiveTvPreferences LiveTvPreferences
@ -122,6 +128,14 @@ class AppPreferencesSerializer
imageDiskCacheSizeBytes = imageDiskCacheSizeBytes =
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
}.build() }.build()
photoPreferences =
PhotoPreferences
.newBuilder()
.apply {
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue
}.build()
}.build() }.build()
override suspend fun readFrom(input: InputStream): AppPreferences { override suspend fun readFrom(input: InputStream): AppPreferences {
@ -180,6 +194,11 @@ inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.B
advancedPreferences = advancedPreferences.toBuilder().apply(block).build() 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() { fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
@ -193,4 +212,5 @@ fun SubtitlePreferences.Builder.resetSubtitles() {
backgroundStyle = SubtitleSettings.BackgroundStylePref.defaultValue backgroundStyle = SubtitleSettings.BackgroundStylePref.defaultValue
margin = SubtitleSettings.Margin.defaultValue.toInt() margin = SubtitleSettings.Margin.defaultValue.toInt()
edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt()
imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt()
} }

View file

@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions 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.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel 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
}
}
}
} }

View file

@ -4,6 +4,9 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -16,12 +19,16 @@ class PlaybackLifecycleObserver
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val suggestionsCache: SuggestionsCache,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
private var wasPlaying: Boolean? = null private var wasPlaying: Boolean? = null
override fun onStart(owner: LifecycleOwner) { override fun onStart(owner: LifecycleOwner) {
val lastDest = navigationManager.backStack.lastOrNull() 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() navigationManager.goBack()
} }
wasPlaying = null wasPlaying = null
@ -45,5 +52,6 @@ class PlaybackLifecycleObserver
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
themeSongPlayer.stop() themeSongPlayer.stop()
CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() }
} }
} }

View file

@ -29,7 +29,7 @@ class RefreshRateService
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) { ) {
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager 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() val supportedDisplayModes get() = display.supportedModes.orEmpty()

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services.hilt
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
@ -176,6 +177,12 @@ object AppModule {
@IoCoroutineScope @IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun workManager(
@ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context)
@Provides @Provides
@Singleton @Singleton
fun seerrApi( fun seerrApi(

View file

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

View file

@ -29,9 +29,9 @@ class TvProviderSchedulerService
constructor( constructor(
@param:ActivityContext private val context: Context, @param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val workManager: WorkManager,
) { ) {
private val activity = (context as AppCompatActivity) private val activity = (context as AppCompatActivity)
private val workManager = WorkManager.getInstance(context)
private val supportsTvProvider = private val supportsTvProvider =
// TODO <=25 has limited support // TODO <=25 has limited support

View file

@ -57,6 +57,7 @@ val DefaultItemFields =
ItemFields.CHAPTERS, ItemFields.CHAPTERS,
ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID,
) )
/** /**
@ -70,8 +71,16 @@ val SlimItemFields =
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID,
) )
val PhotoItemFields =
DefaultItemFields +
listOf(
ItemFields.WIDTH,
ItemFields.HEIGHT,
)
object Cards { object Cards {
const val HEIGHT_2X3_DP = 172 const val HEIGHT_2X3_DP = 172
val height2x3 = HEIGHT_2X3_DP.dp val height2x3 = HEIGHT_2X3_DP.dp

View file

@ -577,21 +577,45 @@ fun CollectionFolderGrid(
onSaveViewOptions = { viewModel.saveViewOptions(it) }, onSaveViewOptions = { viewModel.saveViewOptions(it) },
onChangeBackdrop = viewModel::updateBackdrop, onChangeBackdrop = viewModel::updateBackdrop,
playEnabled = playEnabled, playEnabled = playEnabled,
onClickPlay = { _, item -> onClickPlay = { index, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(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 -> onClickPlayAll = { shuffle ->
itemId.toUUIDOrNull()?.let { itemId.toUUIDOrNull()?.let {
viewModel.navigationManager.navigateTo( val destination =
Destination.PlaybackList( if (item?.type == BaseItemKind.PHOTO_ALBUM) {
itemId = it, Destination.Slideshow(
startIndex = 0, parentId = it,
shuffle = shuffle, index = 0,
recursive = recursive, filter = CollectionFolderFilter(filter = filter),
sortAndDirection = sortAndDirection, sortAndDirection = sortAndDirection,
filter = filter, recursive = true,
), startSlideshow = true,
) )
} else {
Destination.PlaybackList(
itemId = it,
startIndex = 0,
shuffle = shuffle,
recursive = recursive,
sortAndDirection = sortAndDirection,
filter = filter,
)
}
viewModel.navigationManager.navigateTo(destination)
} }
}, },
) )

View file

@ -34,7 +34,9 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -180,6 +182,63 @@ fun ExpandablePlayButton(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
mirrorIcon: Boolean = false, mirrorIcon: Boolean = false,
enabled: Boolean = true, 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 val isFocused = interactionSource.collectIsFocusedAsState().value
Button( Button(
@ -200,14 +259,7 @@ fun ExpandablePlayButton(
.padding(start = 2.dp, top = 2.dp) .padding(start = 2.dp, top = 2.dp)
.height(MinButtonSize), .height(MinButtonSize),
) { ) {
Icon( icon.invoke()
imageVector = icon,
contentDescription = null,
modifier =
Modifier
.size(28.dp)
.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
)
} }
AnimatedVisibility(isFocused) { AnimatedVisibility(isFocused) {
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
@ -343,6 +395,13 @@ private fun ViewOptionsPreview() {
onClick = {}, onClick = {},
interactionSource = source, interactionSource = source,
) )
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = painterResource(R.drawable.baseline_pause_24),
onClick = {},
interactionSource = source,
)
ExpandableFaButton( ExpandableFaButton(
title = R.string.play, title = R.string.play,
iconStringRes = R.string.fa_eye, iconStringRes = R.string.fa_eye,

View file

@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager 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.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain 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.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler 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.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted 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.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -54,6 +54,7 @@ class RecommendedMovieViewModel
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
private val suggestionService: SuggestionService,
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
@ -115,7 +116,7 @@ class RecommendedMovieViewModel
} }
update(R.string.recently_released) { update(R.string.recently_released) {
val recentlyReleasedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -128,13 +129,11 @@ class RecommendedMovieViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
.execute(api, recentlyReleasedRequest)
.toBaseItems(api, false)
} }
update(R.string.recently_added) { update(R.string.recently_added) {
val recentlyAddedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -147,27 +146,11 @@ class RecommendedMovieViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
.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)
} }
update(R.string.top_unwatched) { update(R.string.top_unwatched) {
val unwatchedTopRatedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -181,9 +164,48 @@ class RecommendedMovieViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
.execute(api, unwatchedTopRatedRequest) }
.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) { if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {

View file

@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.NavigationManager 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.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain 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.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler 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.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted 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.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -58,6 +58,7 @@ class RecommendedTvShowViewModel
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
private val lastestNextUpService: LatestNextUpService, private val lastestNextUpService: LatestNextUpService,
private val suggestionService: SuggestionService,
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
@ -86,7 +87,7 @@ class RecommendedTvShowViewModel
val userId = serverRepository.currentUser.value?.id val userId = serverRepository.currentUser.value?.id
try { try {
val resumeItemsDeferred = val resumeItemsDeferred =
viewModelScope.async(Dispatchers.IO) { async(Dispatchers.IO) {
val resumeItemsRequest = val resumeItemsRequest =
GetResumeItemsRequest( GetResumeItemsRequest(
userId = userId, userId = userId,
@ -104,7 +105,7 @@ class RecommendedTvShowViewModel
} }
val nextUpItemsDeferred = val nextUpItemsDeferred =
viewModelScope.async(Dispatchers.IO) { async(Dispatchers.IO) {
val nextUpRequest = val nextUpRequest =
GetNextUpRequest( GetNextUpRequest(
userId = userId, userId = userId,
@ -116,19 +117,16 @@ class RecommendedTvShowViewModel
enableUserData = true, enableUserData = true,
enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp, enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp,
) )
GetNextUpRequestHandler GetNextUpRequestHandler
.execute(api, nextUpRequest) .execute(api, nextUpRequest)
.toBaseItems(api, true) .toBaseItems(api, true)
} }
val resumeItems = resumeItemsDeferred.await() val resumeItems = resumeItemsDeferred.await()
val nextUpItems = nextUpItemsDeferred.await() val nextUpItems = nextUpItemsDeferred.await()
if (combineNextUp) { if (combineNextUp) {
val combined = val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems)
lastestNextUpService.buildCombined(
resumeItems,
nextUpItems,
)
update( update(
R.string.continue_watching, R.string.continue_watching,
HomeRowLoadingState.Success( HomeRowLoadingState.Success(
@ -138,10 +136,7 @@ class RecommendedTvShowViewModel
) )
update( update(
R.string.next_up, R.string.next_up,
HomeRowLoadingState.Success( HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()),
context.getString(R.string.next_up),
listOf(),
),
) )
} else { } else {
update( update(
@ -153,10 +148,7 @@ class RecommendedTvShowViewModel
) )
update( update(
R.string.next_up, R.string.next_up,
HomeRowLoadingState.Success( HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems),
context.getString(R.string.next_up),
nextUpItems,
),
) )
} }
@ -171,7 +163,7 @@ class RecommendedTvShowViewModel
} }
update(R.string.recently_released) { update(R.string.recently_released) {
val recentlyReleasedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -184,14 +176,11 @@ class RecommendedTvShowViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
GetItemsRequestHandler
.execute(api, recentlyReleasedRequest)
.toBaseItems(api, true)
} }
update(R.string.recently_added) { update(R.string.recently_added) {
val recentlyAddedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -204,29 +193,11 @@ class RecommendedTvShowViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
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)
} }
update(R.string.top_unwatched) { update(R.string.top_unwatched) {
val unwatchedTopRatedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -240,9 +211,48 @@ class RecommendedTvShowViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
.execute(api, unwatchedTopRatedRequest) }
.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) { if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {

View file

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

View file

@ -45,7 +45,9 @@ fun CollectionFolderGeneric(
} }
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, onClickItem = { index, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination(index))
},
itemId = itemId, itemId = itemId,
initialFilter = filter, initialFilter = filter,
showTitle = showHeader, showTitle = showHeader,

View file

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

View file

@ -41,6 +41,11 @@ sealed class Destination(
val screen: PreferenceScreenOption, val screen: PreferenceScreenOption,
) : Destination(true) ) : Destination(true)
@Serializable
data class SubtitleSettings(
val hdr: Boolean,
) : Destination(true)
@Serializable @Serializable
data object Search : Destination() data object Search : Destination()
@ -103,6 +108,16 @@ sealed class Destination(
val itemIds: List<UUID>, val itemIds: List<UUID>,
) : Destination(false) ) : 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 @Serializable
data object Favorites : Destination(false) data object Favorites : Destination(false)

View file

@ -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.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie 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.CollectionFolderPlaylist
import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv 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.main.settings.HomeSettingsPage
import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage 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.setup.InstallUpdatePage
import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber import timber.log.Timber
@ -83,6 +86,14 @@ fun DestinationContent(
) )
} }
is Destination.SubtitleSettings -> {
SubtitleStylePage(
preferences.appPreferences,
destination.hdr,
modifier,
)
}
is Destination.SeriesOverview -> { is Destination.SeriesOverview -> {
SeriesOverview( SeriesOverview(
preferences = preferences, 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 -> { else -> {
Timber.w("Unsupported item type: ${destination.type}") Timber.w("Unsupported item type: ${destination.type}")
Text("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 -> { Destination.Favorites -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() } LaunchedEffect(Unit) { onClearBackdrop.invoke() }
FavoritesPage( FavoritesPage(

View file

@ -34,6 +34,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton 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.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize 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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -176,6 +179,7 @@ fun PlaybackPageContent(
LaunchedEffect(player) { LaunchedEffect(player) {
if (playerBackend == PlayerBackend.MPV) { if (playerBackend == PlayerBackend.MPV) {
scope.launch(Dispatchers.IO + ExceptionHandler()) { scope.launch(Dispatchers.IO + ExceptionHandler()) {
// MPV can't play HDR, so always use regular settings
preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv(
configuration, configuration,
density, 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 // Subtitles
if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) {
val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f)
val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null }
AndroidView( AndroidView(
factory = { context -> factory = { context ->
SubtitleView(context).apply { SubtitleView(context).apply {
preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { subtitleSettings.let {
setStyle(it.toSubtitleStyle()) setStyle(it.toSubtitleStyle())
setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setFixedTextSize(Dimension.SP, it.fontSize.toFloat())
setBottomPaddingFraction(it.margin.toFloat() / 100f) setBottomPaddingFraction(it.margin.toFloat() / 100f)
@ -416,10 +433,8 @@ fun PlaybackPageContent(
}, },
update = { update = {
it.setCues(cues) it.setCues(cues)
Media3SubtitleOverride( Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density))
preferences.appPreferences.interfacePreferences.subtitlesPreferences .apply(it)
.calculateEdgeSize(density),
).apply(it)
}, },
onReset = { onReset = {
it.setCues(null) it.setCues(null)
@ -428,7 +443,8 @@ fun PlaybackPageContent(
Modifier Modifier
.fillMaxSize(maxSize) .fillMaxSize(maxSize)
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.background(Color.Transparent), .background(Color.Transparent)
.ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)),
) )
} }
} }

View file

@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.preferences
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@ -11,12 +10,12 @@ import kotlinx.serialization.Serializable
data class PreferenceGroup<T>( data class PreferenceGroup<T>(
@param:StringRes val title: Int, @param:StringRes val title: Int,
val preferences: List<AppPreference<T, out Any?>>, val preferences: List<AppPreference<T, out Any?>>,
val conditionalPreferences: List<ConditionalPreferences> = listOf(), val conditionalPreferences: List<ConditionalPreferences<T>> = listOf(),
) )
data class ConditionalPreferences( data class ConditionalPreferences<T>(
val condition: (AppPreferences) -> Boolean, val condition: (T) -> Boolean,
val preferences: List<AppPreference<AppPreferences, out Any?>>, val preferences: List<AppPreference<T, out Any?>>,
) )
/** /**
@ -34,7 +33,6 @@ sealed interface PreferenceValidation {
enum class PreferenceScreenOption { enum class PreferenceScreenOption {
BASIC, BASIC,
ADVANCED, ADVANCED,
SUBTITLES,
EXO_PLAYER, EXO_PLAYER,
MPV, MPV,
; ;

View file

@ -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.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings 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.UpdateViewModel
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
@ -124,7 +123,6 @@ fun PreferencesContent(
when (preferenceScreenOption) { when (preferenceScreenOption) {
PreferenceScreenOption.BASIC -> basicPreferences PreferenceScreenOption.BASIC -> basicPreferences
PreferenceScreenOption.ADVANCED -> advancedPreferences PreferenceScreenOption.ADVANCED -> advancedPreferences
PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
PreferenceScreenOption.MPV -> MpvPreferences PreferenceScreenOption.MPV -> MpvPreferences
} }
@ -132,7 +130,6 @@ fun PreferencesContent(
when (preferenceScreenOption) { when (preferenceScreenOption) {
PreferenceScreenOption.BASIC -> R.string.settings PreferenceScreenOption.BASIC -> R.string.settings
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
PreferenceScreenOption.MPV -> R.string.mpv_options PreferenceScreenOption.MPV -> R.string.mpv_options
} }
@ -535,12 +532,6 @@ fun PreferencesPage(
.align(Alignment.TopEnd), .align(Alignment.TopEnd),
) )
} }
PreferenceScreenOption.SUBTITLES -> {
SubtitleStylePage(
initialPreferences,
)
}
} }
} }
} }

View file

@ -77,7 +77,6 @@ fun SliderPreference(
max = preference.max, max = preference.max,
interval = preference.interval, interval = preference.interval,
onChange = onChange, onChange = onChange,
color = MaterialTheme.colorScheme.border,
enableWrapAround = false, enableWrapAround = false,
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),

View file

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

View file

@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.ui.preferences.subtitle
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Build
import android.view.Display
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb 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.R
import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppChoicePreference
import com.github.damontecres.wholphin.preferences.AppClickablePreference 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.AppSliderPreference
import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.preferences.BackgroundStyle import com.github.damontecres.wholphin.preferences.BackgroundStyle
import com.github.damontecres.wholphin.preferences.EdgeStyle import com.github.damontecres.wholphin.preferences.EdgeStyle
import com.github.damontecres.wholphin.preferences.SubtitlePreferences 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.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.util.mpv.MPVLib import com.github.damontecres.wholphin.util.mpv.MPVLib
import com.github.damontecres.wholphin.util.mpv.setPropertyColor import com.github.damontecres.wholphin.util.mpv.setPropertyColor
@ -28,18 +30,17 @@ import timber.log.Timber
object SubtitleSettings { object SubtitleSettings {
val FontSize = val FontSize =
AppSliderPreference<AppPreferences>( AppSliderPreference<SubtitlePreferences>(
title = R.string.font_size, title = R.string.font_size,
defaultValue = 24, defaultValue = 24,
min = 8, min = 8,
max = 70, max = 70,
interval = 2, interval = 2,
getter = { getter = {
it.interfacePreferences.subtitlesPreferences.fontSize it.fontSize.toLong()
.toLong()
}, },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { fontSize = value.toInt() } prefs.update { fontSize = value.toInt() }
}, },
summarizer = { value -> value?.toString() }, summarizer = { value -> value?.toString() },
) )
@ -59,12 +60,12 @@ object SubtitleSettings {
) )
val FontColor = val FontColor =
AppChoicePreference<AppPreferences, Color>( AppChoicePreference<SubtitlePreferences, Color>(
title = R.string.font_color, title = R.string.font_color,
defaultValue = Color.White, defaultValue = Color.White,
getter = { Color(it.interfacePreferences.subtitlesPreferences.fontColor) }, getter = { Color(it.fontColor) },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { fontColor = value.toArgb().and(0x00FFFFFF) } prefs.update { fontColor = value.toArgb().and(0x00FFFFFF) }
}, },
displayValues = R.array.font_colors, displayValues = R.array.font_colors,
indexToValue = { colorList.getOrNull(it) ?: Color.White }, indexToValue = { colorList.getOrNull(it) ?: Color.White },
@ -75,49 +76,49 @@ object SubtitleSettings {
) )
val FontBold = val FontBold =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<SubtitlePreferences>(
title = R.string.bold_font, title = R.string.bold_font,
defaultValue = false, defaultValue = false,
getter = { it.interfacePreferences.subtitlesPreferences.fontBold }, getter = { it.fontBold },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { fontBold = value } prefs.update { fontBold = value }
}, },
) )
val FontItalic = val FontItalic =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<SubtitlePreferences>(
title = R.string.italic_font, title = R.string.italic_font,
defaultValue = false, defaultValue = false,
getter = { it.interfacePreferences.subtitlesPreferences.fontItalic }, getter = { it.fontItalic },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { fontItalic = value } prefs.update { fontItalic = value }
}, },
) )
val FontOpacity = val FontOpacity =
AppSliderPreference<AppPreferences>( AppSliderPreference<SubtitlePreferences>(
title = R.string.font_opacity, title = R.string.font_opacity,
defaultValue = 100, defaultValue = 100,
min = 10, min = 10,
max = 100, max = 100,
interval = 10, interval = 10,
getter = { getter = {
it.interfacePreferences.subtitlesPreferences.fontOpacity it.fontOpacity
.toLong() .toLong()
}, },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { fontOpacity = value.toInt() } prefs.update { fontOpacity = value.toInt() }
}, },
summarizer = { value -> value?.let { "$it%" } }, summarizer = { value -> value?.let { "$it%" } },
) )
val EdgeStylePref = val EdgeStylePref =
AppChoicePreference<AppPreferences, EdgeStyle>( AppChoicePreference<SubtitlePreferences, EdgeStyle>(
title = title =
R.string.edge_style, R.string.edge_style,
defaultValue = EdgeStyle.EDGE_SOLID, defaultValue = EdgeStyle.EDGE_SOLID,
getter = { it.interfacePreferences.subtitlesPreferences.edgeStyle }, getter = { it.edgeStyle },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { edgeStyle = value } prefs.update { edgeStyle = value }
}, },
displayValues = R.array.subtitle_edge, displayValues = R.array.subtitle_edge,
indexToValue = { EdgeStyle.forNumber(it) }, indexToValue = { EdgeStyle.forNumber(it) },
@ -125,12 +126,12 @@ object SubtitleSettings {
) )
val EdgeColor = val EdgeColor =
AppChoicePreference<AppPreferences, Color>( AppChoicePreference<SubtitlePreferences, Color>(
title = R.string.edge_color, title = R.string.edge_color,
defaultValue = Color.Black, defaultValue = Color.Black,
getter = { Color(it.interfacePreferences.subtitlesPreferences.edgeColor) }, getter = { Color(it.edgeColor) },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { edgeColor = value.toArgb().and(0x00FFFFFF) } prefs.update { edgeColor = value.toArgb().and(0x00FFFFFF) }
}, },
displayValues = R.array.font_colors, displayValues = R.array.font_colors,
indexToValue = { colorList.getOrNull(it) ?: Color.White }, indexToValue = { colorList.getOrNull(it) ?: Color.White },
@ -141,29 +142,29 @@ object SubtitleSettings {
) )
val EdgeThickness = val EdgeThickness =
AppSliderPreference<AppPreferences>( AppSliderPreference<SubtitlePreferences>(
title = R.string.edge_size, title = R.string.edge_size,
defaultValue = 4, defaultValue = 4,
min = 1, min = 1,
max = 32, max = 32,
interval = 1, interval = 1,
getter = { getter = {
it.interfacePreferences.subtitlesPreferences.edgeThickness it.edgeThickness
.toLong() .toLong()
}, },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { edgeThickness = value.toInt() } prefs.update { edgeThickness = value.toInt() }
}, },
summarizer = { value -> value?.let { "${it / 2.0}" } }, summarizer = { value -> value?.let { "${it / 2.0}" } },
) )
val BackgroundColor = val BackgroundColor =
AppChoicePreference<AppPreferences, Color>( AppChoicePreference<SubtitlePreferences, Color>(
title = R.string.background_color, title = R.string.background_color,
defaultValue = Color.Transparent, defaultValue = Color.Transparent,
getter = { Color(it.interfacePreferences.subtitlesPreferences.backgroundColor) }, getter = { Color(it.backgroundColor) },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { backgroundColor = value.toArgb().and(0x00FFFFFF) } prefs.update { backgroundColor = value.toArgb().and(0x00FFFFFF) }
}, },
displayValues = R.array.font_colors, displayValues = R.array.font_colors,
indexToValue = { colorList.getOrNull(it) ?: Color.White }, indexToValue = { colorList.getOrNull(it) ?: Color.White },
@ -174,30 +175,30 @@ object SubtitleSettings {
) )
val BackgroundOpacity = val BackgroundOpacity =
AppSliderPreference<AppPreferences>( AppSliderPreference<SubtitlePreferences>(
title = R.string.background_opacity, title = R.string.background_opacity,
defaultValue = 50, defaultValue = 50,
min = 10, min = 10,
max = 100, max = 100,
interval = 10, interval = 10,
getter = { getter = {
it.interfacePreferences.subtitlesPreferences.backgroundOpacity it.backgroundOpacity
.toLong() .toLong()
}, },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { backgroundOpacity = value.toInt() } prefs.update { backgroundOpacity = value.toInt() }
}, },
summarizer = { value -> value?.let { "$it%" } }, summarizer = { value -> value?.let { "$it%" } },
) )
val BackgroundStylePref = val BackgroundStylePref =
AppChoicePreference<AppPreferences, BackgroundStyle>( AppChoicePreference<SubtitlePreferences, BackgroundStyle>(
title = title =
R.string.background_style, R.string.background_style,
defaultValue = BackgroundStyle.BG_NONE, defaultValue = BackgroundStyle.BG_NONE,
getter = { it.interfacePreferences.subtitlesPreferences.backgroundStyle }, getter = { it.backgroundStyle },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updateSubtitlePreferences { backgroundStyle = value } prefs.update { backgroundStyle = value }
}, },
displayValues = R.array.background_style, displayValues = R.array.background_style,
indexToValue = { BackgroundStyle.forNumber(it) }, indexToValue = { BackgroundStyle.forNumber(it) },
@ -205,29 +206,51 @@ object SubtitleSettings {
) )
val Margin = val Margin =
AppSliderPreference<AppPreferences>( AppSliderPreference<SubtitlePreferences>(
title = R.string.subtitle_margin, title = R.string.subtitle_margin,
defaultValue = 8, defaultValue = 8,
min = 0, min = 0,
max = 100, max = 100,
interval = 1, interval = 1,
getter = { getter = {
it.interfacePreferences.subtitlesPreferences.margin it.margin
.toLong() .toLong()
}, },
setter = { prefs, value -> 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%" } }, summarizer = { value -> value?.let { "$it%" } },
) )
val Reset = val Reset =
AppClickablePreference<AppPreferences>( AppClickablePreference<SubtitlePreferences>(
title = R.string.reset, title = R.string.reset,
getter = { }, getter = { },
setter = { prefs, _ -> prefs }, setter = { prefs, _ -> prefs },
) )
val HdrSettings =
AppDestinationPreference<SubtitlePreferences>(
title = R.string.hdr_subtitle_style,
destination = Destination.SubtitleSettings(true),
)
val preferences = val preferences =
listOf( listOf(
PreferenceGroup( PreferenceGroup(
@ -264,11 +287,25 @@ object SubtitleSettings {
preferences = preferences =
listOf( listOf(
Margin, Margin,
ImageOpacity,
Reset, 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( private fun combine(
color: Int, color: Int,
opacity: Int, opacity: Int,
@ -361,3 +398,5 @@ object SubtitleSettings {
MPVLib.setPropertyString("sub-border-style", borderStyle) MPVLib.setPropertyString("sub-border-style", borderStyle)
} }
} }
inline fun SubtitlePreferences.update(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = toBuilder().apply(block).build()

View file

@ -1,5 +1,7 @@
package com.github.damontecres.wholphin.ui.preferences.subtitle package com.github.damontecres.wholphin.ui.preferences.subtitle
import android.content.pm.ActivityInfo
import android.os.Build
import androidx.annotation.Dimension import androidx.annotation.Dimension
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.Image 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -19,9 +22,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -30,20 +37,25 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.preferences.SubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesContent 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.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize 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.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
import com.github.damontecres.wholphin.util.Media3SubtitleOverride import com.github.damontecres.wholphin.util.Media3SubtitleOverride
import timber.log.Timber
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
fun SubtitleStylePage( fun SubtitleStylePage(
initialPreferences: AppPreferences, initialPreferences: AppPreferences,
hdrSettings: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PreferencesViewModel = hiltViewModel(), viewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
val density = LocalDensity.current val density = LocalDensity.current
var preferences by remember { mutableStateOf(initialPreferences) } var preferences by remember { mutableStateOf(initialPreferences) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -51,8 +63,27 @@ fun SubtitleStylePage(
preferences = it 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 focusedOnMargin by remember { mutableStateOf(false) }
var focusedOnImageOpacity by remember { mutableStateOf(false) }
Row( Row(
modifier = modifier, modifier = modifier,
@ -72,7 +103,7 @@ fun SubtitleStylePage(
Modifier Modifier
.fillMaxSize(), .fillMaxSize(),
) )
if (!focusedOnMargin) { if (!focusedOnMargin && !focusedOnImageOpacity) {
Column( Column(
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
modifier = modifier =
@ -109,7 +140,7 @@ fun SubtitleStylePage(
) )
} }
} }
} else { } else if (focusedOnMargin) {
// Margin // Margin
AndroidView( AndroidView(
factory = { context -> factory = { context ->
@ -129,17 +160,79 @@ fun SubtitleStylePage(
Modifier Modifier
.fillMaxSize(), .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( val display = LocalView.current.display
initialPreferences = preferences, val prefList =
preferenceScreenOption = PreferenceScreenOption.SUBTITLES, 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 -> onFocus = { groupIndex, prefIndex ->
val focusedPref =
focusedOnMargin = SubtitleSettings.preferences
SubtitleSettings.preferences.getOrNull(groupIndex)?.preferences?.getOrNull( .getOrNull(groupIndex)
prefIndex, ?.preferences
) == SubtitleSettings.Margin ?.getOrNull(prefIndex)
focusedOnMargin = focusedPref == SubtitleSettings.Margin
focusedOnImageOpacity = focusedPref == SubtitleSettings.ImageOpacity
}, },
modifier = modifier =
Modifier Modifier

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -127,6 +127,7 @@ message SubtitlePreferences{
bool font_italic = 10; bool font_italic = 10;
int32 margin = 11; int32 margin = 11;
int32 edge_thickness = 12; int32 edge_thickness = 12;
int32 image_subtitle_opacity = 13;
} }
message LiveTvPreferences { message LiveTvPreferences {
@ -152,12 +153,18 @@ message InterfacePreferences {
SubtitlePreferences subtitles_preferences = 7; SubtitlePreferences subtitles_preferences = 7;
LiveTvPreferences live_tv_preferences = 8; LiveTvPreferences live_tv_preferences = 8;
BackdropStyle backdrop_style = 9; BackdropStyle backdrop_style = 9;
SubtitlePreferences hdr_subtitles_preferences = 10;
} }
message AdvancedPreferences { message AdvancedPreferences {
int64 image_disk_cache_size_bytes = 1; int64 image_disk_cache_size_bytes = 1;
} }
message PhotoPreferences{
int64 slideshow_duration = 1;
bool slideshow_play_videos = 2;
}
message AppPreferences { message AppPreferences {
// The currently signed in server and user IDs, mostly for restoring a session // The currently signed in server and user IDs, mostly for restoring a session
string current_server_id = 1; string current_server_id = 1;
@ -173,4 +180,5 @@ message AppPreferences {
bool debug_logging = 9; bool debug_logging = 9;
AdvancedPreferences advanced_preferences = 10; AdvancedPreferences advanced_preferences = 10;
bool sign_in_automatically = 11; bool sign_in_automatically = 11;
PhotoPreferences photo_preferences = 12;
} }

View file

@ -9,6 +9,7 @@
<string name="fa_images" translatable="false">&#xf302;</string> <string name="fa_images" translatable="false">&#xf302;</string>
<string name="fa_rotate_right" translatable="false">&#xf2f9;</string> <string name="fa_rotate_right" translatable="false">&#xf2f9;</string>
<string name="fa_rotate_left" translatable="false">&#xf2ea;</string> <string name="fa_rotate_left" translatable="false">&#xf2ea;</string>
<string name="fa_arrows_rotate" translatable="false">&#xf021;</string>
<string name="fa_arrow_right_arrow_left" translatable="false">&#xf0ec;</string> <string name="fa_arrow_right_arrow_left" translatable="false">&#xf0ec;</string>
<string name="fa_magnifying_glass_plus" translatable="false">&#xf00e;</string> <string name="fa_magnifying_glass_plus" translatable="false">&#xf00e;</string>
<string name="fa_magnifying_glass_minus" translatable="false">&#xf010;</string> <string name="fa_magnifying_glass_minus" translatable="false">&#xf010;</string>

View file

@ -25,7 +25,7 @@
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
<string name="continue_watching">Continue watching</string> <string name="continue_watching">Continue watching</string>
<string name="critic_rating">Critic Rating</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="default_track">Default</string>
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="died">Died</string> <string name="died">Died</string>
@ -462,6 +462,29 @@
<string name="request_4k">Request in 4K</string> <string name="request_4k">Request in 4K</string>
<string name="software_decoding_av1">AV1 software decoding</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="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="add_row">Add row</string>
<string name="genres_in">Genres in %1$s</string> <string name="genres_in">Genres in %1$s</string>
<string name="recently_released_in">Recently released in %1$s</string> <string name="recently_released_in">Recently released in %1$s</string>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,6 +41,8 @@ preferenceKtx = "1.2.1"
tvprovider = "1.1.0" tvprovider = "1.1.0"
workRuntimeKtx = "2.11.1" workRuntimeKtx = "2.11.1"
paletteKtx = "1.0.0" paletteKtx = "1.0.0"
kotlinxCoroutinesTest = "1.7.3"
coreTesting = "2.2.0"
openapi-generator = "7.19.0" openapi-generator = "7.19.0"
[libraries] [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-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } 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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } 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-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 = { 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-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-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", 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-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }