diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b3f74ef..5a1dbd68 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -223,6 +223,7 @@ dependencies { implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.material) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) implementation(libs.protobuf.kotlin.lite) @@ -265,6 +266,7 @@ dependencies { implementation(libs.androidx.preference.ktx) implementation(libs.androidx.room.testing) implementation(libs.androidx.palette.ktx) + implementation(libs.androidx.media3.effect) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) ksp(libs.androidx.hilt.compiler) @@ -298,5 +300,7 @@ dependencies { testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) } diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json new file mode 100644 index 00000000..715fb618 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json @@ -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')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index af88392e..dcc25af0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore @@ -51,12 +52,14 @@ import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.SuggestionsSchedulerService import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent @@ -70,6 +73,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import okhttp3.OkHttpClient @@ -116,6 +120,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var tvProviderSchedulerService: TvProviderSchedulerService + @Inject + lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + // Note: unused but injected to ensure it is created @Inject lateinit var serverEventListener: ServerEventListener @@ -133,7 +140,9 @@ class MainActivity : AppCompatActivity() { Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) if (savedInstanceState == null) { - appUpgradeHandler.copySubfont(false) + lifecycleScope.launchIO { + appUpgradeHandler.copySubfont(false) + } } viewModel.serverRepository.currentUser.observe(this) { user -> if (user?.hasPin == true) { @@ -148,6 +157,25 @@ class MainActivity : AppCompatActivity() { viewModel.appStart() setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) + if (appPreferences == null) { + // Show loading page if it is taking a while to get app preferences + var showLoading by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(500) + Timber.i("Showing loading page") + showLoading = true + } + if (showLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + LoadingPage() + } + } + } appPreferences?.let { appPreferences -> LaunchedEffect(appPreferences.signInAutomatically) { signInAuto = appPreferences.signInAutomatically diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 19e33616..a3629843 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser @@ -32,12 +33,14 @@ import java.util.UUID ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class, + PlaybackEffect::class, PlaybackLanguageChoice::class, ItemTrackModification::class, SeerrServer::class, SeerrUser::class, + ], - version = 20, + version = 30, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -50,6 +53,7 @@ import java.util.UUID AutoMigration(10, 11), AutoMigration(11, 12), AutoMigration(12, 20), + AutoMigration(20, 30), ], ) @TypeConverters(Converters::class) @@ -65,6 +69,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao abstract fun seerrServerDao(): SeerrServerDao + + abstract fun playbackEffectDao(): PlaybackEffectDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt new file mode 100644 index 00000000..0fa9f0ee --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt @@ -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) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index bc59cf27..5703b682 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -107,6 +107,12 @@ data class BaseItem( data.premiereDate?.let { add(DateFormatter.format(it)) } } else if (type == BaseItemKind.SERIES) { data.seriesProductionYears?.let(::add) + } else if (type == BaseItemKind.PHOTO) { + if (data.productionYear != null) { + add(data.productionYear!!.toString()) + } else if (data.premiereDate != null) { + add(data.premiereDate!!.toLocalDate().toString()) + } } else { data.productionYear?.let { add(it.toString()) } } @@ -155,7 +161,7 @@ data class BaseItem( it.dayOfMonth.toString().padStart(2, '0') }?.toIntOrNull() - fun destination(): Destination { + fun destination(index: Int? = null): Destination { val result = // Redirect episodes & seasons to their series if possible when (type) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt new file mode 100644 index 00000000..3c5cf6a0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt @@ -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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt new file mode 100644 index 00000000..e71db95c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt @@ -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 = + 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 + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 28903af3..2b6b7064 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -716,7 +716,7 @@ sealed interface AppPreference { val SubtitleStyle = AppDestinationPreference( title = R.string.subtitle_style, - destination = Destination.Settings(PreferenceScreenOption.SUBTITLES), + destination = Destination.SubtitleSettings(false), ) val RefreshRateSwitching = @@ -909,6 +909,46 @@ sealed interface AppPreference { getter = { }, setter = { prefs, _ -> prefs }, ) + + val SlideshowDuration = + AppSliderPreference( + 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( + title = R.string.play_videos_during_slideshow, + defaultValue = false, + getter = { it.photoPreferences.slideshowPlayVideos }, + setter = { prefs, value -> + prefs.updatePhotoPreferences { slideshowPlayVideos = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) } } @@ -1022,6 +1062,8 @@ val advancedPreferences = // AppPreference.NavDrawerSwitchOnFocus, AppPreference.ControllerTimeout, AppPreference.BackdropStylePref, + AppPreference.SlideshowDuration, + AppPreference.SlideshowPlayVideos, ), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index f84744c7..e2182aa3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -100,6 +100,12 @@ class AppPreferencesSerializer .apply { resetSubtitles() }.build() + hdrSubtitlesPreferences = + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + }.build() liveTvPreferences = LiveTvPreferences @@ -122,6 +128,14 @@ class AppPreferencesSerializer imageDiskCacheSizeBytes = AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT }.build() + + photoPreferences = + PhotoPreferences + .newBuilder() + .apply { + slideshowDuration = AppPreference.SlideshowDuration.defaultValue + slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue + }.build() }.build() override suspend fun readFrom(input: InputStream): AppPreferences { @@ -180,6 +194,11 @@ inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.B advancedPreferences = advancedPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder.() -> Unit): AppPreferences = + update { + photoPreferences = photoPreferences.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() @@ -193,4 +212,5 @@ fun SubtitlePreferences.Builder.resetSubtitles() { backgroundStyle = SubtitleSettings.BackgroundStylePref.defaultValue margin = SubtitleSettings.Margin.defaultValue.toInt() edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() + imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index d6fbea40..96130f48 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions +import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel @@ -213,4 +214,27 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + subtitlesPreferences = + subtitlesPreferences + .toBuilder() + .apply { + imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() + }.build() + // Copy current subtitle prefs as HDR ones + hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) { + appPreferences.updateData { + it.updatePhotoPreferences { + slideshowDuration = AppPreference.SlideshowDuration.defaultValue + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 3c32a497..cc9f5779 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.github.damontecres.wholphin.ui.nav.Destination import dagger.hilt.android.scopes.ActivityRetainedScoped +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -16,12 +19,16 @@ class PlaybackLifecycleObserver private val navigationManager: NavigationManager, private val playerFactory: PlayerFactory, private val themeSongPlayer: ThemeSongPlayer, + private val suggestionsCache: SuggestionsCache, ) : DefaultLifecycleObserver { private var wasPlaying: Boolean? = null override fun onStart(owner: LifecycleOwner) { val lastDest = navigationManager.backStack.lastOrNull() - if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) { + if (lastDest is Destination.Playback || + lastDest is Destination.PlaybackList || + lastDest is Destination.Slideshow + ) { navigationManager.goBack() } wasPlaying = null @@ -45,5 +52,6 @@ class PlaybackLifecycleObserver override fun onStop(owner: LifecycleOwner) { themeSongPlayer.stop() + CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index d961d4c5..bc0c8f94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -29,7 +29,7 @@ class RefreshRateService @param:ApplicationContext private val context: Context, ) { private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + private val display get() = displayManager.getDisplay(Display.DEFAULT_DISPLAY) val supportedDisplayModes get() = display.supportedModes.orEmpty() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt new file mode 100644 index 00000000..a38c713d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -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, + ) : 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 { + 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, + itemKind: BaseItemKind, + ): List { + 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) } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt new file mode 100644 index 00000000..f131b13b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -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, +) + +@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 = _cacheVersion.asStateFlow() + + private val memoryCache: MutableMap = + LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true) + + @Volatile + private var diskCacheLoadedUserId: UUID? = null + private val dirtyKeys: MutableSet = 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? { + 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(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(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, + ) { + 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 + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt new file mode 100644 index 00000000..1b2132fc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -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() + .setConstraints(constraints) + .setInputData(inputData) + .build(), + ) + } + + Timber.i("Scheduling periodic SuggestionsWorker") + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = SuggestionsWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = + PeriodicWorkRequestBuilder( + repeatInterval = 12.hours.toJavaDuration(), + ).setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15.minutes.toJavaDuration(), + ).setInputData(inputData) + .build(), + ) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt new file mode 100644 index 00000000..70943f83 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -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, + 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 = + 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? = null, + extraFields: List = emptyList(), + ): List { + 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 + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index e6ae832a..f207562c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services.hilt import android.content.Context import androidx.datastore.core.DataStore +import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository @@ -176,6 +177,12 @@ object AppModule { @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Provides + @Singleton + fun workManager( + @ApplicationContext context: Context, + ): WorkManager = WorkManager.getInstance(context) + @Provides @Singleton fun seerrApi( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index ed3f761b..31c92774 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerPreferencesDao @@ -66,6 +67,10 @@ object DatabaseModule { @Singleton fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + @Provides + @Singleton + fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt index 05ce1d2a..ab0e04bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -29,9 +29,9 @@ class TvProviderSchedulerService constructor( @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, + private val workManager: WorkManager, ) { private val activity = (context as AppCompatActivity) - private val workManager = WorkManager.getInstance(context) private val supportsTvProvider = // TODO <=25 has limited support diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index ce1a7352..0db41202 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -57,6 +57,7 @@ val DefaultItemFields = ItemFields.CHAPTERS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, + ItemFields.PARENT_ID, ) /** @@ -70,8 +71,16 @@ val SlimItemFields = ItemFields.OVERVIEW, ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, + ItemFields.PARENT_ID, ) +val PhotoItemFields = + DefaultItemFields + + listOf( + ItemFields.WIDTH, + ItemFields.HEIGHT, + ) + object Cards { const val HEIGHT_2X3_DP = 172 val height2x3 = HEIGHT_2X3_DP.dp diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 29991126..89cda77e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -577,21 +577,45 @@ fun CollectionFolderGrid( onSaveViewOptions = { viewModel.saveViewOptions(it) }, onChangeBackdrop = viewModel::updateBackdrop, playEnabled = playEnabled, - onClickPlay = { _, item -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) + onClickPlay = { index, item -> + val destination = + if (item.type == BaseItemKind.PHOTO_ALBUM) { + Destination.Slideshow( + parentId = item.id, + index = index, + filter = CollectionFolderFilter(filter = filter), + sortAndDirection = sortAndDirection, + recursive = true, + startSlideshow = true, + ) + } else { + Destination.Playback(item) + } + viewModel.navigationManager.navigateTo(destination) }, onClickPlayAll = { shuffle -> itemId.toUUIDOrNull()?.let { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it, - startIndex = 0, - shuffle = shuffle, - recursive = recursive, - sortAndDirection = sortAndDirection, - filter = filter, - ), - ) + val destination = + if (item?.type == BaseItemKind.PHOTO_ALBUM) { + Destination.Slideshow( + parentId = it, + index = 0, + filter = CollectionFolderFilter(filter = filter), + sortAndDirection = sortAndDirection, + recursive = true, + startSlideshow = true, + ) + } else { + Destination.PlaybackList( + itemId = it, + startIndex = 0, + shuffle = shuffle, + recursive = recursive, + sortAndDirection = sortAndDirection, + filter = filter, + ) + } + viewModel.navigationManager.navigateTo(destination) } }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 2856f319..17b84dee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -34,7 +34,9 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -180,6 +182,63 @@ fun ExpandablePlayButton( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, mirrorIcon: Boolean = false, enabled: Boolean = true, +) = ExpandablePlayButton( + title = title, + resume = resume, + icon = { + Icon( + imageVector = icon, + contentDescription = null, + modifier = + Modifier + .size(28.dp) + .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), + ) + }, + onClick = onClick, + modifier = modifier, + interactionSource = interactionSource, + enabled = enabled, +) + +@Composable +fun ExpandablePlayButton( + @StringRes title: Int, + resume: Duration, + icon: Painter, + onClick: (position: Duration) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + mirrorIcon: Boolean = false, + enabled: Boolean = true, +) = ExpandablePlayButton( + title = title, + resume = resume, + icon = { + Icon( + painter = icon, + contentDescription = null, + modifier = + Modifier + .size(28.dp) + .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), + ) + }, + onClick = onClick, + modifier = modifier, + interactionSource = interactionSource, + enabled = enabled, +) + +@Composable +fun ExpandablePlayButton( + @StringRes title: Int, + resume: Duration, + icon: @Composable () -> Unit, + onClick: (position: Duration) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + enabled: Boolean = true, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( @@ -200,14 +259,7 @@ fun ExpandablePlayButton( .padding(start = 2.dp, top = 2.dp) .height(MinButtonSize), ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = - Modifier - .size(28.dp) - .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), - ) + icon.invoke() } AnimatedVisibility(isFocused) { Spacer(Modifier.size(8.dp)) @@ -343,6 +395,13 @@ private fun ViewOptionsPreview() { onClick = {}, interactionSource = source, ) + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = painterResource(R.drawable.baseline_pause_24), + onClick = {}, + interactionSource = source, + ) ExpandableFaButton( title = R.string.play, iconStringRes = R.string.fa_eye, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 1272faf5..866efbf3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SuggestionService +import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.setValueOnMain @@ -22,7 +24,6 @@ import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler -import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted @@ -42,7 +43,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID @@ -54,6 +54,7 @@ class RecommendedMovieViewModel private val api: ApiClient, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, + private val suggestionService: SuggestionService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -115,7 +116,7 @@ class RecommendedMovieViewModel } update(R.string.recently_released) { - val recentlyReleasedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -128,13 +129,11 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, recentlyReleasedRequest) - .toBaseItems(api, false) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) } update(R.string.recently_added) { - val recentlyAddedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -147,27 +146,11 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, recentlyAddedRequest) - .toBaseItems(api, false) - } - - update(R.string.suggestions) { - val suggestionsRequest = - GetSuggestionsRequest( - userId = serverRepository.currentUser.value?.id, - type = listOf(BaseItemKind.MOVIE), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetSuggestionsRequestHandler - .execute(api, suggestionsRequest) - .toBaseItems(api, false) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) } update(R.string.top_unwatched) { - val unwatchedTopRatedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -181,9 +164,48 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, unwatchedTopRatedRequest) - .toBaseItems(api, false) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + } + + viewModelScope.launch(Dispatchers.IO) { + try { + suggestionService + .getSuggestionsFlow(parentId, BaseItemKind.MOVIE) + .collect { resource -> + val state = + when (resource) { + is SuggestionsResource.Loading -> { + HomeRowLoadingState.Loading( + context.getString(R.string.suggestions), + ) + } + + is SuggestionsResource.Success -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + resource.items, + ) + } + + is SuggestionsResource.Empty -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + emptyList(), + ) + } + } + update(R.string.suggestions, state) + } + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch suggestions") + update( + R.string.suggestions, + HomeRowLoadingState.Error( + title = context.getString(R.string.suggestions), + exception = ex, + ), + ) + } } if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index fa4071b6..2decd21a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SuggestionService +import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.setValueOnMain @@ -23,7 +25,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler -import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted @@ -45,7 +46,6 @@ import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID @@ -58,6 +58,7 @@ class RecommendedTvShowViewModel private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, private val lastestNextUpService: LatestNextUpService, + private val suggestionService: SuggestionService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -86,7 +87,7 @@ class RecommendedTvShowViewModel val userId = serverRepository.currentUser.value?.id try { val resumeItemsDeferred = - viewModelScope.async(Dispatchers.IO) { + async(Dispatchers.IO) { val resumeItemsRequest = GetResumeItemsRequest( userId = userId, @@ -104,7 +105,7 @@ class RecommendedTvShowViewModel } val nextUpItemsDeferred = - viewModelScope.async(Dispatchers.IO) { + async(Dispatchers.IO) { val nextUpRequest = GetNextUpRequest( userId = userId, @@ -116,19 +117,16 @@ class RecommendedTvShowViewModel enableUserData = true, enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp, ) - GetNextUpRequestHandler .execute(api, nextUpRequest) .toBaseItems(api, true) } + val resumeItems = resumeItemsDeferred.await() val nextUpItems = nextUpItemsDeferred.await() + if (combineNextUp) { - val combined = - lastestNextUpService.buildCombined( - resumeItems, - nextUpItems, - ) + val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems) update( R.string.continue_watching, HomeRowLoadingState.Success( @@ -138,10 +136,7 @@ class RecommendedTvShowViewModel ) update( R.string.next_up, - HomeRowLoadingState.Success( - context.getString(R.string.next_up), - listOf(), - ), + HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()), ) } else { update( @@ -153,10 +148,7 @@ class RecommendedTvShowViewModel ) update( R.string.next_up, - HomeRowLoadingState.Success( - context.getString(R.string.next_up), - nextUpItems, - ), + HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems), ) } @@ -171,7 +163,7 @@ class RecommendedTvShowViewModel } update(R.string.recently_released) { - val recentlyReleasedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -184,14 +176,11 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - - GetItemsRequestHandler - .execute(api, recentlyReleasedRequest) - .toBaseItems(api, true) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) } update(R.string.recently_added) { - val recentlyAddedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -204,29 +193,11 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - - GetItemsRequestHandler - .execute(api, recentlyAddedRequest) - .toBaseItems(api, true) - } - - update(R.string.suggestions) { - val suggestionsRequest = - GetSuggestionsRequest( - userId = serverRepository.currentUser.value?.id, - type = listOf(BaseItemKind.SERIES), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - - GetSuggestionsRequestHandler - .execute(api, suggestionsRequest) - .toBaseItems(api, true) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) } update(R.string.top_unwatched) { - val unwatchedTopRatedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -240,9 +211,48 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, unwatchedTopRatedRequest) - .toBaseItems(api, true) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) + } + + viewModelScope.launch(Dispatchers.IO) { + try { + suggestionService + .getSuggestionsFlow(parentId, BaseItemKind.SERIES) + .collect { resource -> + val state = + when (resource) { + is SuggestionsResource.Loading -> { + HomeRowLoadingState.Loading( + context.getString(R.string.suggestions), + ) + } + + is SuggestionsResource.Success -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + resource.items, + ) + } + + is SuggestionsResource.Empty -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + emptyList(), + ) + } + } + update(R.string.suggestions, state) + } + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch suggestions") + update( + R.string.suggestions, + HomeRowLoadingState.Error( + title = context.getString(R.string.suggestions), + exception = ex, + ), + ) + } } if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt index d9d69f6a..64cfc78b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt @@ -40,7 +40,7 @@ fun SliderBar( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interval: Int = 1, - color: Color = MaterialTheme.colorScheme.border, + colors: SliderColors = SliderColors.default(), ) { val isFocused by interactionSource.collectIsFocusedAsState() val animatedIndicatorHeight by animateDpAsState( @@ -49,9 +49,6 @@ fun SliderBar( var currentValue by remember(value) { mutableLongStateOf(value) } val percent = (currentValue - min).toFloat() / (max - min) - val activeColor = SliderActiveColor(isFocused) - val inactiveColor = SliderInactiveColor(isFocused) - val handleSeekEventModifier = Modifier.handleDPadKeyEvents( triggerOnAction = KeyEvent.ACTION_DOWN, @@ -91,14 +88,14 @@ fun SliderBar( onDraw = { val yOffset = size.height.div(2) drawLine( - color = inactiveColor, + color = if (isFocused) colors.inactiveFocused else colors.inactiveUnfocused, start = Offset(x = 0f, y = yOffset), end = Offset(x = size.width, y = yOffset), strokeWidth = size.height, cap = StrokeCap.Round, ) drawLine( - color = activeColor, + color = if (isFocused) colors.activeFocused else colors.activeUnfocused, start = Offset(x = 0f, y = yOffset), end = Offset( @@ -119,6 +116,24 @@ fun SliderBar( } } +data class SliderColors( + val activeFocused: Color, + val activeUnfocused: Color, + val inactiveFocused: Color, + val inactiveUnfocused: Color, +) { + companion object { + @Composable + fun default() = + SliderColors( + activeFocused = SliderActiveColor(true), + activeUnfocused = SliderActiveColor(false), + inactiveFocused = SliderInactiveColor(true), + inactiveUnfocused = SliderInactiveColor(false), + ) + } +} + @Composable fun SliderActiveColor(focused: Boolean): Color { val theme = LocalTheme.current diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index 6db471e3..800c84c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -45,7 +45,9 @@ fun CollectionFolderGeneric( } CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, + onClickItem = { index, item -> + preferencesViewModel.navigationManager.navigateTo(item.destination(index)) + }, itemId = itemId, initialFilter = filter, showTitle = showHeader, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt new file mode 100644 index 00000000..c36da909 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -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> = DefaultFilterOptions, + sortOptions: List = 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( + 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, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index d00416cf..59d102c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -41,6 +41,11 @@ sealed class Destination( val screen: PreferenceScreenOption, ) : Destination(true) + @Serializable + data class SubtitleSettings( + val hdr: Boolean, + ) : Destination(true) + @Serializable data object Search : Destination() @@ -103,6 +108,16 @@ sealed class Destination( val itemIds: List, ) : Destination(false) + @Serializable + data class Slideshow( + val parentId: UUID, + val index: Int, + val filter: CollectionFolderFilter, + val sortAndDirection: SortAndDirection, + val recursive: Boolean, + val startSlideshow: Boolean, + ) : Destination(true) + @Serializable data object Favorites : Destination(false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index e3edbb6b..a16e909a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie +import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv @@ -35,7 +36,9 @@ import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage +import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage +import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import timber.log.Timber @@ -83,6 +86,14 @@ fun DestinationContent( ) } + is Destination.SubtitleSettings -> { + SubtitleStylePage( + preferences.appPreferences, + destination.hdr, + modifier, + ) + } + is Destination.SeriesOverview -> { SeriesOverview( preferences = preferences, @@ -191,6 +202,16 @@ fun DestinationContent( ) } + BaseItemKind.PHOTO_ALBUM -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + CollectionFolderPhotoAlbum( + preferences = preferences, + itemId = destination.itemId, + recursive = true, + modifier = modifier, + ) + } + else -> { Timber.w("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}") @@ -230,6 +251,12 @@ fun DestinationContent( ) } + is Destination.Slideshow -> { + SlideshowPage( + slideshow = destination, + ) + } + Destination.Favorites -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } FavoritesPage( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index fde12a22..f8b80020 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -70,6 +71,7 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize @@ -85,6 +87,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -176,6 +179,7 @@ fun PlaybackPageContent( LaunchedEffect(player) { if (playerBackend == PlayerBackend.MPV) { scope.launch(Dispatchers.IO + ExceptionHandler()) { + // MPV can't play HDR, so always use regular settings preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( configuration, density, @@ -401,13 +405,26 @@ fun PlaybackPageContent( ) } + val subtitleSettings = + remember(mediaInfo) { + Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}") + if (mediaInfo?.videoStream?.hdr == true) { + preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences + } else { + preferences.appPreferences.interfacePreferences.subtitlesPreferences + } + } + val subtitleImageOpacity = + remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } + // Subtitles if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) + val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } AndroidView( factory = { context -> SubtitleView(context).apply { - preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { + subtitleSettings.let { setStyle(it.toSubtitleStyle()) setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setBottomPaddingFraction(it.margin.toFloat() / 100f) @@ -416,10 +433,8 @@ fun PlaybackPageContent( }, update = { it.setCues(cues) - Media3SubtitleOverride( - preferences.appPreferences.interfacePreferences.subtitlesPreferences - .calculateEdgeSize(density), - ).apply(it) + Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) + .apply(it) }, onReset = { it.setCues(null) @@ -428,7 +443,8 @@ fun PlaybackPageContent( Modifier .fillMaxSize(maxSize) .align(Alignment.TopCenter) - .background(Color.Transparent), + .background(Color.Transparent) + .ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index 091dd475..c9f6f529 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.annotation.StringRes import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences import kotlinx.serialization.Serializable /** @@ -11,12 +10,12 @@ import kotlinx.serialization.Serializable data class PreferenceGroup( @param:StringRes val title: Int, val preferences: List>, - val conditionalPreferences: List = listOf(), + val conditionalPreferences: List> = listOf(), ) -data class ConditionalPreferences( - val condition: (AppPreferences) -> Boolean, - val preferences: List>, +data class ConditionalPreferences( + val condition: (T) -> Boolean, + val preferences: List>, ) /** @@ -34,7 +33,6 @@ sealed interface PreferenceValidation { enum class PreferenceScreenOption { BASIC, ADVANCED, - SUBTITLES, EXO_PLAYER, MPV, ; diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 63c5a9b1..dcbda2fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -59,7 +59,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings -import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.setup.UpdateViewModel import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel @@ -124,7 +123,6 @@ fun PreferencesContent( when (preferenceScreenOption) { PreferenceScreenOption.BASIC -> basicPreferences PreferenceScreenOption.ADVANCED -> advancedPreferences - PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.MPV -> MpvPreferences } @@ -132,7 +130,6 @@ fun PreferencesContent( when (preferenceScreenOption) { PreferenceScreenOption.BASIC -> R.string.settings PreferenceScreenOption.ADVANCED -> R.string.advanced_settings - PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options PreferenceScreenOption.MPV -> R.string.mpv_options } @@ -535,12 +532,6 @@ fun PreferencesPage( .align(Alignment.TopEnd), ) } - - PreferenceScreenOption.SUBTITLES -> { - SubtitleStylePage( - initialPreferences, - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt index 56c15f7f..e5564ee2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt @@ -77,7 +77,6 @@ fun SliderPreference( max = preference.max, interval = preference.interval, onChange = onChange, - color = MaterialTheme.colorScheme.border, enableWrapAround = false, interactionSource = interactionSource, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt new file mode 100644 index 00000000..8abc6dbe --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt @@ -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>, + 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 + 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), + ), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt index 506649d5..b7f3d764 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt @@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.ui.preferences.subtitle import android.content.res.Configuration import android.graphics.Typeface +import android.os.Build +import android.view.Display import androidx.annotation.OptIn import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -13,14 +15,14 @@ import androidx.media3.ui.CaptionStyleCompat import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference -import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppDestinationPreference import com.github.damontecres.wholphin.preferences.AppSliderPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.BackgroundStyle import com.github.damontecres.wholphin.preferences.EdgeStyle import com.github.damontecres.wholphin.preferences.SubtitlePreferences -import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.util.mpv.MPVLib import com.github.damontecres.wholphin.util.mpv.setPropertyColor @@ -28,18 +30,17 @@ import timber.log.Timber object SubtitleSettings { val FontSize = - AppSliderPreference( + AppSliderPreference( title = R.string.font_size, defaultValue = 24, min = 8, max = 70, interval = 2, getter = { - it.interfacePreferences.subtitlesPreferences.fontSize - .toLong() + it.fontSize.toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontSize = value.toInt() } + prefs.update { fontSize = value.toInt() } }, summarizer = { value -> value?.toString() }, ) @@ -59,12 +60,12 @@ object SubtitleSettings { ) val FontColor = - AppChoicePreference( + AppChoicePreference( title = R.string.font_color, defaultValue = Color.White, - getter = { Color(it.interfacePreferences.subtitlesPreferences.fontColor) }, + getter = { Color(it.fontColor) }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontColor = value.toArgb().and(0x00FFFFFF) } + prefs.update { fontColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -75,49 +76,49 @@ object SubtitleSettings { ) val FontBold = - AppSwitchPreference( + AppSwitchPreference( title = R.string.bold_font, defaultValue = false, - getter = { it.interfacePreferences.subtitlesPreferences.fontBold }, + getter = { it.fontBold }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontBold = value } + prefs.update { fontBold = value } }, ) val FontItalic = - AppSwitchPreference( + AppSwitchPreference( title = R.string.italic_font, defaultValue = false, - getter = { it.interfacePreferences.subtitlesPreferences.fontItalic }, + getter = { it.fontItalic }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontItalic = value } + prefs.update { fontItalic = value } }, ) val FontOpacity = - AppSliderPreference( + AppSliderPreference( title = R.string.font_opacity, defaultValue = 100, min = 10, max = 100, interval = 10, getter = { - it.interfacePreferences.subtitlesPreferences.fontOpacity + it.fontOpacity .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontOpacity = value.toInt() } + prefs.update { fontOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val EdgeStylePref = - AppChoicePreference( + AppChoicePreference( title = R.string.edge_style, defaultValue = EdgeStyle.EDGE_SOLID, - getter = { it.interfacePreferences.subtitlesPreferences.edgeStyle }, + getter = { it.edgeStyle }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { edgeStyle = value } + prefs.update { edgeStyle = value } }, displayValues = R.array.subtitle_edge, indexToValue = { EdgeStyle.forNumber(it) }, @@ -125,12 +126,12 @@ object SubtitleSettings { ) val EdgeColor = - AppChoicePreference( + AppChoicePreference( title = R.string.edge_color, defaultValue = Color.Black, - getter = { Color(it.interfacePreferences.subtitlesPreferences.edgeColor) }, + getter = { Color(it.edgeColor) }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { edgeColor = value.toArgb().and(0x00FFFFFF) } + prefs.update { edgeColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -141,29 +142,29 @@ object SubtitleSettings { ) val EdgeThickness = - AppSliderPreference( + AppSliderPreference( title = R.string.edge_size, defaultValue = 4, min = 1, max = 32, interval = 1, getter = { - it.interfacePreferences.subtitlesPreferences.edgeThickness + it.edgeThickness .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { edgeThickness = value.toInt() } + prefs.update { edgeThickness = value.toInt() } }, summarizer = { value -> value?.let { "${it / 2.0}" } }, ) val BackgroundColor = - AppChoicePreference( + AppChoicePreference( title = R.string.background_color, defaultValue = Color.Transparent, - getter = { Color(it.interfacePreferences.subtitlesPreferences.backgroundColor) }, + getter = { Color(it.backgroundColor) }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { backgroundColor = value.toArgb().and(0x00FFFFFF) } + prefs.update { backgroundColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -174,30 +175,30 @@ object SubtitleSettings { ) val BackgroundOpacity = - AppSliderPreference( + AppSliderPreference( title = R.string.background_opacity, defaultValue = 50, min = 10, max = 100, interval = 10, getter = { - it.interfacePreferences.subtitlesPreferences.backgroundOpacity + it.backgroundOpacity .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { backgroundOpacity = value.toInt() } + prefs.update { backgroundOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val BackgroundStylePref = - AppChoicePreference( + AppChoicePreference( title = R.string.background_style, defaultValue = BackgroundStyle.BG_NONE, - getter = { it.interfacePreferences.subtitlesPreferences.backgroundStyle }, + getter = { it.backgroundStyle }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { backgroundStyle = value } + prefs.update { backgroundStyle = value } }, displayValues = R.array.background_style, indexToValue = { BackgroundStyle.forNumber(it) }, @@ -205,29 +206,51 @@ object SubtitleSettings { ) val Margin = - AppSliderPreference( + AppSliderPreference( title = R.string.subtitle_margin, defaultValue = 8, min = 0, max = 100, interval = 1, getter = { - it.interfacePreferences.subtitlesPreferences.margin + it.margin .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { margin = value.toInt() } + prefs.update { margin = value.toInt() } + }, + summarizer = { value -> value?.let { "$it%" } }, + ) + + val ImageOpacity = + AppSliderPreference( + title = R.string.image_subtitle_opacity, + defaultValue = 100, + min = 10, + max = 100, + interval = 5, + getter = { + it.imageSubtitleOpacity.toLong() + }, + setter = { prefs, value -> + prefs.update { imageSubtitleOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val Reset = - AppClickablePreference( + AppClickablePreference( title = R.string.reset, getter = { }, setter = { prefs, _ -> prefs }, ) + val HdrSettings = + AppDestinationPreference( + title = R.string.hdr_subtitle_style, + destination = Destination.SubtitleSettings(true), + ) + val preferences = listOf( PreferenceGroup( @@ -264,11 +287,25 @@ object SubtitleSettings { preferences = listOf( Margin, + ImageOpacity, Reset, ), ), ) + val hdrPreferenceGroup = + listOf( + PreferenceGroup( + title = R.string.hdr, + preferences = + listOf( + HdrSettings, + ), + ), + ) + + fun shouldShowHdr(display: Display): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && display.isHdr + private fun combine( color: Int, opacity: Int, @@ -361,3 +398,5 @@ object SubtitleSettings { MPVLib.setPropertyString("sub-border-style", borderStyle) } } + +inline fun SubtitlePreferences.update(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = toBuilder().apply(block).build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt index b0bc6426..52b3570a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin.ui.preferences.subtitle +import android.content.pm.ActivityInfo +import android.os.Build import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.foundation.Image @@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,9 +22,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -30,20 +37,25 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption -import com.github.damontecres.wholphin.ui.preferences.PreferencesContent +import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles +import com.github.damontecres.wholphin.preferences.updateInterfacePreferences +import com.github.damontecres.wholphin.ui.findActivity import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle import com.github.damontecres.wholphin.util.Media3SubtitleOverride +import timber.log.Timber @OptIn(UnstableApi::class) @Composable fun SubtitleStylePage( initialPreferences: AppPreferences, + hdrSettings: Boolean, modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), ) { + val context = LocalContext.current val density = LocalDensity.current var preferences by remember { mutableStateOf(initialPreferences) } LaunchedEffect(Unit) { @@ -51,8 +63,27 @@ fun SubtitleStylePage( preferences = it } } - val prefs = preferences.interfacePreferences.subtitlesPreferences + val display = LocalView.current.display + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + DisposableEffect(context) { + if (hdrSettings) { + Timber.v("Switching color mode to HDR") + context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_HDR + } + onDispose { + context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT + } + } + } + val prefs = + if (hdrSettings) { + preferences.interfacePreferences.hdrSubtitlesPreferences + } else { + preferences.interfacePreferences.subtitlesPreferences + } var focusedOnMargin by remember { mutableStateOf(false) } + var focusedOnImageOpacity by remember { mutableStateOf(false) } Row( modifier = modifier, @@ -72,7 +103,7 @@ fun SubtitleStylePage( Modifier .fillMaxSize(), ) - if (!focusedOnMargin) { + if (!focusedOnMargin && !focusedOnImageOpacity) { Column( verticalArrangement = Arrangement.SpaceBetween, modifier = @@ -109,7 +140,7 @@ fun SubtitleStylePage( ) } } - } else { + } else if (focusedOnMargin) { // Margin AndroidView( factory = { context -> @@ -129,17 +160,79 @@ fun SubtitleStylePage( Modifier .fillMaxSize(), ) + } else if (focusedOnImageOpacity) { + AndroidView( + factory = { context -> + SubtitleView(context) + }, + update = { + it.setStyle( + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + }.build() + .toSubtitleStyle(), + ) + it.setCues( + listOf( + Cue + .Builder() + .setText("ExoPlayer only:\nImage based subtitles can be dimmed.") + .build(), + ), + ) + }, + modifier = + Modifier + .fillMaxSize() + .alpha(prefs.imageSubtitleOpacity / 100f), + ) } } - PreferencesContent( - initialPreferences = preferences, - preferenceScreenOption = PreferenceScreenOption.SUBTITLES, + val display = LocalView.current.display + val prefList = + remember(hdrSettings, display) { + if (!hdrSettings && SubtitleSettings.shouldShowHdr(display)) { + // If not on HDR page and display is HDR capable, then show the HDR button + SubtitleSettings.preferences + SubtitleSettings.hdrPreferenceGroup + } else { + SubtitleSettings.preferences + } + } + SubtitlePreferencesContent( + title = + if (hdrSettings) { + stringResource(R.string.hdr_subtitle_style) + } else { + stringResource(R.string.subtitle_style) + }, + preferences = + if (hdrSettings) { + preferences.interfacePreferences.hdrSubtitlesPreferences + } else { + preferences.interfacePreferences.subtitlesPreferences + }, + prefList = prefList, + onPreferenceChange = { newSubtitlePrefs -> + viewModel.preferenceDataStore.updateData { + it.updateInterfacePreferences { + if (hdrSettings) { + hdrSubtitlesPreferences = newSubtitlePrefs + } else { + subtitlesPreferences = newSubtitlePrefs + } + } + } + }, onFocus = { groupIndex, prefIndex -> - - focusedOnMargin = - SubtitleSettings.preferences.getOrNull(groupIndex)?.preferences?.getOrNull( - prefIndex, - ) == SubtitleSettings.Margin + val focusedPref = + SubtitleSettings.preferences + .getOrNull(groupIndex) + ?.preferences + ?.getOrNull(prefIndex) + focusedOnMargin = focusedPref == SubtitleSettings.Margin + focusedOnImageOpacity = focusedPref == SubtitleSettings.ImageOpacity }, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt new file mode 100644 index 00000000..1b9555c6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt new file mode 100644 index 00000000..99675dc0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt @@ -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(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt new file mode 100644 index 00000000..d07f054a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt new file mode 100644 index 00000000..a6b16ff0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt new file mode 100644 index 00000000..b177be74 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt @@ -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(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, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt new file mode 100644 index 00000000..a513715d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -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( + 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() + }, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt new file mode 100644 index 00000000..4d2d0662 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -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(false, false)) + val slideshow: StateFlow = _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() + + // private val album = MutableLiveData() + private val _pager = MutableLiveData>() + val pager: LiveData> = _pager.map { it } + val position = MutableLiveData(0) + + private val _image = MutableLiveData() + val image: LiveData = _image + + val loadingState = MutableLiveData(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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt new file mode 100644 index 00000000..5a27f818 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt @@ -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( + source: LiveData, + delayMs: Long, +) : MediatorLiveData() { + 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 + } + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 3c7f38ed..41fd78ef 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -127,6 +127,7 @@ message SubtitlePreferences{ bool font_italic = 10; int32 margin = 11; int32 edge_thickness = 12; + int32 image_subtitle_opacity = 13; } message LiveTvPreferences { @@ -152,12 +153,18 @@ message InterfacePreferences { SubtitlePreferences subtitles_preferences = 7; LiveTvPreferences live_tv_preferences = 8; BackdropStyle backdrop_style = 9; + SubtitlePreferences hdr_subtitles_preferences = 10; } message AdvancedPreferences { int64 image_disk_cache_size_bytes = 1; } +message PhotoPreferences{ + int64 slideshow_duration = 1; + bool slideshow_play_videos = 2; +} + message AppPreferences { // The currently signed in server and user IDs, mostly for restoring a session string current_server_id = 1; @@ -173,4 +180,5 @@ message AppPreferences { bool debug_logging = 9; AdvancedPreferences advanced_preferences = 10; bool sign_in_automatically = 11; + PhotoPreferences photo_preferences = 12; } diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index adf2c254..38cf32c1 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -9,6 +9,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2b050c8..fd5ceed1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,7 +25,7 @@ Confirm Continue watching Critic Rating - %.1f seconds + %.2f seconds Default Delete Died @@ -462,6 +462,29 @@ Request in 4K AV1 software decoding MPV is now the default player except for HDR.\nYou can change this in settings. + HDR subtitle style + Image subtitle opacity + + Brightness + Constrast + Saturation + Hue + Red + Green + Blue + Blur + Save for album + Play slideshow + Stop slideshow + At beginning + No more photos + Rotate left + Rotate right + Zoom in + Zoom out + Slideshow duration + Play videos during slideshow + Add row Genres in %1$s Recently released in %1$s diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt new file mode 100644 index 00000000..2f858ab1 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -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(relaxed = true) + private val mockServerRepository = mockk() + private val mockCache = mockk() + private val mockWorkManager = mockk() + + @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): Response = + 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 { every { this@mockk.state } returns state } + + @Test + fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() = + runTest { + val currentUser = MutableLiveData(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(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(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(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(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(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(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(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) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt new file mode 100644 index 00000000..709509c6 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt @@ -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(relaxed = true) + every { mockContext.cacheDir } returns tempDir + return SuggestionsCache(mockContext) + } + + private fun memoryCacheOf(cache: SuggestionsCache): MutableMap { + val field = SuggestionsCache::class.java.getDeclaredField("memoryCache") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + return field.get(cache) as MutableMap + } + + @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() + 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]) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt new file mode 100644 index 00000000..0df0cf38 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -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() + private val mockActivity = mockk(relaxed = true) + private val mockServerRepository = mockk(relaxed = true) + private val mockCache = mockk(relaxed = true) + private val mockWorkManager = mockk(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(mockk(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) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt new file mode 100644 index 00000000..4e388f21 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -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(relaxed = true) + private val mockServerRepository = mockk(relaxed = true) + private val mockPreferences = mockk>(relaxed = true) + private val mockApi = mockk(relaxed = true) + private val mockCache = mockk(relaxed = true) + private val mockUserViewsApi = mockk(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(relaxed = true) { + every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 } + } + + private fun mockQueryResult(items: List = emptyList()) = + mockk>(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() + 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(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) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt new file mode 100644 index 00000000..5027aee8 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt @@ -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, + private val random: List, + private val fresh: List, + 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> { + 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(), + listOf(movie1, movie2), + listOf(movie3), + 3, + "empty contextual - still combines others", + ), + arrayOf( + emptyList(), + emptyList(), + emptyList(), + 0, + "all empty - returns empty", + ), + ) + } + } +} + +// Helper functions to create test data + +private fun movie( + id: UUID = UUID.randomUUID(), + name: String = "Test Movie", + genres: List? = 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? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.EPISODE, + name = name, + seriesId = seriesId, + genreItems = genres, + ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10832e4a..b2a32580 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,8 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" +kotlinxCoroutinesTest = "1.7.3" +coreTesting = "2.2.0" openapi-generator = "7.19.0" [libraries] @@ -67,6 +69,7 @@ androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-com androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } @@ -97,6 +100,7 @@ androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } +androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } @@ -123,6 +127,8 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }