Skip to content

Commit eee6591

Browse files
authored
Merge pull request #40 from bnorm/bnorm/remember-index
Persist the animatic current index for restarts
2 parents e3bb303 + a593931 commit eee6591

File tree

12 files changed

+253
-148
lines changed

12 files changed

+253
-148
lines changed

storyboard-easel/src/commonMain/kotlin/dev/bnorm/storyboard/easel/Animatic.kt

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,9 @@ fun rememberAnimatic(
1818
val storyboard = remember(storyboard) { storyboard() }
1919
val states = remember(storyboard) { Animatic.buildStates(storyboard) }
2020

21-
var transitionState by remember {
21+
val transitionState = remember(storyboard) {
2222
val initialState = states.find { it.index == initialIndex } ?: states.first()
23-
mutableStateOf(SeekableTransitionState(initialState))
24-
}
25-
26-
remember(storyboard) {
27-
// TODO a little ugly to have this in a remember...
28-
// TODO need to backtrack to the nearest frame value
29-
// Attempt to preserve the index of the previous Storyboard instance when it changes.
30-
val initialState = states.find { it.index == transitionState.currentState.index } ?: states.first()
31-
transitionState = SeekableTransitionState(initialState)
23+
SeekableTransitionState(initialState)
3224
}
3325

3426
val transition = rememberTransition(transitionState, label = "Animatic")

storyboard-easel/src/commonMain/kotlin/dev/bnorm/storyboard/easel/assist/EaselAssistant.kt

Lines changed: 30 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,17 @@ import androidx.compose.ui.graphics.Color
1818
import androidx.compose.ui.input.pointer.PointerIcon
1919
import androidx.compose.ui.input.pointer.pointerHoverIcon
2020
import androidx.compose.ui.unit.dp
21-
import dev.bnorm.storyboard.Storyboard
22-
import dev.bnorm.storyboard.easel.Animatic
23-
import dev.bnorm.storyboard.easel.ScenePreview
21+
import dev.bnorm.storyboard.easel.*
2422
import dev.bnorm.storyboard.easel.internal.QuarteredBox
25-
import dev.bnorm.storyboard.easel.onStoryNavigation
2623
import kotlinx.coroutines.Job
2724
import kotlinx.coroutines.launch
2825

2926
@Composable
3027
fun EaselAssistant(
31-
assistantState: EaselAssistantState,
28+
animatic: Animatic,
29+
captions: SnapshotStateList<Caption>,
3230
modifier: Modifier = Modifier,
3331
) {
34-
val animatic = assistantState.animatic
35-
val captions = assistantState.captions
36-
3732
Surface(
3833
modifier = modifier
3934
.fillMaxSize()
@@ -59,13 +54,12 @@ fun EaselAssistant(
5954
@Composable
6055
private fun CurrentFramePreview(animatic: Animatic, modifier: Modifier = Modifier) {
6156
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
62-
// TODO should "current" actually be target index?
6357
Text("Current Frame", style = MaterialTheme.typography.h5)
6458
Spacer(Modifier.size(8.dp))
6559
Box {
66-
ClickableScenePreview(
67-
animatic.storyboard,
68-
animatic.currentIndex,
60+
Easel(
61+
animatic,
62+
mode = SceneMode.Preview,
6963
// Add padding for the progress indicator.
7064
modifier = Modifier.padding(bottom = 2.dp),
7165
)
@@ -90,17 +84,30 @@ private fun NextFramePreview(animatic: Animatic, modifier: Modifier = Modifier)
9084
animatic.storyboard.indices.getOrNull(i + 1)
9185
}
9286
nextIndex?.let {
93-
ClickableScenePreview(
94-
storyboard = animatic.storyboard,
95-
index = it,
96-
onClick = {
97-
job?.cancel()
98-
job = coroutineScope.launch {
99-
animatic.jumpTo(it)
100-
job = null
101-
}
102-
},
103-
)
87+
// TODO share with StoryboardOverview?
88+
Box(Modifier) {
89+
ScenePreview(
90+
storyboard = animatic.storyboard,
91+
index = it,
92+
)
93+
94+
// Use a Box to overlay the preview so nothing within the preview can be clicked.
95+
// Also gives it a pointer hand.
96+
Box(
97+
modifier = Modifier.matchParentSize()
98+
.pointerHoverIcon(PointerIcon.Hand)
99+
.clickable(
100+
interactionSource = null, indication = null, // disable ripple effect
101+
onClick = {
102+
job?.cancel()
103+
job = coroutineScope.launch {
104+
animatic.jumpTo(it)
105+
job = null
106+
}
107+
}
108+
)
109+
)
110+
}
104111
}
105112
}
106113
}
@@ -125,35 +132,6 @@ private fun Captions(captions: SnapshotStateList<Caption>, modifier: Modifier =
125132
}
126133
}
127134

128-
@Composable
129-
private fun ClickableScenePreview(
130-
storyboard: Storyboard,
131-
index: Storyboard.Index,
132-
onClick: (() -> Unit)? = null,
133-
modifier: Modifier = Modifier,
134-
) {
135-
// TODO share with StoryboardOverview?
136-
Box(modifier) {
137-
ScenePreview(
138-
storyboard = storyboard,
139-
index = index,
140-
)
141-
142-
if (onClick != null) {
143-
Box(
144-
modifier = Modifier.matchParentSize()
145-
.pointerHoverIcon(PointerIcon.Hand)
146-
.clickable(
147-
interactionSource = null, indication = null, // disable ripple effect
148-
onClick = {
149-
onClick.invoke()
150-
}
151-
)
152-
)
153-
}
154-
}
155-
}
156-
157135
@Composable
158136
private fun SceneAnimationProgressIndicator(animatic: Animatic, modifier: Modifier = Modifier) {
159137
Row(modifier) {

storyboard-easel/src/commonMain/kotlin/dev/bnorm/storyboard/easel/assist/EaselAssistantState.kt

Lines changed: 0 additions & 19 deletions
This file was deleted.

storyboard-easel/src/commonMain/kotlin/dev/bnorm/storyboard/easel/internal/QuarteredBox.kt

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import androidx.compose.ui.unit.Constraints
1010
* Places [quarter1] in the top-start quarter box,
1111
* places [quarter2] in either the top-end or bottom-start quarter box,
1212
* and places [remaining] in the remaining space.
13-
* Placement of [quarter2] is determined by the measured height of [quarter1]:
14-
* if the measured height is shorter than the maximum allowed height,
15-
* the quarters are placed vertically, otherwise they are placed horizontally.
13+
* Placement of [quarter2] is determined by the measured height of [quarter1] and [quarter2]:
14+
* if the max measured height is shorter than half the maximum allowed height,
15+
* the quarters are placed horizontally, otherwise they are placed vertically.
1616
*/
1717
@Composable
1818
internal fun QuarteredBox(
@@ -31,6 +31,7 @@ internal fun QuarteredBox(
3131
) { measurables, constraints ->
3232
val quarter1Measurable = measurables[0]
3333
val quarter2Measurable = measurables[1]
34+
val remainingMeasurable = measurables[2]
3435

3536
val quarterBoxHeight = constraints.maxHeight / 2
3637
val quarterBoxWidth = constraints.maxWidth / 2
@@ -42,30 +43,48 @@ internal fun QuarteredBox(
4243
val quarter1Placeable = quarter1Measurable.measure(quarterBoxConstraints)
4344
val quarter2Placeable = quarter2Measurable.measure(quarterBoxConstraints)
4445

45-
val vertical = maxOf(quarter1Placeable.height, quarter2Placeable.height) < quarterBoxConstraints.maxHeight
46-
val remainingConstraints = when {
47-
vertical -> Constraints.fixed(
48-
width = constraints.maxWidth,
49-
height = constraints.maxHeight - quarter1Placeable.height,
50-
)
46+
val quarterMaxHeight = maxOf(quarter1Placeable.height, quarter2Placeable.height)
47+
val quarterMaxWidth = maxOf(quarter1Placeable.width, quarter2Placeable.width)
5148

52-
else -> Constraints.fixed(
53-
width = constraints.maxWidth - quarter1Placeable.width,
54-
height = constraints.maxHeight,
55-
)
56-
}
49+
val heightUsePercent = quarterMaxHeight.toFloat() / quarterBoxConstraints.maxHeight
50+
val verticalUsePercent = quarterMaxWidth.toFloat() / quarterBoxConstraints.maxWidth
51+
when (heightUsePercent <= verticalUsePercent) {
52+
true -> {
53+
// If 'remaining' is placed vertically-relative to 'quarter1' and 'quarter2'.
54+
val remainingPlaceable = remainingMeasurable.measure(
55+
Constraints.fixed(
56+
width = constraints.maxWidth,
57+
height = constraints.maxHeight - quarterMaxHeight,
58+
)
59+
)
5760

58-
val remainingMeasurable = measurables[2]
59-
val remainingPlaceable = remainingMeasurable.measure(remainingConstraints)
61+
layout(
62+
width = maxOf(quarter1Placeable.width + quarter2Placeable.width, remainingPlaceable.width),
63+
height = quarterMaxHeight + remainingPlaceable.height
64+
) {
65+
quarter1Placeable.placeRelative(0, 0)
66+
quarter2Placeable.placeRelative(quarter1Placeable.width, 0)
67+
remainingPlaceable.placeRelative(0, quarterMaxHeight)
68+
}
69+
}
70+
71+
false -> {
72+
// If 'remaining' is placed horizontally-relative to 'quarter1' and 'quarter2'.
73+
val remainingPlaceable = remainingMeasurable.measure(
74+
Constraints.fixed(
75+
width = constraints.maxWidth - quarterMaxWidth,
76+
height = constraints.maxHeight,
77+
)
78+
)
6079

61-
layout(constraints.maxWidth, constraints.maxHeight) {
62-
quarter1Placeable.placeRelative(0, 0)
63-
if (vertical) {
64-
quarter2Placeable.placeRelative(quarter1Placeable.width, 0)
65-
remainingPlaceable.placeRelative(0, quarter1Placeable.height)
66-
} else {
67-
quarter2Placeable.placeRelative(0, quarter1Placeable.height)
68-
remainingPlaceable.placeRelative(quarter1Placeable.width, 0)
80+
layout(
81+
width = quarterMaxWidth + remainingPlaceable.width,
82+
height = maxOf(quarter1Placeable.height + quarter2Placeable.height, remainingPlaceable.height)
83+
) {
84+
quarter1Placeable.placeRelative(0, 0)
85+
quarter2Placeable.placeRelative(0, quarter1Placeable.height)
86+
remainingPlaceable.placeRelative(quarterMaxWidth, 0)
87+
}
6988
}
7089
}
7190
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package dev.bnorm.storyboard.easel
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.LaunchedEffect
5+
import androidx.compose.runtime.getValue
6+
import androidx.compose.runtime.mutableStateOf
7+
import androidx.compose.runtime.remember
8+
import androidx.compose.runtime.setValue
9+
import dev.bnorm.storyboard.Storyboard
10+
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.runInterruptible
12+
import kotlinx.serialization.ExperimentalSerializationApi
13+
import kotlinx.serialization.KSerializer
14+
import kotlinx.serialization.StringFormat
15+
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
16+
import kotlinx.serialization.descriptors.element
17+
import kotlinx.serialization.encoding.*
18+
import kotlinx.serialization.json.Json
19+
import java.io.IOException
20+
import java.nio.file.Path
21+
import java.nio.file.Paths
22+
import java.nio.file.StandardOpenOption.*
23+
import kotlin.io.path.createParentDirectories
24+
import kotlin.io.path.exists
25+
import kotlin.io.path.readText
26+
import kotlin.io.path.writeText
27+
28+
@Composable
29+
fun rememberAnimatic(
30+
fileName: String,
31+
format: StringFormat = Json,
32+
storyboard: () -> Storyboard,
33+
): Animatic {
34+
val animatic = rememberAnimatic { storyboard() }
35+
AnimaticPersistenceEffect(animatic, fileName, format)
36+
return animatic
37+
}
38+
39+
@Composable
40+
fun AnimaticPersistenceEffect(
41+
animatic: Animatic,
42+
fileName: String,
43+
format: StringFormat = Json,
44+
) {
45+
// TODO extract this directory to somewhere as a constant?
46+
val path = Paths.get(".storyboard", "${fileName.lowercase()}.json")
47+
var loaded by remember { mutableStateOf(false) }
48+
LaunchedEffect(Unit) {
49+
val index = runInterruptible(Dispatchers.IO) {
50+
if (path.exists()) {
51+
val text = path.readText()
52+
runCatching { format.decodeFromString(StoryboardIndexSerializer, text) }
53+
.getOrNull()
54+
} else {
55+
path.createParentDirectories()
56+
null
57+
}
58+
}
59+
60+
// TODO is the jump noticeable when starting a desktop easel?
61+
if (index != null) {
62+
val indices = animatic.storyboard.indices
63+
val searchIndex = indices.binarySearch(index)
64+
val jumpIndex = when {
65+
searchIndex >= 0 -> indices[searchIndex]
66+
else -> indices[-searchIndex - 1]
67+
}
68+
animatic.jumpTo(jumpIndex)
69+
}
70+
71+
loaded = true
72+
}
73+
74+
if (loaded) {
75+
LaunchedStoryboardIndexWriter(animatic, path, format)
76+
}
77+
}
78+
79+
@Composable
80+
private fun LaunchedStoryboardIndexWriter(animatic: Animatic, path: Path, format: StringFormat) {
81+
val index = animatic.currentIndex
82+
LaunchedEffect(index, path, format) {
83+
try {
84+
runInterruptible(Dispatchers.IO) {
85+
val text = format.encodeToString(StoryboardIndexSerializer, index)
86+
path.writeText(text, options = arrayOf(WRITE, CREATE, TRUNCATE_EXISTING))
87+
}
88+
} catch (_: IOException) {
89+
}
90+
}
91+
}
92+
93+
94+
@OptIn(ExperimentalSerializationApi::class)
95+
internal object StoryboardIndexSerializer : KSerializer<Storyboard.Index> {
96+
override val descriptor = buildClassSerialDescriptor("StoryboardIndex") {
97+
element<Int>("sceneIndex")
98+
element<Int>("frameIndex")
99+
}
100+
101+
override fun serialize(encoder: Encoder, value: Storyboard.Index) = encoder.encodeStructure(descriptor) {
102+
encodeIntElement(descriptor, 0, value.sceneIndex)
103+
encodeIntElement(descriptor, 1, value.frameIndex)
104+
}
105+
106+
override fun deserialize(decoder: Decoder): Storyboard.Index = decoder.decodeStructure(descriptor) {
107+
var sceneIndex = 0
108+
var frameIndex = 0
109+
if (decodeSequentially()) {
110+
sceneIndex = decodeIntElement(descriptor, 0)
111+
frameIndex = decodeIntElement(descriptor, 1)
112+
} else while (true) {
113+
when (val index = decodeElementIndex(descriptor)) {
114+
0 -> sceneIndex = decodeIntElement(descriptor, 0)
115+
1 -> frameIndex = decodeIntElement(descriptor, 1)
116+
CompositeDecoder.DECODE_DONE -> break
117+
else -> error("Unexpected index: $index")
118+
}
119+
}
120+
Storyboard.Index(sceneIndex, frameIndex)
121+
}
122+
}

storyboard-easel/src/jvmMain/kotlin/dev/bnorm/storyboard/easel/DesktopEasel.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ import dev.bnorm.storyboard.plus
1717

1818
@Composable
1919
fun ApplicationScope.DesktopEasel(
20-
storyboard: () -> Storyboard
20+
animaticFileName: String? = "animatic",
21+
storyboard: () -> Storyboard,
2122
) {
22-
DesktopEasel(rememberAnimatic(storyboard = storyboard))
23+
val animatic = when (animaticFileName) {
24+
null -> rememberAnimatic(storyboard = storyboard)
25+
else -> rememberAnimatic(animaticFileName, storyboard = storyboard)
26+
}
27+
DesktopEasel(animatic)
2328
}
2429

2530
@Composable

0 commit comments

Comments
 (0)