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)
This commit is contained in:
Ray 2026-02-04 20:04:46 -05:00 committed by GitHub
parent ee440698b0
commit d9d5ab02e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 3128 additions and 32 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -903,6 +903,46 @@ sealed interface AppPreference<Pref, T> {
getter = { },
setter = { prefs, _ -> prefs },
)
val SlideshowDuration =
AppSliderPreference<AppPreferences>(
title = R.string.slideshow_duration,
defaultValue = 5_000,
min = 1_000,
max = 30.seconds.inWholeMilliseconds,
interval = 250,
getter = {
it.photoPreferences.slideshowDuration
},
setter = { prefs, value ->
prefs.updatePhotoPreferences {
slideshowDuration = value
}
},
summarizer = { value ->
if (value != null) {
val seconds = value / 1000.0
WholphinApplication.instance.resources.getString(
R.string.decimal_seconds,
seconds,
)
} else {
null
}
},
)
val SlideshowPlayVideos =
AppSwitchPreference<AppPreferences>(
title = R.string.play_videos_during_slideshow,
defaultValue = false,
getter = { it.photoPreferences.slideshowPlayVideos },
setter = { prefs, value ->
prefs.updatePhotoPreferences { slideshowPlayVideos = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
}
}
@ -1015,6 +1055,8 @@ val advancedPreferences =
// AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout,
AppPreference.BackdropStylePref,
AppPreference.SlideshowDuration,
AppPreference.SlideshowPlayVideos,
),
),
)

View file

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

View file

@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.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
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -105,6 +105,16 @@ sealed class Destination(
val itemIds: List<UUID>,
) : Destination(false)
@Serializable
data class Slideshow(
val parentId: UUID,
val index: Int,
val filter: CollectionFolderFilter,
val sortAndDirection: SortAndDirection,
val recursive: Boolean,
val startSlideshow: Boolean,
) : Destination(true)
@Serializable
data object Favorites : Destination(false)

View file

