diff --git a/core/model/src/main/kotlin/tech/idct/clipra/core/model/edit/LinkCommands.kt b/core/model/src/main/kotlin/tech/idct/clipra/core/model/edit/LinkCommands.kt new file mode 100644 index 0000000..8f7150f --- /dev/null +++ b/core/model/src/main/kotlin/tech/idct/clipra/core/model/edit/LinkCommands.kt @@ -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 }, + ) + } +} diff --git a/core/model/src/test/kotlin/tech/idct/clipra/core/model/edit/LinkCommandsTest.kt b/core/model/src/test/kotlin/tech/idct/clipra/core/model/edit/LinkCommandsTest.kt new file mode 100644 index 0000000..aa9410b --- /dev/null +++ b/core/model/src/test/kotlin/tech/idct/clipra/core/model/edit/LinkCommandsTest.kt @@ -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) } + } +} diff --git a/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorNavigation.kt b/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorNavigation.kt index 5d804df..b4e5d18 100644 --- a/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorNavigation.kt +++ b/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorNavigation.kt @@ -407,6 +407,7 @@ private fun EditorRoute( } }, onExtract = viewModel::extractAudio, + onLinked = viewModel::toggleLinked, onTool = { tool -> when (tool) { EditorTool.Layer -> openBoard(LayerTab.Blend, false) diff --git a/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorScreen.kt b/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorScreen.kt index 228d966..130b422 100644 --- a/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorScreen.kt +++ b/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorScreen.kt @@ -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, @@ -402,10 +404,7 @@ private fun mainTools(callbacks: EditorCallbacks, onAdd: () -> Unit): List ) } -/** - * 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 { val open = callbacks.onTool @@ -422,6 +421,7 @@ private fun clipTools(callbacks: EditorCallbacks): List { 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), @@ -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 } diff --git a/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorViewModel.kt b/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorViewModel.kt index 02b6a5d..025f676 100644 --- a/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorViewModel.kt +++ b/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/EditorViewModel.kt @@ -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 @@ -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 @@ -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, ) /** @@ -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, } @@ -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 diff --git a/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/tablet/TabletToolbar.kt b/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/tablet/TabletToolbar.kt index 69da023..39fbaca 100644 --- a/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/tablet/TabletToolbar.kt +++ b/feature/editor/src/main/kotlin/tech/idct/clipra/feature/editor/tablet/TabletToolbar.kt @@ -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 @@ -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, @@ -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), diff --git a/feature/editor/src/main/res/values-pl/strings.xml b/feature/editor/src/main/res/values-pl/strings.xml index 28458c8..ebb766b 100644 --- a/feature/editor/src/main/res/values-pl/strings.xml +++ b/feature/editor/src/main/res/values-pl/strings.xml @@ -276,4 +276,8 @@ Dźwięk %1$s nagrane AI czysty + + Połączone + Stuknij klip, z którym go połączyć + Rozłączono: klip przesuwa się teraz osobno diff --git a/feature/editor/src/main/res/values/strings.xml b/feature/editor/src/main/res/values/strings.xml index e5ff206..71b3d6f 100644 --- a/feature/editor/src/main/res/values/strings.xml +++ b/feature/editor/src/main/res/values/strings.xml @@ -272,4 +272,8 @@ %1$s audio recorded AI clean + + Linked + Tap the clip to link it with + Unlinked: the clip moves on its own now diff --git a/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theAudioTab.png b/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theAudioTab.png index 50e4d53..3b7a8ee 100644 Binary files a/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theAudioTab.png and b/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theAudioTab.png differ diff --git a/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theColorTab.png b/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theColorTab.png index 2a6ebde..2913b47 100644 Binary files a/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theColorTab.png and b/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theColorTab.png differ diff --git a/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theEditorInPolish.png b/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theEditorInPolish.png index 3e835ed..db6d7de 100644 Binary files a/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theEditorInPolish.png and b/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theEditorInPolish.png differ diff --git a/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theEditorWithAClipSelected.png b/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theEditorWithAClipSelected.png index bc313e3..c3170c8 100644 Binary files a/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theEditorWithAClipSelected.png and b/feature/editor/src/test/screenshots/tech.idct.clipra.feature.editor.tablet.TabletEditorScreenTest.theEditorWithAClipSelected.png differ