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

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