@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.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(

View file

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

View file

@ -0,0 +1,219 @@
package com.github.damontecres.wholphin.ui.slideshow
import androidx.annotation.OptIn
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Icon
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import kotlin.time.Duration
@OptIn(UnstableApi::class)
@Composable
fun ImageControlsOverlay(
slideshowEnabled: Boolean,
slideshowControls: SlideshowControls,
onDismiss: () -> Unit,
isImageClip: Boolean,
onZoom: (Float) -> Unit,
onRotate: (Int) -> Unit,
onReset: () -> Unit,
moreOnClick: () -> Unit,
isPlaying: Boolean,
playPauseOnClick: () -> Unit,
onShowFilterDialogClick: () -> Unit,
bringIntoViewRequester: BringIntoViewRequester?,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
val onFocused = { focusState: FocusState ->
if (focusState.isFocused && bringIntoViewRequester != null) {
scope.launch(ExceptionHandler()) { bringIntoViewRequester.bringIntoView() }
}
}
LazyRow(
modifier =
modifier
.focusGroup(),
contentPadding = PaddingValues(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
item {
ExpandablePlayButton(
title = if (slideshowEnabled) R.string.stop_slideshow else R.string.play_slideshow,
icon =
painterResource(
if (slideshowEnabled) {
R.drawable.baseline_pause_24
} else {
R.drawable.baseline_play_arrow_24
},
),
resume = Duration.ZERO,
onClick = {
if (slideshowEnabled) {
slideshowControls.stopSlideshow()
} else {
slideshowControls.startSlideshow()
onDismiss.invoke()
}
},
modifier =
Modifier
.focusRequester(focusRequester)
.onFocusChanged(onFocused),
)
}
if (isImageClip) {
item {
Button(
onClick = playPauseOnClick,
modifier =
Modifier
.onFocusChanged(onFocused),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
painter =
painterResource(
if (isPlaying) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24,
),
contentDescription = null,
)
}
}
} else {
// Regular image
item {
ExpandableFaButton(
title = R.string.rotate_left,
iconStringRes = R.string.fa_rotate_left,
onClick = { onRotate(-90) },
modifier =
Modifier
.onFocusChanged(onFocused),
)
}
item {
ExpandableFaButton(
title = R.string.rotate_right,
iconStringRes = R.string.fa_rotate_right,
onClick = { onRotate(90) },
modifier =
Modifier
.onFocusChanged(onFocused),
)
}
item {
ExpandableFaButton(
title = R.string.zoom_in,
iconStringRes = R.string.fa_magnifying_glass_plus,
onClick = { onZoom(.15f) },
modifier =
Modifier
.onFocusChanged(onFocused),
)
}
item {
ExpandableFaButton(
title = R.string.zoom_out,
iconStringRes = R.string.fa_magnifying_glass_minus,
onClick = { onZoom(-.15f) },
modifier =
Modifier
.onFocusChanged(onFocused),
)
}
item {
ExpandableFaButton(
title = R.string.reset,
iconStringRes = R.string.fa_arrows_rotate,
onClick = onReset,
modifier =
Modifier
.onFocusChanged(onFocused),
)
}
item {
ExpandableFaButton(
title = R.string.filter,
iconStringRes = R.string.fa_sliders,
onClick = onShowFilterDialogClick,
modifier =
Modifier
.onFocusChanged(onFocused),
)
}
}
// More button
// item {
// ExpandablePlayButton(
// title = R.string.more,
// resume = Duration.ZERO,
// icon = Icons.Default.MoreVert,
// onClick = { moreOnClick.invoke() },
// modifier = Modifier.onFocusChanged(onFocused),
// )
// }
}
}
@Preview(widthDp = 800)
@Composable
private fun ImageControlsOverlayPreview() {
WholphinTheme {
ImageControlsOverlay(
slideshowEnabled = true,
slideshowControls =
object : SlideshowControls {
override fun startSlideshow() {
}
override fun stopSlideshow() {
}
},
isImageClip = false,
onZoom = {},
onRotate = {},
onReset = {},
moreOnClick = {},
isPlaying = false,
playPauseOnClick = {},
bringIntoViewRequester = null,
onShowFilterDialogClick = {},
onDismiss = {},
modifier = Modifier,
)
}
}

View file

@ -0,0 +1,114 @@
package com.github.damontecres.wholphin.ui.slideshow
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.StreamLabel
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.MediaType
@OptIn(UnstableApi::class)
@Composable
fun ImageDetailsHeader(
slideshowEnabled: Boolean,
slideshowControls: SlideshowControls,
player: Player,
image: ImageState,
position: Int,
count: Int,
moreOnClick: () -> Unit,
onZoom: (Float) -> Unit,
onRotate: (Int) -> Unit,
onReset: () -> Unit,
onShowFilterDialogClick: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val scope = rememberCoroutineScope()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.bringIntoViewRequester(bringIntoViewRequester),
) {
image.image.title?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
}
if (image.image.ui.quickDetails
.isNotNullOrBlank()
) {
QuickDetails(image.image.ui.quickDetails, null)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
StreamLabel("${position + 1} of $count")
if (image.image.data.mediaType == MediaType.VIDEO) {
VideoStreamDetails(
chosenStreams = image.chosenStreams,
numberOfVersions = 0,
)
} else {
image.image.data.let {
if (it.width != null && it.height != null) {
StreamLabel("${it.width}x${it.height}")
}
}
}
}
OverviewText(
overview = image.image.data.overview ?: "",
maxLines = 3,
onClick = {},
modifier = Modifier.fillMaxWidth(.75f),
)
val playPauseState = rememberPlayPauseButtonState(player)
ImageControlsOverlay(
slideshowEnabled = slideshowEnabled,
slideshowControls = slideshowControls,
isImageClip = image.image.data.mediaType == MediaType.VIDEO,
bringIntoViewRequester = bringIntoViewRequester,
onZoom = onZoom,
onRotate = onRotate,
onReset = onReset,
moreOnClick = moreOnClick,
playPauseOnClick = playPauseState::onClick,
isPlaying = playPauseState.showPlay,
onShowFilterDialogClick = onShowFilterDialogClick,
onDismiss = {},
modifier =
Modifier
.fillMaxWidth(),
)
}
}

View file

