Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package tech.idct.clipra.core.model.edit

import tech.idct.clipra.core.model.LinkGroup
import tech.idct.clipra.core.model.Project

// The Linked tool (#184): clips linked move and get cut together; these make and break links.

/**
* Links clip [clipId] with [otherId], and so with the clips either is linked with already: they
* move and get cut together from now on. One undo step.
*/
class LinkClips(
private val clipId: String,
private val otherId: String,
private val newId: () -> String = ::randomId,
) : EditCommand {
override fun apply(project: Project): Project {
project.clipWithLayer(clipId)
project.clipWithLayer(otherId)
if (clipId == otherId) throw EditRejected(EditRejected.Reason.OutOfRange)
val groups = project.links.filter { clipId in it.clipIds || otherId in it.clipIds }
val ids = (groups.flatMap { it.clipIds } + clipId + otherId).distinct()
val group = LinkGroup(groups.firstOrNull()?.id ?: newId(), ids)
return project.copy(links = project.links.filterNot { it in groups } + group)
}
}

/** Takes [clipId] out of its links: it moves and gets cut on its own. One undo step. */
data class UnlinkClip(val clipId: String) : EditCommand {
override fun apply(project: Project): Project {
project.clipWithLayer(clipId)
return project.copy(
links = project.links.map { it.copy(clipIds = it.clipIds - clipId) }
.filter { it.clipIds.size > 1 },
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package tech.idct.clipra.core.model.edit

import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import tech.idct.clipra.core.model.Clip
import tech.idct.clipra.core.model.ClipType
import tech.idct.clipra.core.model.Layer
import tech.idct.clipra.core.model.LinkGroup
import tech.idct.clipra.core.model.MediaRef
import tech.idct.clipra.core.model.Project

/** The Linked tool (#184): linking and unlinking clips, which then move apart or together. */
class LinkCommandsTest {
private val sec = 1_000_000L

private fun clip(id: String, start: Long, type: ClipType = ClipType.Video) =
Clip(id, type, start, 2 * sec, MediaRef("m", 0, 2 * sec))

// L1: a video and its detached sound (linked) on L2; a music clip on L3, on its own.
private val project = Project(
id = "p",
name = "p",
layers = listOf(
Layer("L1", clips = listOf(clip("video", 0))),
Layer("L2", clips = listOf(clip("sound", 0, ClipType.Audio))),
Layer("L3", clips = listOf(clip("music", 4 * sec, ClipType.Audio))),
),
links = listOf(LinkGroup("g", listOf("video", "sound"))),
)

private fun applyAndCheckInverse(command: EditCommand, from: Project = project): Project {
val history = EditHistory(from)
val edited = history.apply(command)
assertEquals(from, history.undo())
assertEquals(edited, history.redo())
return edited
}

@Test
fun anUnlinkedClipMovesOnItsOwn() {
val unlinked = applyAndCheckInverse(UnlinkClip("sound"))
assertTrue(unlinked.links.isEmpty())
val moved = MoveClip("video", 1 * sec).apply(unlinked)
assertEquals(0L, moved.findClip("sound")!!.second.startUs)
}

@Test
fun linkingJoinsTheClipsEitherIsLinkedWith() {
val linked = applyAndCheckInverse(LinkClips("music", "video", newId = { "new" }))
// One group, the existing one, with all three.
assertEquals(1, linked.links.size)
assertEquals("g", linked.links.single().id)
assertEquals(setOf("video", "sound", "music"), linked.links.single().clipIds.toSet())
val moved = MoveClip("video", 1 * sec).apply(linked)
assertEquals(5 * sec, moved.findClip("music")!!.second.startUs)

// Two clips linked with nothing make a new group.
val alone = UnlinkClip("sound").apply(project)
val paired = LinkClips("music", "sound", newId = { "new" }).apply(alone)
assertEquals(listOf(LinkGroup("new", listOf("music", "sound"))), paired.links)
assertThrows(EditRejected::class.java) { LinkClips("video", "video").apply(project) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ private fun EditorRoute(
}
},
onExtract = viewModel::extractAudio,
onLinked = viewModel::toggleLinked,
onTool = { tool ->
when (tool) {
EditorTool.Layer -> openBoard(LayerTab.Blend, false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ class EditorCallbacks(
/** Plays the selected clip backwards, or forwards again (#22). */
val onReverse: () -> Unit = {},
val onExtract: () -> Unit,
/** Linked (#184): unlinks the selected clip, or links it with the clip tapped next. */
val onLinked: () -> Unit = {},
val onLayers: () -> Unit,
val onCrop: () -> Unit,
val onDismissMessage: () -> Unit,
Expand Down Expand Up @@ -402,10 +404,7 @@ private fun mainTools(callbacks: EditorCallbacks, onAdd: () -> Unit): List<Tool>
)
}

/**
* With a clip selected. Linked joins when its issue lands (M7), so every tool shown does
* something.
*/
/** With a clip selected, in the Editor board's order. */
@Composable
private fun clipTools(callbacks: EditorCallbacks): List<Tool> {
val open = callbacks.onTool
Expand All @@ -422,6 +421,7 @@ private fun clipTools(callbacks: EditorCallbacks): List<Tool> {
tool(R.string.editor_tool_color, ClipraIcons.Color) { open(EditorTool.Color) },
tool(R.string.editor_tool_cutout, ClipraIcons.Cutout) { open(EditorTool.Cutout) },
tool(R.string.editor_tool_stabilize, ClipraIcons.Stabilize) { open(EditorTool.Stabilize) },
tool(R.string.editor_tool_linked, ClipraIcons.Link, callbacks.onLinked),
tool(R.string.editor_tool_duplicate, ClipraIcons.Duplicate, callbacks.onDuplicate),
tool(R.string.editor_tool_delete, ClipraIcons.Delete, callbacks.onDelete),
tool(R.string.editor_tool_crop, ClipraIcons.Crop, callbacks.onCrop),
Expand All @@ -442,6 +442,8 @@ internal fun EditorMessage.textRes(): Int = when (this) {
EditorMessage.NeedsCut -> R.string.editor_message_needs_cut
EditorMessage.NeedsSound -> R.string.editor_message_needs_sound
EditorMessage.NeedsMicrophone -> R.string.editor_message_needs_microphone
EditorMessage.PickClipToLink -> R.string.editor_message_pick_link
EditorMessage.Unlinked -> R.string.editor_message_unlinked
EditorMessage.MicrophoneUnavailable -> R.string.editor_message_mic_unavailable
EditorMessage.NotSaved -> R.string.editor_message_not_saved
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import tech.idct.clipra.core.model.edit.ExtractAudio
import tech.idct.clipra.core.model.edit.FreezeFrame
import tech.idct.clipra.core.model.edit.KeyframeTarget
import tech.idct.clipra.core.model.edit.LayerTarget
import tech.idct.clipra.core.model.edit.LinkClips
import tech.idct.clipra.core.model.edit.MoveClip
import tech.idct.clipra.core.model.edit.MoveKeyframe
import tech.idct.clipra.core.model.edit.MoveLayer
Expand All @@ -96,6 +97,8 @@ import tech.idct.clipra.core.model.edit.SetTransform
import tech.idct.clipra.core.model.edit.SplitClip
import tech.idct.clipra.core.model.edit.ToggleKeyframe
import tech.idct.clipra.core.model.edit.TrimClip
import tech.idct.clipra.core.model.edit.UnlinkClip
import tech.idct.clipra.core.model.edit.linkedPartners
import tech.idct.clipra.core.model.edit.randomId
import tech.idct.clipra.core.model.mediaTimeAt
import tech.idct.clipra.core.model.timelineAt
Expand All @@ -122,6 +125,8 @@ data class EditorUiState(
/** The tablet toolbar's Snap and Ripple (#69): moves snap to cuts; deleting closes the gap. */
val snap: Boolean = true,
val ripple: Boolean = true,
/** Linked (#184) was tapped on this clip, which has no links: the next clip tapped joins it. */
val linkingFrom: String? = null,
)

/**
Expand All @@ -148,6 +153,12 @@ enum class EditorMessage {
NeedsMicrophone,
MicrophoneUnavailable,

/** Linked (#184) on a clip with no links: the clip tapped next is linked with it. */
PickClipToLink,

/** Linked on a linked clip: it moves and gets cut on its own now. */
Unlinked,

/** Edits couldn't be saved, as when the phone's storage is full; they are tried again. */
NotSaved,
}
Expand Down Expand Up @@ -231,7 +242,38 @@ class EditorViewModel @Inject constructor(
}
}

fun select(clipId: String?) = mutableState.update { it.copy(selectedClipId = clipId) }
fun select(clipId: String?) {
val from = state.value.linkingFrom
if (from != null) {
// Linked (#184): the clip tapped after it is linked with it; anything else lets go.
mutableState.update { it.copy(linkingFrom = null) }
if (clipId != null && clipId != from) {
edit(LinkClips(from, clipId))
mutableState.update { it.copy(selectedClipId = from) }
return
}
}
mutableState.update { it.copy(selectedClipId = clipId) }
}

/**
* Linked (#184): the selected clip, when linked, goes its own way; when not, the clip tapped
* next is linked with it.
*/
fun toggleLinked() {
val clipId = state.value.selectedClipId ?: return
val project = state.value.project ?: return
if (project.linkedPartners(clipId).isNotEmpty()) {
edit(UnlinkClip(clipId))
if (state.value.message == null) {
mutableState.update { it.copy(message = EditorMessage.Unlinked) }
}
} else {
mutableState.update {
it.copy(linkingFrom = clipId, message = EditorMessage.PickClipToLink)
}
}
}

fun seek(timeUs: Long) {
val end = state.value.project?.durationUs ?: 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import tech.idct.clipra.core.designsystem.component.IconAction
import tech.idct.clipra.core.designsystem.icon.ClipraIcons
import tech.idct.clipra.core.model.Clip
import tech.idct.clipra.core.model.ClipType
import tech.idct.clipra.core.model.edit.linkedPartners
import tech.idct.clipra.feature.editor.EditorCallbacks
import tech.idct.clipra.feature.editor.EditorUiState
import tech.idct.clipra.feature.editor.R
Expand Down Expand Up @@ -93,6 +94,16 @@ internal fun TabletToolbar(
video,
callbacks.onExtract,
)
// Lit while the selected clip is linked (#184, TabletEditor board).
val linked = selected != null &&
state.project?.linkedPartners(selected.id)?.isNotEmpty() == true
ToolButton(
R.string.editor_tool_linked,
ClipraIcons.Link,
clip,
callbacks.onLinked,
on = linked,
)
ToolButton(R.string.editor_layer_add, ClipraIcons.Layers, true, actions.onAddLayer)
ToolButton(
R.string.editor_add_adjustment,
Expand Down Expand Up @@ -126,20 +137,37 @@ internal fun TabletToolbar(
}
}

/** A tool; [on] lights it (Linked while the clip is linked) and tells TalkBack so. */
@Composable
private fun ToolButton(label: Int, icon: Int, enabled: Boolean, onClick: () -> Unit) {
private fun ToolButton(
label: Int,
icon: Int,
enabled: Boolean,
onClick: () -> Unit,
on: Boolean? = null,
) {
val colors = ClipraTheme.colors
val text = stringResource(label)
val state = on?.let {
stringResource(if (it) R.string.editor_tablet_on else R.string.editor_tablet_off)
}
Surface(
onClick = onClick,
enabled = enabled,
modifier = Modifier
.height(44.dp)
.semantics { role = Role.Button }
.semantics {
role = Role.Button
if (state != null) stateDescription = state
}
.testTag("tool:$text"),
shape = RoundedCornerShape(10.dp),
color = Color.Transparent,
contentColor = if (enabled) colors.textPrimary else colors.textDisabled,
contentColor = when {
!enabled -> colors.textDisabled
on == true -> colors.accent
else -> colors.textPrimary
},
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp),
Expand Down
4 changes: 4 additions & 0 deletions feature/editor/src/main/res/values-pl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -276,4 +276,8 @@
<string name="editor_tablet_sound_of">Dźwięk %1$s</string>
<string name="editor_tablet_sound_recorded">nagrane</string>
<string name="editor_tablet_ai_clean">AI czysty</string>
<!-- The Linked tool (#184) -->
<string name="editor_tool_linked">Połączone</string>
<string name="editor_message_pick_link">Stuknij klip, z którym go połączyć</string>
<string name="editor_message_unlinked">Rozłączono: klip przesuwa się teraz osobno</string>
</resources>
4 changes: 4 additions & 0 deletions feature/editor/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,8 @@
<string name="editor_tablet_sound_of">%1$s audio</string>
<string name="editor_tablet_sound_recorded">recorded</string>
<string name="editor_tablet_ai_clean">AI clean</string>
<!-- The Linked tool (#184) -->
<string name="editor_tool_linked">Linked</string>
<string name="editor_message_pick_link">Tap the clip to link it with</string>
<string name="editor_message_unlinked">Unlinked: the clip moves on its own now</string>
</resources>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.