From d9d5ab02e8c419290643063fd1b04b161f6de2c4 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:04:46 -0500 Subject: [PATCH] Add support for viewing photos in photo albums (#703) ## Description Add support for viewing photos in photo albums ### Features - Browse and view albums & photos - View an album as a slideshow - Show overlay with photo details and controls - Rotate, zoom, & pan photos - Apply basic filters to photos or albums such as contrast, saturation, red/green/blue, etc - Setting to include video clips during slideshows ### Controls - D-Pad left or right: scroll between photos in album - Hold D-Pad up or down: zoom in or out of the current photo - D-Pad left/right/up/down, while zoomed: pan around photo - Back, while zoomed: revert the zoom - Otherwise: show photo overlay ### Related issues Closes #458 Closes #634 ### Screenshots #### Overlay ![photo_overlay Large](https://github.com/user-attachments/assets/b44ed876-d8bd-4f75-aa1e-4106c2edb2f7) #### Applying filters ![photo_filters Large](https://github.com/user-attachments/assets/1379978a-b27c-47ac-80af-f7bb6a2177df) --- app/build.gradle.kts | 1 + .../30.json | 635 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 8 +- .../wholphin/data/PlaybackEffectDao.kt | 22 + .../wholphin/data/model/BaseItem.kt | 8 +- .../wholphin/data/model/PlaybackEffect.kt | 14 + .../wholphin/data/model/VideoFilter.kt | 223 ++++++ .../wholphin/preferences/AppPreference.kt | 42 ++ .../preferences/AppPreferencesSerializer.kt | 13 + .../wholphin/services/AppUpgradeHandler.kt | 9 + .../services/PlaybackLifecycleObserver.kt | 5 +- .../wholphin/services/hilt/DatabaseModule.kt | 5 + .../damontecres/wholphin/ui/UiConstants.kt | 9 + .../ui/components/CollectionFolderGrid.kt | 48 +- .../wholphin/ui/components/PlayButtons.kt | 75 ++- .../wholphin/ui/components/SliderBar.kt | 27 +- .../ui/detail/CollectionFolderGeneric.kt | 4 +- .../ui/detail/CollectionFolderPhotoAlbum.kt | 88 +++ .../wholphin/ui/nav/Destination.kt | 10 + .../wholphin/ui/nav/DestinationContent.kt | 18 + .../ui/preferences/SliderPreference.kt | 1 - .../ui/slideshow/ImageControlsOverlay.kt | 219 ++++++ .../ui/slideshow/ImageDetailsHeader.kt | 114 ++++ .../ui/slideshow/ImageFilterSliders.kt | 296 ++++++++ .../ui/slideshow/ImageLoadingPlaceholder.kt | 54 ++ .../wholphin/ui/slideshow/ImageOverlay.kt | 146 ++++ .../wholphin/ui/slideshow/SlideshowPage.kt | 509 ++++++++++++++ .../ui/slideshow/SlideshowViewModel.kt | 445 ++++++++++++ .../wholphin/ui/util/ThrottledLiveData.kt | 82 +++ app/src/main/proto/WholphinDataStore.proto | 6 + app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 22 +- gradle/libs.versions.toml | 1 + 33 files changed, 3128 insertions(+), 32 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b3f74ef..37729e19 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -265,6 +265,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) 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/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 a9673131..869a0398 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 @@ -106,6 +106,12 @@ data class BaseItem( data.premiereDate?.let { add(DateFormatter.format(it)) } } else if (type == BaseItemKind.SERIES) { data.seriesProductionYears?.let(::add) + } else if (type == BaseItemKind.PHOTO) { + if (data.productionYear != null) { + add(data.productionYear!!.toString()) + } else if (data.premiereDate != null) { + add(data.premiereDate!!.toLocalDate().toString()) + } } else { data.productionYear?.let { add(it.toString()) } } @@ -154,7 +160,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 0472be5a..6015a128 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 @@ -903,6 +903,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, + ) } } @@ -1015,6 +1055,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 ca343804..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 @@ -128,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 { @@ -186,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() 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 7e911767..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 @@ -228,4 +229,12 @@ suspend fun upgradeApp( } } } + + 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..4be082a5 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 @@ -21,7 +21,10 @@ class PlaybackLifecycleObserver override fun onStart(owner: LifecycleOwner) { val lastDest = navigationManager.backStack.lastOrNull() - if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) { + if (lastDest is Destination.Playback || + lastDest is Destination.PlaybackList || + lastDest is Destination.Slideshow + ) { navigationManager.goBack() } wasPlaying = null 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/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 54f564f1..958f9dfd 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 { val height2x3 = 172.dp val heightEpisode = height2x3 * .75f 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/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 e6f3636d..0d16f36a 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 @@ -105,6 +105,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 09fd3936..c1e70880 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 @@ -36,6 +37,7 @@ 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 @@ -195,6 +197,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}") @@ -234,6 +246,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/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/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 eac223b0..41fd78ef 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -160,6 +160,11 @@ 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; @@ -175,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 ae2a4ae1..371560a1 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 c85ef303..c0021de1 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 @@ -465,6 +465,26 @@ 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 + Disabled Lowest diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10832e4a..92e42737 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -97,6 +97,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" }