@ -0,0 +1,296 @@
package com.github.damontecres.wholphin.ui.slideshow
import android.view.Gravity
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.ui.components.SliderBar
import com.github.damontecres.wholphin.ui.components.SliderColors
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
const val DRAG_THROTTLE_DELAY = 50L
@Composable
fun ImageFilterSliders(
filter: VideoFilter,
showVideoOptions: Boolean,
showSaveButton: Boolean,
showSaveGalleryButton: Boolean,
onChange: (VideoFilter) -> Unit,
onClickSave: () -> Unit,
onClickSaveGallery: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
item {
SliderBarRow(
title = R.string.brightness,
value = filter.brightness,
min = 0,
max = 200,
onChange = { onChange.invoke(filter.copy(brightness = it)) },
valueFormater = { "$it%" },
)
}
item {
SliderBarRow(
title = R.string.contrast,
value = filter.contrast,
min = 0,
max = 200,
onChange = { onChange.invoke(filter.copy(contrast = it)) },
valueFormater = { "$it%" },
)
}
item {
SliderBarRow(
title = R.string.saturation,
value = filter.saturation,
min = 0,
max = 200,
onChange = { onChange.invoke(filter.copy(saturation = it)) },
valueFormater = { "$it%" },
)
}
if (showVideoOptions) {
item {
SliderBarRow(
title = R.string.hue,
value = filter.hue,
min = 0,
max = 360,
onChange = { onChange.invoke(filter.copy(hue = it)) },
valueFormater = { "$it\u00b0" },
)
}
}
item {
SliderBarRow(
title = R.string.red,
value = filter.red,
min = 0,
max = 200,
onChange = { onChange.invoke(filter.copy(red = it)) },
valueFormater = { "$it%" },
colors =
SliderColors(
activeFocused = Color.Red.copy(alpha = .75f),
activeUnfocused = Color.Red.copy(alpha = .75f),
inactiveFocused = Color.Red.copy(alpha = .33f),
inactiveUnfocused = Color.Red.copy(alpha = .33f),
),
)
}
item {
SliderBarRow(
title = R.string.green,
value = filter.green,
min = 0,
max = 200,
onChange = { onChange.invoke(filter.copy(green = it)) },
valueFormater = { "$it%" },
colors =
SliderColors(
activeFocused = Color.Green.copy(alpha = .75f),
activeUnfocused = Color.Green.copy(alpha = .75f),
inactiveFocused = Color.Green.copy(alpha = .33f),
inactiveUnfocused = Color.Green.copy(alpha = .33f),
),
)
}
item {
SliderBarRow(
title = R.string.blue,
value = filter.blue,
min = 0,
max = 200,
onChange = { onChange.invoke(filter.copy(blue = it)) },
valueFormater = { "$it%" },
colors =
SliderColors(
activeFocused = Color.Blue.copy(alpha = .75f),
activeUnfocused = Color.Blue.copy(alpha = .75f),
inactiveFocused = Color.Blue.copy(alpha = .33f),
inactiveUnfocused = Color.Blue.copy(alpha = .33f),
),
)
}
if (showVideoOptions) {
item {
SliderBarRow(
title = R.string.blur,
value = filter.blur,
min = 0,
max = 250,
onChange = { onChange.invoke(filter.copy(blur = it)) },
valueFormater = { "${it}px" },
)
}
}
item {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier,
) {
if (showSaveButton) {
Button(
onClick = onClickSave,
) {
Text(text = stringResource(R.string.save))
}
}
if (showSaveGalleryButton) {
Button(
onClick = onClickSaveGallery,
) {
Text(text = stringResource(R.string.save_for_album))
}
}
Button(
onClick = { onChange(VideoFilter()) },
) {
Text(text = stringResource(R.string.reset))
}
}
}
}
}
}
@Composable
fun SliderBarRow(
@StringRes title: Int,
value: Int,
min: Int,
max: Int,
onChange: (Int) -> Unit,
valueFormater: (Int) -> String,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
interval: Int = 1,
colors: SliderColors = SliderColors.default(),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Text(
text = stringResource(title),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.width(96.dp),
)
SliderBar(
value = value.toLong(),
min = min.toLong(),
max = max.toLong(),
interval = interval,
onChange = {
onChange.invoke(it.toInt())
},
colors = colors,
interactionSource = interactionSource,
enableWrapAround = true,
modifier = Modifier.weight(1f),
)
Text(
text = valueFormater(value),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.width(48.dp),
)
}
}
@Composable
fun ImageFilterDialog(
filter: VideoFilter,
showVideoOptions: Boolean,
showSaveGalleryButton: Boolean,
onChange: (VideoFilter) -> Unit,
onClickSave: () -> Unit,
onClickSaveGallery: () -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.TOP or Gravity.END)
window.setDimAmount(0f)
}
Box(
modifier =
modifier
.wrapContentSize()
.padding(8.dp)
.background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .4f))
.fillMaxWidth(.4f),
) {
ImageFilterSliders(
filter = filter,
showVideoOptions = showVideoOptions,
showSaveButton = true,
showSaveGalleryButton = showSaveGalleryButton,
onChange = onChange,
onClickSave = onClickSave,
onClickSaveGallery = onClickSaveGallery,
modifier = Modifier.padding(8.dp),
)
}
}
}
@Preview
@Composable
private fun ImageFilterSlidersPreview() {
WholphinTheme {
ImageFilterSliders(
filter = VideoFilter(),
showVideoOptions = true,
onChange = {},
onClickSave = {},
onClickSaveGallery = {},
showSaveButton = true,
showSaveGalleryButton = true,
modifier = Modifier.padding(8.dp),
)
}
}

View file

@ -0,0 +1,54 @@
package com.github.damontecres.wholphin.ui.slideshow
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@Composable
fun ImageLoadingPlaceholder(
thumbnailUrl: String?,
showThumbnail: Boolean,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
if (showThumbnail && thumbnailUrl.isNotNullOrBlank()) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(thumbnailUrl)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter = colorFilter,
modifier =
Modifier
.fillMaxSize()
.align(Alignment.Center)
.alpha(.75f)
.blur(4.dp),
)
}
CircularProgress(
Modifier
.size(80.dp)
.align(Alignment.Center),
)
}
}

View file

