Skip to content

Commit 41cf58d

Browse files
authored
Merge pull request #1622 from tunjid/tj/media-config
Add media config composition local
2 parents 1212800 + 215a62f commit 41cf58d

3 files changed

Lines changed: 100 additions & 59 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.tunjid.heron.media
2+
3+
import androidx.collection.IntList
4+
import androidx.collection.intListOf
5+
import androidx.compose.runtime.Stable
6+
import androidx.compose.runtime.staticCompositionLocalOf
7+
import androidx.compose.ui.unit.IntSize
8+
9+
@Stable
10+
class MediaConfig(
11+
val windowSize: () -> IntSize,
12+
val autoPlayGifs: () -> Boolean,
13+
val imageSizeBuckets: () -> IntList = ::DefaultImageSizeBuckets,
14+
)
15+
16+
private val DefaultImageSizeBuckets = intListOf(
17+
96, 128, 256, 320, 480, 640,
18+
720, 1024, 1280, 1536, 1920,
19+
)
20+
val LocalMediaConfig = staticCompositionLocalOf<MediaConfig> {
21+
throw IllegalArgumentException("MediaConfig has not been provided")
22+
}

ui/media/src/commonMain/kotlin/com/tunjid/heron/media/images/AsyncImage.kt

Lines changed: 38 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,13 @@ import androidx.compose.ui.layout.Measurable
4040
import androidx.compose.ui.layout.MeasureResult
4141
import androidx.compose.ui.layout.MeasureScope
4242
import androidx.compose.ui.layout.layout
43-
import androidx.compose.ui.platform.LocalWindowInfo
4443
import androidx.compose.ui.unit.Constraints
4544
import androidx.compose.ui.unit.IntSize
4645
import com.tunjid.composables.ui.animate
4746
import com.tunjid.heron.data.files.RestrictedFile
4847
import com.tunjid.heron.data.files.uiDisplayModel
48+
import com.tunjid.heron.media.LocalMediaConfig
49+
import com.tunjid.heron.media.MediaConfig
4950
import com.tunjid.heron.ui.shapes.RoundedPolygonShape
5051
import com.tunjid.heron.ui.shapes.animate
5152
import kotlin.math.min
@@ -107,12 +108,10 @@ data class ImageArgs(
107108

108109
@Stable
109110
class ImageState internal constructor(
110-
args: ImageArgs,
111-
private val imageLoader: ImageLoader,
112-
private val windowSize: () -> IntSize,
111+
internal val args: () -> ImageArgs,
112+
internal val mediaConfig: () -> MediaConfig,
113+
private val imageLoader: () -> ImageLoader,
113114
) {
114-
var args by mutableStateOf(args)
115-
116115
internal var image by mutableStateOf<Image?>(null)
117116
private var layoutSize by mutableStateOf(IntSize.Zero)
118117

@@ -152,7 +151,7 @@ class ImageState internal constructor(
152151
)
153152
.distinctUntilChanged()
154153
.collectLatest { (request, size) ->
155-
imageLoader.fetchImage(
154+
imageLoader().fetchImage(
156155
request = request,
157156
size = size,
158157
)
@@ -161,7 +160,7 @@ class ImageState internal constructor(
161160
}
162161

163162
private fun requests(): Flow<ImageRequest> =
164-
snapshotFlow { args.request }
163+
snapshotFlow { args().request }
165164

166165
private fun fetchSizes(): Flow<IntSize> =
167166
snapshotFlow { layoutSize }
@@ -171,23 +170,23 @@ class ImageState internal constructor(
171170
if (index == 0) 0.milliseconds
172171
else ImageLayoutSizeRefetchDebounce.milliseconds
173172
}
174-
.map { (_, size) -> size.bucketedFetchSize(windowSize()) }
173+
.map { (_, size) -> mediaConfig().bucketedFetchSize(size) }
175174
}
176175

177176
@Composable
178177
fun rememberUpdatedImageState(
179178
args: ImageArgs,
180179
): ImageState {
181-
val imageLoader = LocalImageLoader.current
182-
val windowSize = rememberUpdatedState(LocalWindowInfo.current.containerSize)
183-
return remember(imageLoader) {
180+
val updatedArgs = rememberUpdatedState(args)
181+
val imageLoader = rememberUpdatedState(LocalImageLoader.current)
182+
val mediaConfig = rememberUpdatedState(LocalMediaConfig.current)
183+
return remember {
184184
ImageState(
185-
args = args,
186-
imageLoader = imageLoader,
187-
windowSize = windowSize::value,
185+
mediaConfig = mediaConfig::value,
186+
imageLoader = imageLoader::value,
187+
args = updatedArgs::value,
188188
)
189189
}
190-
.also { it.args = args }
191190
}
192191

193192
fun ImageArgs(
@@ -248,14 +247,14 @@ fun AsyncImage(
248247
""".trimIndent()
249248
}
250249

251-
val contentDescription = state.args.contentDescription
250+
val contentDescription = state.args().contentDescription
252251
val contentScaleState = rememberUpdatedState(
253-
state.args.contentScale.animate(ImageInterpolationSpec),
252+
state.args().contentScale.animate(ImageInterpolationSpec),
254253
)
255254
val alignmentState = rememberUpdatedState(
256-
state.args.alignment.animate(ImageInterpolationSpec),
255+
state.args().alignment.animate(ImageInterpolationSpec),
257256
)
258-
val shape = state.args.shape.animate(ImageInterpolationSpec)
257+
val shape = state.args().shape.animate(ImageInterpolationSpec)
259258

260259
Box(
261260
modifier = modifier
@@ -279,7 +278,9 @@ fun AsyncImage(
279278
contentScale = contentScaleState.value,
280279
)
281280

282-
state.image?.AnimationEffect()
281+
if (state.mediaConfig().autoPlayGifs()) {
282+
state.image?.AnimationEffect()
283+
}
283284
}
284285

285286
val scope = rememberCoroutineScope(
@@ -307,46 +308,31 @@ private val IntSize.isUsable: Boolean
307308
height > IntSize.Zero.height &&
308309
height < Int.MAX_VALUE
309310

310-
private fun IntSize.bucketedFetchSize(
311-
windowSize: IntSize,
311+
private fun MediaConfig.bucketedFetchSize(
312+
size: IntSize,
312313
): IntSize {
313-
val maxWidth = windowSize.width.takeIf { it > 0 } ?: width
314-
val maxHeight = windowSize.height.takeIf { it > 0 } ?: height
314+
val windowSize = windowSize()
315+
val maxWidth = if (windowSize.width > 0) windowSize.width else size.width
316+
val maxHeight = if (windowSize.height > 0) windowSize.height else size.height
315317
return IntSize(
316-
width = min(width, maxWidth)
317-
.roundedUpToDecodeBucket()
318+
width = roundedUpToDecodeBucket(min(size.width, maxWidth))
318319
.coerceAtMost(maxWidth),
319-
height = min(height, maxHeight)
320-
.roundedUpToDecodeBucket()
320+
height = roundedUpToDecodeBucket(min(size.height, maxHeight))
321321
.coerceAtMost(maxHeight),
322322
)
323323
}
324324

325-
private fun Int.roundedUpToDecodeBucket(): Int {
326-
DecodeBucketsPx
327-
// Smallest bucket >= this, via a lower-bound binary search over the ascending buckets. If this is
328-
// larger than every bucket, keep the requested size (never upscale); the window cap still applies.
329-
var low = 0
330-
var high = DecodeBucketsPx.size - 1
331-
var ceiling = this
332-
while (low <= high) {
333-
val mid = (low + high) ushr 1
334-
val bucket = DecodeBucketsPx[mid]
335-
if (bucket >= this) {
336-
ceiling = bucket
337-
high = mid - 1
338-
} else {
339-
low = mid + 1
340-
}
341-
}
342-
return ceiling
325+
private fun MediaConfig.roundedUpToDecodeBucket(
326+
dimension: Int,
327+
): Int {
328+
val currentBuckets = imageSizeBuckets()
329+
val search = currentBuckets.binarySearch(dimension)
330+
val index = if (search >= 0) search else search.inv()
331+
return currentBuckets[
332+
minOf(index, currentBuckets.size - 1),
333+
]
343334
}
344335

345-
private val DecodeBucketsPx = intArrayOf(
346-
96, 128, 256, 320, 480, 640,
347-
720, 1024, 1280, 1536, 1920,
348-
)
349-
350336
private val ImageInterpolationSpec = spring<Float>(
351337
stiffness = Spring.StiffnessLow,
352338
)

ui/scaffold/src/commonMain/kotlin/com/tunjid/heron/ui/scaffold/scaffold/App.kt

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import androidx.compose.material3.Surface
2424
import androidx.compose.runtime.Composable
2525
import androidx.compose.runtime.CompositionLocalProvider
2626
import androidx.compose.runtime.LaunchedEffect
27+
import androidx.compose.runtime.State
2728
import androidx.compose.runtime.getValue
2829
import androidx.compose.runtime.mutableStateOf
2930
import androidx.compose.runtime.remember
@@ -34,13 +35,17 @@ import androidx.compose.ui.Modifier
3435
import androidx.compose.ui.layout.layout
3536
import androidx.compose.ui.platform.LocalDensity
3637
import androidx.compose.ui.platform.LocalWindowInfo
38+
import androidx.compose.ui.unit.Dp
39+
import androidx.compose.ui.unit.IntSize
3740
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
3841
import androidx.navigation3.runtime.NavEntryDecorator
3942
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
4043
import androidx.navigationevent.NavigationEvent
4144
import androidx.navigationevent.NavigationEventTransitionState
4245
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
4346
import com.tunjid.composables.splitlayout.SplitLayout
47+
import com.tunjid.heron.media.LocalMediaConfig
48+
import com.tunjid.heron.media.MediaConfig
4449
import com.tunjid.heron.media.images.LocalImageLoader
4550
import com.tunjid.heron.media.video.LocalVideoPlayerController
4651
import com.tunjid.heron.ui.UiTokens
@@ -109,11 +114,8 @@ fun AppScaffold(
109114
sharedTransitionScope = this,
110115
)
111116
}
112-
val windowWidth = rememberUpdatedState(
113-
with(density) {
114-
LocalWindowInfo.current.containerSize.width.toDp()
115-
},
116-
)
117+
val windowSizeState = windowSizeAsState()
118+
val windowWidthDpState = windowWidthDpAsState(windowSizeState)
117119
if (!sharedElementsCoordinatesSet()) return@SharedTransitionLayout
118120

119121
val saveableStateHolderNavEntryDecorator =
@@ -133,7 +135,7 @@ fun AppScaffold(
133135
tertiaryPaneBreakPoint = mutableStateOf(
134136
UiTokens.TertiaryPaneMinWidthBreakpoint,
135137
),
136-
windowWidthState = windowWidth,
138+
windowWidthState = windowWidthDpState,
137139
),
138140
threePaneMovableSharedElementDecorator(
139141
movableSharedElementHostState,
@@ -165,16 +167,29 @@ fun AppScaffold(
165167
AppScaffoldState(
166168
paneNavigationState = { displayScope.paneNavigationState },
167169
density = density,
168-
windowWidth = windowWidth,
170+
windowWidth = windowWidthDpState,
169171
staticStates = staticStates,
170172
)
171173
}.also {
172174
it.update(
173175
density = density,
174176
)
175177
}
178+
val localMediaConfig = remember {
179+
MediaConfig(
180+
windowSize = windowSizeState::value,
181+
autoPlayGifs = {
182+
// Make sure the static object is captured and read in the lambda
183+
staticStates.identityState
184+
.preferences
185+
?.local
186+
?.autoPlayTimelineGifs ?: true
187+
},
188+
)
189+
}
176190
CompositionLocalProvider(
177191
LocalAppScaffoldState provides appScaffoldState,
192+
LocalMediaConfig provides localMediaConfig,
178193
LocalImageLoader provides staticStates.imageLoader,
179194
LocalVideoPlayerController provides staticStates.videoPlayerController,
180195
) {
@@ -241,6 +256,24 @@ fun AppScaffold(
241256
}
242257
}
243258

259+
@Composable
260+
private fun windowSizeAsState(): State<IntSize> =
261+
rememberUpdatedState(
262+
LocalWindowInfo.current.containerSize,
263+
)
264+
265+
@Composable
266+
private fun windowWidthDpAsState(
267+
sizeState: State<IntSize>,
268+
): State<Dp> {
269+
val density = LocalDensity.current
270+
return rememberUpdatedState(
271+
with(density) {
272+
sizeState.value.width.toDp()
273+
},
274+
)
275+
}
276+
244277
@Composable
245278
private fun sharedElementsCoordinatesSet(): Boolean {
246279
var coordinatesSet by remember {

0 commit comments

Comments
 (0)