@ -0,0 +1,146 @@
package com.github.damontecres.wholphin.ui.slideshow
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun ImageOverlay(
onDismiss: () -> Unit,
player: Player,
slideshowControls: SlideshowControls,
slideshowEnabled: Boolean,
position: Int,
count: Int,
image: ImageState,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
onZoom: (Float) -> Unit,
onRotate: (Int) -> Unit,
onReset: () -> Unit,
onShowFilterDialogClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var showDialog by remember { mutableStateOf<DialogParams?>(null) }
val moreDialogParams =
remember {
DialogParams(
fromLongClick = false,
title = "TODO",
items =
listOf(
DialogItem(
headlineContent = {
Text(
text =
if (slideshowEnabled) {
stringResource(R.string.stop_slideshow)
} else {
stringResource(R.string.play_slideshow)
},
)
},
leadingContent = {
val icon =
if (slideshowEnabled) {
R.drawable.baseline_pause_24
} else {
R.drawable.baseline_play_arrow_24
}
Icon(
painter = painterResource(icon),
contentDescription = null,
)
},
onClick = {
if (slideshowEnabled) {
slideshowControls.stopSlideshow()
} else {
slideshowControls.startSlideshow()
}
},
),
DialogItem(
headlineContent = {
Text(
text = stringResource(R.string.filter),
)
},
leadingContent = {
Text(
text = stringResource(R.string.fa_sliders),
fontFamily = FontAwesome,
)
},
onClick = onShowFilterDialogClick,
),
),
)
}
val horizontalPadding = 16.dp
LazyColumn(
contentPadding =
PaddingValues(
start = horizontalPadding,
end = horizontalPadding,
top = 16.dp,
bottom = 16.dp,
),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
item {
ImageDetailsHeader(
onDismiss = onDismiss,
slideshowEnabled = slideshowEnabled,
slideshowControls = slideshowControls,
player = player,
image = image,
position = position,
count = count,
moreOnClick = {
showDialog = moreDialogParams
},
onZoom = onZoom,
onRotate = onRotate,
onReset = onReset,
onShowFilterDialogClick = onShowFilterDialogClick,
modifier = Modifier.fillMaxWidth(),
)
}
}
showDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { showDialog = null },
waitToLoad = params.fromLongClick,
)
}
}

View file

@ -0,0 +1,509 @@
package com.github.damontecres.wholphin.ui.slideshow
import android.annotation.SuppressLint
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorMatrixColorFilter
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
import androidx.media3.ui.compose.state.rememberPresentationState
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.SubcomposeAsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.size.Size
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.findActivity
import com.github.damontecres.wholphin.ui.keepScreenOn
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
import com.github.damontecres.wholphin.ui.playback.isDpad
import com.github.damontecres.wholphin.ui.playback.isEnterKey
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import kotlin.math.abs
private const val TAG = "ImagePage"
private const val DEBUG = false
@SuppressLint("ConfigurationScreenWidthHeight")
@OptIn(UnstableApi::class)
@Composable
fun SlideshowPage(
slideshow: Destination.Slideshow,
modifier: Modifier = Modifier,
viewModel: SlideshowViewModel =
hiltViewModel<SlideshowViewModel, SlideshowViewModel.Factory>(
creationCallback = {
it.create(slideshow)
},
),
) {
val context = LocalContext.current
val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading)
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
val position by viewModel.position.observeAsState(0)
val pager by viewModel.pager.observeAsState()
val imageState by viewModel.image.observeAsState()
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
val isZoomed = zoomFactor * 100 > 102
var rotation by rememberSaveable { mutableFloatStateOf(0f) }
var showOverlay by rememberSaveable { mutableStateOf(false) }
var showFilterDialog by rememberSaveable { mutableStateOf(false) }
var panX by rememberSaveable { mutableFloatStateOf(0f) }
var panY by rememberSaveable { mutableFloatStateOf(0f) }
val slideshowControls =
object : SlideshowControls {
override fun startSlideshow() {
showOverlay = false
viewModel.startSlideshow()
}
override fun stopSlideshow() {
viewModel.stopSlideshow()
}
}
val rotateAnimation: Float by animateFloatAsState(
targetValue = rotation,
label = "image_rotation",
)
val zoomAnimation: Float by animateFloatAsState(
targetValue = zoomFactor,
label = "image_zoom",
)
val panXAnimation: Float by animateFloatAsState(
targetValue = panX,
label = "image_panX",
)
val panYAnimation: Float by animateFloatAsState(
targetValue = panY,
label = "image_panY",
)
val slideshowState by viewModel.slideshow.collectAsState()
val slideshowActive by viewModel.slideshowActive.collectAsState(false)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
val density = LocalDensity.current
val screenHeight = LocalWindowInfo.current.containerSize.height
val screenWidth = LocalWindowInfo.current.containerSize.width
val maxPanX = screenWidth * .75f
val maxPanY = screenHeight * .75f
fun reset(resetRotate: Boolean) {
zoomFactor = 1f
panX = 0f
panY = 0f
if (resetRotate) rotation = 0f
}
fun pan(
xFactor: Int,
yFactor: Int,
) {
if (xFactor != 0) {
panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX)
}
if (yFactor != 0) {
panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY)
}
}
fun zoom(factor: Float) {
if (factor < 0) {
val diffFactor = factor / (zoomFactor - 1f)
// zooming out
val panXDiff = abs(panX * diffFactor)
val panYDiff = abs(panY * diffFactor)
if (DEBUG) {
Timber.d(
"zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff",
)
}
if (panX > 0f) {
panX -= panXDiff
} else if (panX < 0f) {
panX += panXDiff
}
if (panY > 0f) {
panY -= panYDiff
} else if (panY < 0f) {
panY += panYDiff
}
}
zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f)
if (!isZoomed) {
// Always reset if not zoomed
panX = 0f
panY = 0f
}
}
LaunchedEffect(imageState) {
reset(true)
}
val player = viewModel.player
val presentationState = rememberPresentationState(player)
LaunchedEffect(slideshowActive) {
player.repeatMode =
if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE
context.findActivity()?.keepScreenOn(slideshowActive)
}
DisposableEffect(Unit) {
onDispose {
context.findActivity()?.keepScreenOn(false)
}
}
var longPressing by remember { mutableStateOf(false) }
val contentModifier =
Modifier
.clickable(
interactionSource = null,
indication = null,
onClick = {
showOverlay = !showOverlay
},
)
Box(
modifier =
modifier
.background(Color.Black)
.focusRequester(focusRequester)
.focusable()
.onKeyEvent {
val isOverlayShowing = showOverlay || showFilterDialog
var result = false
if (!isOverlayShowing) {
if (longPressing && it.type == KeyEventType.KeyUp) {
// User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image
longPressing = false
return@onKeyEvent true
}
longPressing =
it.nativeKeyEvent.isLongPress ||
it.nativeKeyEvent.repeatCount > 0
if (longPressing) {
when (it.key) {
Key.DirectionUp -> zoom(.05f)
Key.DirectionDown -> zoom(-.05f)
// These work, but feel awkward because Up/Down zoom, so you can't long press them to pan
// Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() }
// Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() }
}
return@onKeyEvent true
}
}
if (it.type != KeyEventType.KeyUp) {
result = false
} else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) {
// Image is zoomed in
when (it.key) {
Key.DirectionLeft -> pan(30, 0)
Key.DirectionRight -> pan(-30, 0)
Key.DirectionUp -> pan(0, 30)
Key.DirectionDown -> pan(0, -30)
}
result = true
} else if (!isOverlayShowing && isZoomed && it.key == Key.Back) {
reset(false)
result = true
} else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) {
when (it.key) {
Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> {
if (!viewModel.previousImage()) {
Toast
.makeText(
context,
R.string.slideshow_at_beginning,
Toast.LENGTH_SHORT,
).show()
}
}
Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> {
if (!viewModel.nextImage()) {
Toast
.makeText(
context,
R.string.no_more_images,
Toast.LENGTH_SHORT,
).show()
}
}
}
} else if (isOverlayShowing && it.key == Key.Back) {
showOverlay = false
viewModel.unpauseSlideshow()
result = true
} else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) {
showOverlay = true
viewModel.pauseSlideshow()
result = true
}
if (result) {
// Handled the key, so reset the slideshow timer
viewModel.pulseSlideshow()
}
result
},
) {
when (loadingState) {
ImageLoadingState.Error -> {
ErrorMessage("Error loading image", null)
}
ImageLoadingState.Loading -> {
LoadingPage()
}
is ImageLoadingState.Success -> {
imageState?.let { imageState ->
if (imageState.image.data.mediaType == MediaType.VIDEO) {
LaunchedEffect(imageState.id) {
val mediaItem =
MediaItem
.Builder()
.setUri(imageState.url)
.build()
player.setMediaItem(mediaItem)
player.repeatMode =
if (slideshowState.enabled) {
Player.REPEAT_MODE_OFF
} else {
Player.REPEAT_MODE_ONE
}
player.prepare()
player.play()
viewModel.pulseSlideshow(Long.MAX_VALUE)
}
LifecycleStartEffect(Unit) {
onStopOrDispose {
player.stop()
}
}
val contentScale = ContentScale.Fit
val scaledModifier =
contentModifier.resizeWithContentScale(
contentScale,
presentationState.videoSizeDp,
)
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier =
scaledModifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoomAnimation
scaleY = zoomAnimation
translationX = panXAnimation
translationY = panYAnimation
}.rotate(rotateAnimation),
)
if (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
)
}
} else {
val colorFilter =
remember(imageState.id, imageFilter) {
if (imageFilter.hasImageFilter()) {
ColorMatrixColorFilter(imageFilter.colorMatrix)
} else {
null
}
}
// If the image loading is large, show the thumbnail while waiting
// TODO
val showLoadingThumbnail = true
SubcomposeAsyncImage(
modifier =
contentModifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoomAnimation
scaleY = zoomAnimation
translationX = panXAnimation
translationY = panYAnimation
val xTransform =
(screenWidth - panXAnimation) / (screenWidth * 2)
val yTransform =
(screenHeight - panYAnimation) / (screenHeight * 2)
if (DEBUG) {
Timber.d(
"graphicsLayer: xTransform=$xTransform, yTransform=$yTransform",
)
}
transformOrigin = TransformOrigin(xTransform, yTransform)
}.rotate(rotateAnimation),
model =
ImageRequest
.Builder(LocalContext.current)
.data(imageState.url)
.size(Size.ORIGINAL)
.crossfade(!showLoadingThumbnail)
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter = colorFilter,
error = {
Text(
modifier =
Modifier
.align(Alignment.Center),
text = "Error loading image",
color = MaterialTheme.colorScheme.onBackground,
)
},
loading = {
ImageLoadingPlaceholder(
thumbnailUrl = imageState.thumbnailUrl,
showThumbnail = showLoadingThumbnail,
colorFilter = colorFilter,
modifier = Modifier.fillMaxSize(),
)
},
// Ensure that if an image takes a long time to load, it won't be skipped
onLoading = {
viewModel.pulseSlideshow(Long.MAX_VALUE)
},
onSuccess = {
viewModel.pulseSlideshow()
},
onError = {
Timber.e(
it.result.throwable,
"Error loading image ${imageState.id}",
)
Toast
.makeText(
context,
"Error loading image: ${it.result.throwable.localizedMessage}",
Toast.LENGTH_LONG,
).show()
viewModel.pulseSlideshow()
},
)
}
}
}
}
AnimatedVisibility(
showOverlay,
enter = slideInVertically { it },
exit = slideOutVertically { it },
modifier = Modifier.align(Alignment.BottomStart),
) {
imageState?.let { imageState ->
ImageOverlay(
modifier =
contentModifier
.fillMaxWidth()
.background(AppColors.TransparentBlack50),
onDismiss = { showOverlay = false },
player = player,
slideshowControls = slideshowControls,
slideshowEnabled = slideshowState.enabled,
image = imageState,
position = position,
count = pager?.size ?: -1,
onClickItem = {},
onLongClickItem = {},
onZoom = ::zoom,
onRotate = { rotation += it },
onReset = { reset(true) },
onShowFilterDialogClick = {
showFilterDialog = true
showOverlay = false
viewModel.pauseSlideshow()
},
)
}
}
AnimatedVisibility(showFilterDialog) {
ImageFilterDialog(
filter = imageFilter,
showVideoOptions = false,
showSaveGalleryButton = true,
onChange = viewModel::updateImageFilter,
onClickSave = viewModel::saveImageFilter,
onClickSaveGallery = viewModel::saveGalleryFilter,
onDismissRequest = {
showFilterDialog = false
viewModel.unpauseSlideshow()
viewModel.pulseSlideshow()
},
)
}
}
}

View file

@ -0,0 +1,445 @@
package com.github.damontecres.wholphin.ui.slideshow
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.media3.common.Player
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaybackEffect
import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.PhotoItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.util.ThrottledLiveData
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
import kotlin.properties.Delegates
@HiltViewModel(assistedFactory = SlideshowViewModel.Factory::class)
class SlideshowViewModel
@AssistedInject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val playerFactory: PlayerFactory,
private val playbackEffectDao: PlaybackEffectDao,
private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
private val userPreferencesService: UserPreferencesService,
@Assisted val slideshowSettings: Destination.Slideshow,
) : ViewModel(),
Player.Listener {
@AssistedFactory
interface Factory {
fun create(slideshow: Destination.Slideshow): SlideshowViewModel
}
val player by lazy {
playerFactory.createVideoPlayer()
}
private var saveFilters = true
/**
* Whether slideshow mode is on or off
*/
private val _slideshow = MutableStateFlow<SlideshowState>(SlideshowState(false, false))
val slideshow: StateFlow<SlideshowState> = _slideshow
/**
* Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused
*/
val slideshowActive = slideshow.map { it.enabled && !it.paused }
var slideshowDelay by Delegates.notNull<Long>()
// private val album = MutableLiveData<BaseItem>()
private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>()
val pager: LiveData<List<BaseItem?>> = _pager.map { it }
val position = MutableLiveData(0)
private val _image = MutableLiveData<ImageState>()
val image: LiveData<ImageState> = _image
val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading)
private val _imageFilter = MutableLiveData(VideoFilter())
val imageFilter = ThrottledLiveData(_imageFilter, 500L)
private var albumImageFilter = VideoFilter()
init {
addCloseable {
player.removeListener(this@SlideshowViewModel)
player.release()
}
player.addListener(this@SlideshowViewModel)
viewModelScope.launchIO {
val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences
slideshowDelay =
photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min }
?: AppPreference.SlideshowDuration.defaultValue
// val album =
// api.userLibraryApi
// .getItem(
// itemId = slideshowSettings.parentId,
// ).content
// .let { BaseItem(it, false) }
// this@SlideshowViewModel.album.setValueOnMain(album)
val includeItemTypes =
if (photoPrefs.slideshowPlayVideos) {
listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO)
} else {
listOf(BaseItemKind.PHOTO)
}
val request =
slideshowSettings.filter.filter.applyTo(
GetItemsRequest(
parentId = slideshowSettings.parentId,
includeItemTypes = includeItemTypes,
fields = PhotoItemFields,
recursive = true,
sortBy = listOf(slideshowSettings.sortAndDirection.sort),
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
),
)
serverRepository.currentUser.value?.let { user ->
val filter =
playbackEffectDao
.getPlaybackEffect(
user.rowId,
slideshowSettings.parentId,
BaseItemKind.PHOTO_ALBUM,
)?.videoFilter
if (filter != null) {
Timber.v("Got filter for album %s", slideshowSettings.parentId)
albumImageFilter = filter
}
}
val pager =
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
.init(slideshowSettings.index)
this@SlideshowViewModel._pager.setValueOnMain(pager)
updatePosition(slideshowSettings.index)?.join()
if (slideshowSettings.startSlideshow) onMain { startSlideshow() }
}
}
fun nextImage(): Boolean {
val size = pager.value?.size
val newPosition = position.value!! + 1
return if (size != null && newPosition < size) {
updatePosition(newPosition)
true
} else {
false
}
}
fun previousImage(): Boolean {
val newPosition = position.value!! - 1
return if (newPosition >= 0) {
updatePosition(newPosition)
true
} else {
false
}
}
fun updatePosition(position: Int): Job? =
_pager.value?.let { pager ->
viewModelScope.launchIO {
try {
val image = pager.getBlocking(position)
Timber.v("Got image for $position: ${image != null}")
if (image != null) {
this@SlideshowViewModel.position.setValueOnMain(position)
val url =
if (image.data.mediaType == MediaType.VIDEO) {
// TODO this assumes direct play
api.videosApi.getVideoStreamUrl(
itemId = image.id,
)
} else {
api.libraryApi.getDownloadUrl(image.id)
}
val chosenStreams =
if (image.data.mediaType == MediaType.VIDEO) {
image.data.mediaSources?.firstOrNull()?.let { source ->
val video =
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
val audio =
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
ChosenStreams(
itemPlayback = null,
plc = null,
itemId = image.id,
source = source,
videoStream = video,
audioStream = audio,
subtitleStream = null,
subtitlesDisabled = false,
)
}
} else {
null
}
val imageState =
ImageState(
image,
url,
imageUrlService.getItemImageUrl(image, ImageType.THUMB),
chosenStreams,
)
// reset image filter
updateImageFilter(albumImageFilter)
if (saveFilters) {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
val vf =
playbackEffectDao
.getPlaybackEffect(
user.rowId,
image.id,
BaseItemKind.PHOTO,
)
if (vf != null && vf.videoFilter.hasImageFilter()) {
Timber.d(
"Loaded VideoFilter for image ${image.id}",
)
withContext(Dispatchers.Main) {
// Pause throttling so that the image loads with the filter applied immediately
imageFilter.stopThrottling(true)
updateImageFilter(vf.videoFilter)
imageFilter.startThrottling()
}
}
withContext(Dispatchers.Main) {
_image.value = imageState
loadingState.value =
ImageLoadingState.Success(imageState)
}
}
}
} else {
withContext(Dispatchers.Main) {
_image.value = imageState
loadingState.value = ImageLoadingState.Success(imageState)
}
}
} else {
loadingState.setValueOnMain(ImageLoadingState.Error)
}
} catch (ex: Exception) {
Timber.e(ex)
loadingState.setValueOnMain(ImageLoadingState.Error)
}
}
}
private var slideshowJob: Job? = null
fun startSlideshow() {
_slideshow.update {
SlideshowState(enabled = true, paused = false)
}
if (_image.value
?.image
?.data
?.mediaType != MediaType.VIDEO
) {
pulseSlideshow()
}
}
fun stopSlideshow() {
slideshowJob?.cancel()
_slideshow.update {
SlideshowState(enabled = false, paused = false)
}
}
fun pauseSlideshow() {
Timber.v("pauseSlideshow")
_slideshow.update {
if (it.enabled) {
slideshowJob?.cancel()
it.copy(paused = true)
} else {
it
}
}
}
fun unpauseSlideshow() {
Timber.v("unpauseSlideshow")
_slideshow.update {
if (it.enabled) {
it.copy(paused = false)
} else {
it
}
}
}
fun pulseSlideshow() = pulseSlideshow(slideshowDelay)
fun pulseSlideshow(milliseconds: Long) {
Timber.v("pulseSlideshow $milliseconds")
slideshowJob?.cancel()
slideshowJob =
viewModelScope
.launchIO {
delay(milliseconds)
// Timber.v("pulseSlideshow after delay")
if (slideshowActive.first()) {
// Next image or loop to beginning
if (!nextImage()) updatePosition(0)
}
}.apply {
invokeOnCompletion { if (it !is CancellationException) pulseSlideshow() }
}
}
fun updateImageFilter(newFilter: VideoFilter) {
viewModelScope.launchIO {
_imageFilter.setValueOnMain(newFilter)
}
}
fun saveImageFilter() {
image.value?.let {
viewModelScope.launchIO {
val vf = _imageFilter.value
if (vf != null) {
serverRepository.currentUser.value?.let { user ->
playbackEffectDao
.insert(
PlaybackEffect(
user.rowId,
it.image.id,
BaseItemKind.PHOTO,
vf,
),
)
Timber.d("Saved VideoFilter for image %s", it.image.id)
withContext(Dispatchers.Main) {
showToast(
context,
"Saved",
Toast.LENGTH_SHORT,
)
}
}
}
}
}
}
fun saveGalleryFilter() {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
val vf = _imageFilter.value
if (vf != null) {
albumImageFilter = vf
serverRepository.currentUser.value?.let { user ->
playbackEffectDao
.insert(
PlaybackEffect(
user.rowId,
slideshowSettings.parentId,
BaseItemKind.PHOTO_ALBUM,
vf,
),
)
Timber.d("Saved VideoFilter for album %s", slideshowSettings.parentId)
withContext(Dispatchers.Main) {
showToast(
context,
"Saved",
Toast.LENGTH_SHORT,
)
}
}
}
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
pulseSlideshow(slideshowDelay)
}
}
}
interface SlideshowControls {
fun startSlideshow()
fun stopSlideshow()
}
sealed class ImageLoadingState {
data object Loading : ImageLoadingState()
data object Error : ImageLoadingState()
data class Success(
val image: ImageState,
) : ImageLoadingState()
}
@Stable
data class ImageState(
val image: BaseItem,
val url: String,
val thumbnailUrl: String?,
val chosenStreams: ChosenStreams?,
) {
val id: UUID get() = image.id
}
data class SlideshowState(
val enabled: Boolean,
val paused: Boolean,
)

View file

@ -0,0 +1,82 @@
package com.github.damontecres.wholphin.ui.util
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
/**
* LiveData throttling value emissions so they don't happen more often than [delayMs].
*
* From https://stackoverflow.com/a/62467521
*/
class ThrottledLiveData<T>(
source: LiveData<T>,
delayMs: Long,
) : MediatorLiveData<T>() {
val handler = Handler(Looper.getMainLooper())
var delayMs = delayMs
private set
private var isValueDelayed = false
private var delayedValue: T? = null
private var delayRunnable: Runnable? = null
set(value) {
field?.let { handler.removeCallbacks(it) }
value?.let { handler.postDelayed(it, delayMs) }
field = value
}
private val objDelayRunnable = Runnable { if (consumeDelayedValue()) startDelay() }
init {
addSource(source) { newValue ->
if (delayRunnable == null) {
value = newValue
startDelay()
} else {
isValueDelayed = true
delayedValue = newValue
}
}
}
/** Start throttling or modify the delay. If [newDelay] is `0` (default) reuse previous delay value. */
fun startThrottling(newDelay: Long = 0L) {
require(newDelay >= 0L)
when {
newDelay > 0 -> delayMs = newDelay
delayMs < 0 -> delayMs *= -1
delayMs > 0 -> return
else -> throw kotlin.IllegalArgumentException("newDelay cannot be zero if old delayMs is zero")
}
}
/** Stop throttling, if [immediate] emit any pending value now. */
fun stopThrottling(immediate: Boolean = false) {
if (delayMs <= 0) return
delayMs *= -1
if (immediate) consumeDelayedValue()
}
override fun onInactive() {
super.onInactive()
consumeDelayedValue()
}
// start counting the delay or clear it if conditions are not met
private fun startDelay() {
delayRunnable = if (delayMs > 0 && hasActiveObservers()) objDelayRunnable else null
}
private fun consumeDelayedValue(): Boolean {
delayRunnable = null
return if (isValueDelayed) {
value = delayedValue
delayedValue = null
isValueDelayed = false
true
} else {
false
}
}
}

View file

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

View file

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

View file

@ -25,7 +25,7 @@
<string name="confirm">Confirm</string>
<string name="continue_watching">Continue watching</string>
<string name="critic_rating">Critic Rating</string>
<string name="decimal_seconds">%.1f seconds</string>
<string name="decimal_seconds">%.2f seconds</string>
<string name="default_track">Default</string>
<string name="delete">Delete</string>
<string name="died">Died</string>
@ -465,6 +465,26 @@
<string name="hdr_subtitle_style">HDR subtitle style</string>
<string name="image_subtitle_opacity">Image subtitle opacity</string>
<string name="brightness">Brightness</string>
<string name="contrast">Constrast</string>
<string name="saturation">Saturation</string>
<string name="hue">Hue</string>
<string name="red">Red</string>
<string name="green">Green</string>
<string name="blue">Blue</string>
<string name="blur">Blur</string>
<string name="save_for_album">Save for album</string>
<string name="play_slideshow">Play slideshow</string>
<string name="stop_slideshow">Stop slideshow</string>
<string name="slideshow_at_beginning">At beginning</string>
<string name="no_more_images">No more photos</string>
<string name="rotate_left">Rotate left</string>
<string name="rotate_right">Rotate right</string>
<string name="zoom_in">Zoom in</string>
<string name="zoom_out">Zoom out</string>
<string name="slideshow_duration">Slideshow duration</string>
<string name="play_videos_during_slideshow">Play videos during slideshow</string>
<string-array name="theme_song_volume">
<item>Disabled</item>
<item>Lowest</item>