Skip to content
Open
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
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ android {
applicationId = "io.github.stozo04.openloop"
minSdk = 26
targetSdk = 36
versionCode = 27
versionName = "1.0.27"
versionCode = 28
versionName = "1.0.28"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
Expand Down
59 changes: 53 additions & 6 deletions app/src/main/java/io/github/stozo04/openloop/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.OptIn
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
Expand Down Expand Up @@ -211,6 +212,8 @@ class MainActivity : ComponentActivity() {
// Friendly "That clip's a bit long" dialog (slice 07); held in the ViewModel so it
// survives Activity recreation after the Photo Picker returns.
val showTooLongDialog by viewModel.showImportTooLongDialog.collectAsStateWithLifecycle()
// Its "a bit short" sibling (issue #95 follow-up) — same recreation rationale.
val showTooShortDialog by viewModel.showImportTooShortDialog.collectAsStateWithLifecycle()

// Hoisted out of the (non-composable) collect lambda below — stringResource can only
// be read in a composable scope.
Expand All @@ -221,6 +224,7 @@ class MainActivity : ComponentActivity() {
val reversePreviewForwardMessage = stringResource(R.string.snackbar_reverse_preview_forward)
val reversePreviewReportAction = stringResource(R.string.snackbar_reverse_preview_report_action)
val importFailedMessage = stringResource(R.string.snackbar_import_failed)
val captureTooShortMessage = stringResource(R.string.snackbar_capture_too_short)
val undoAction = stringResource(R.string.undo)
// The "N loops deleted" plural is count-dependent, so we capture resources here (in a
// composable scope) and resolve the quantity string inside the collect lambda below.
Expand Down Expand Up @@ -301,6 +305,15 @@ class MainActivity : ComponentActivity() {
// [OpenLoopViewModel.showImportTooLongDialog] so it survives Activity
// recreation after the Photo Picker closes.
BoomerangEvent.ImportTooLong -> Unit
// Picked clip was too short (issue #95 follow-up): same dialog-over-
// event pattern, driven by [OpenLoopViewModel.showImportTooShortDialog].
BoomerangEvent.ImportTooShort -> Unit
// Capture stopped before the minimum loopable length (issue #95
// follow-up): the ViewModel already discarded the scratch and returned
// to the viewfinder — nudge instead of failing silently.
BoomerangEvent.CaptureTooShort -> snackbarHostState.showSnackbar(
message = captureTooShortMessage,
)
// Loops marked for deletion (Issue #35): show an Undo snackbar. The real
// file delete is deferred — Undo restores the tiles, any other dismissal
// (timeout, swipe, or a superseding delete) commits the delete to disk.
Expand Down Expand Up @@ -367,6 +380,10 @@ class MainActivity : ComponentActivity() {
if (showTooLongDialog) {
ImportTooLongDialog(onDismiss = { viewModel.dismissImportTooLongDialog() })
}
// Friendly "too short" guidance over the gallery (issue #95 follow-up).
if (showTooShortDialog) {
ImportTooShortDialog(onDismiss = { viewModel.dismissImportTooShortDialog() })
}
}
}
}
Expand Down Expand Up @@ -788,13 +805,43 @@ fun PermissionExplanationScreen(

/**
* Friendly "That clip's a bit long" dialog shown when an imported library video exceeds the 30 s
* limit (slice 07). Hand-rolled in the app's neon aesthetic (matching [PermissionExplanationScreen]
* and the gallery overlay) rather than a stock Material3 `AlertDialog`, so it reads as warm guidance,
* not a system error. Acknowledgment-only — the user is already back on the gallery and nothing was
* limit (slice 07). Acknowledgment-only — the user is already back on the gallery and nothing was
* copied; the single "Got it" button just dismisses.
*/
@Composable
fun ImportTooLongDialog(onDismiss: () -> Unit) {
ImportClipLengthDialog(
titleRes = R.string.import_too_long_title,
bodyRes = R.string.import_too_long_body,
onDismiss = onDismiss,
)
}

/**
* The "a bit short" sibling: shown when a picked clip is under the minimum loopable window
* ([io.github.stozo04.openloop.ui.OpenLoopViewModel.MIN_TRIM_DURATION] — issue #95 follow-up).
* Same acknowledgment-only contract as [ImportTooLongDialog].
*/
@Composable
fun ImportTooShortDialog(onDismiss: () -> Unit) {
ImportClipLengthDialog(
titleRes = R.string.import_too_short_title,
bodyRes = R.string.import_too_short_body,
onDismiss = onDismiss,
)
}

/**
* Shared friendly clip-length guidance dialog (too long / too short). Hand-rolled in the app's neon
* aesthetic (matching [PermissionExplanationScreen] and the gallery overlay) rather than a stock
* Material3 `AlertDialog`, so it reads as warm guidance, not a system error.
*/
@Composable
private fun ImportClipLengthDialog(
@StringRes titleRes: Int,
@StringRes bodyRes: Int,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
Expand Down Expand Up @@ -824,7 +871,7 @@ fun ImportTooLongDialog(onDismiss: () -> Unit) {
Spacer(modifier = Modifier.height(20.dp))

Text(
text = stringResource(R.string.import_too_long_title),
text = stringResource(titleRes),
style = MaterialTheme.typography.headlineSmall,
color = Color.White,
textAlign = TextAlign.Center
Expand All @@ -833,7 +880,7 @@ fun ImportTooLongDialog(onDismiss: () -> Unit) {
Spacer(modifier = Modifier.height(12.dp))

Text(
text = stringResource(R.string.import_too_long_body),
text = stringResource(bodyRes),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
Expand All @@ -850,7 +897,7 @@ fun ImportTooLongDialog(onDismiss: () -> Unit) {
.height(48.dp)
) {
Text(
text = stringResource(R.string.import_too_long_button),
text = stringResource(R.string.import_length_dialog_button),
style = MaterialTheme.typography.labelLarge,
color = LimeInk
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ sealed interface BoomerangEvent {
*/
object ImportTooLong : BoomerangEvent

/**
* A picked library video was shorter than the minimum loopable window
* ([OpenLoopViewModel.MIN_TRIM_DURATION]) — issue #95 follow-up. Drives the friendly
* "That clip's a bit short" dialog; nothing is left in flight (rejected pre-copy, or the
* scratch copy was discarded).
*/
object ImportTooShort : BoomerangEvent

/**
* A camera capture finalized shorter than the minimum loopable window, or with no encoded
* frames at all (`ERROR_NO_VALID_DATA`, a tap-and-release) — issue #95 follow-up. The scratch
* is discarded and the user is back on the viewfinder; drives a "record a little longer"
* snackbar instead of a silent return. (The shutter is tap-to-start / tap-to-stop, so the
* copy says "record longer", not "hold".)
*/
object CaptureTooShort : BoomerangEvent

/**
* Importing a picked library video failed for a non-length reason — unreadable/revoked URI, an
* unreadable duration, or a copy I/O error (slice 07). Drives a "Couldn't import that video."
Expand Down Expand Up @@ -322,6 +339,19 @@ class OpenLoopViewModel(
_showImportTooLongDialog.value = false
}

/**
* Friendly "That clip's a bit short" dialog (issue #95 follow-up). Same [StateFlow]-not-event
* rationale as [showImportTooLongDialog]: it must survive Activity recreation after the Photo
* Picker returns.
*/
private val _showImportTooShortDialog = MutableStateFlow(false)
val showImportTooShortDialog: StateFlow<Boolean> = _showImportTooShortDialog.asStateFlow()

/** Dismisses the import-too-short guidance dialog after the user taps "Got it". */
fun dismissImportTooShortDialog() {
_showImportTooShortDialog.value = false
}

/**
* One-shot signal for MainActivity to request [android.Manifest.permission.POST_NOTIFICATIONS]
* on first Save (API 33+). Activity checks grant state before showing the system dialog.
Expand Down Expand Up @@ -375,6 +405,13 @@ class OpenLoopViewModel(
videoStorage.discardScratch(scratch)
activeScratch = null
_uiState.value = OpenLoopUiState.ReadyToCapture
// ERROR_NO_VALID_DATA = the stop landed before a single frame was
// encoded (a tap-and-release). To the user that's "too short", not a
// device failure — say so instead of returning silently (issue #95
// follow-up). Other error codes keep the log-only behavior.
if (event.error == VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA) {
viewModelScope.launch { _events.send(BoomerangEvent.CaptureTooShort) }
}
} else {
// Auto-route straight to the Trim screen (no preview landing pad).
// The scratch stays in cache until the user saves (promote→raw) or discards.
Expand All @@ -383,6 +420,22 @@ class OpenLoopViewModel(
// repo) before routing — never block the main thread (ANDROID_STANDARDS §9).
viewModelScope.launch {
val durationMs = videoStorage.durationOf(outputFile)
// A clip below the minimum trim window would land on the Trim
// screen with pinned handles and a dead SAVE (Lesson 025) — a
// silent dead-end. Discard it and tell the user to hold longer
// instead (issue #95 follow-up). Also swallows an unreadable
// (<= 0) duration, which could never be trimmed either.
if (durationMs < MIN_TRIM_DURATION.inWholeMilliseconds) {
Log.w(
"OpenLoopViewModel",
"Capture finalized below the ${MIN_TRIM_DURATION.inWholeMilliseconds}ms minimum (${durationMs}ms); discarding",
)
videoStorage.discardScratch(scratch)
activeScratch = null
_events.send(BoomerangEvent.CaptureTooShort)
_uiState.value = OpenLoopUiState.ReadyToCapture
return@launch
}
Log.d("OpenLoopViewModel", "Capture finalized (${durationMs}ms): ${outputFile.absolutePath}")
resetEditorTabForNewClip()
_editorState.value = TrimState(
Expand Down Expand Up @@ -513,10 +566,11 @@ class OpenLoopViewModel(
/**
* Result of the Android Photo Picker (launched `VideoOnly` from the gallery). [uri] is the picked
* video, or `null` if the user backed out. On a valid pick we probe the duration *before* copying
* (so a >30 s clip is rejected with a friendly dialog without ever being copied), then copy the
* bytes into a fresh scratch file and enter the existing [OpenLoopUiState.Trim] flow exactly as a
* fresh capture would — the imported clip is just "a scratch that came from the picker." Any I/O
* or unreadable-duration failure routes back to the gallery with a snackbar; never a crash.
* (so a >30 s clip — or one under [MIN_TRIM_DURATION], issue #95 follow-up — is rejected with a
* friendly dialog without ever being copied), then copy the bytes into a fresh scratch file and
* enter the existing [OpenLoopUiState.Trim] flow exactly as a fresh capture would — the imported
* clip is just "a scratch that came from the picker." Any I/O or unreadable-duration failure
* routes back to the gallery with a snackbar; never a crash.
*/
fun onVideoPicked(uri: Uri?) {
if (uri == null) return // user backed out of the picker
Expand All @@ -526,6 +580,10 @@ class OpenLoopViewModel(
when {
// Unreadable duration → we can't enforce the ≤30 s rule, so don't import it.
durationMs <= 0L -> failImport()
// Below the minimum trim window the editor would open with pinned handles and a
// dead SAVE (Lesson 025) — reject with friendly guidance instead, before any copy
// (issue #95 follow-up).
durationMs < MIN_TRIM_DURATION.inWholeMilliseconds -> warnTooShort()
// Enforce the dialog's advertised "up to 30 s" cap LENIENTLY: the small grace
// (IMPORT_DURATION_GRACE_MS) accepts a clip the user thinks is "30 s" but whose
// container duration reads 30.2–30.5 s. The grace only ever makes us *more* permissive
Expand All @@ -546,6 +604,12 @@ class OpenLoopViewModel(
failImport()
return@launch
}
dur < MIN_TRIM_DURATION.inWholeMilliseconds -> {
// Pre-copy probe can over-read; the local copy is authoritative.
videoStorage.discardScratch(scratch)
warnTooShort()
return@launch
}
exceedsImportDurationLimit(dur) -> {
// Pre-copy probe can under-read; enforce the cap again on the local copy.
videoStorage.discardScratch(scratch)
Expand Down Expand Up @@ -589,6 +653,16 @@ class OpenLoopViewModel(
_uiState.value = OpenLoopUiState.Gallery
}

/**
* Picked clip is shorter than [MIN_TRIM_DURATION]: friendly dialog + back to the gallery
* (nothing left in flight — issue #95 follow-up).
*/
private suspend fun warnTooShort() {
_showImportTooShortDialog.value = true
_events.send(BoomerangEvent.ImportTooShort)
_uiState.value = OpenLoopUiState.Gallery
}

/** True when [durationMs] is past the advertised "up to 30 s" import cap (including grace). */
private fun exceedsImportDurationLimit(durationMs: Long): Boolean =
durationMs > (IMPORT_MAX_DURATION + IMPORT_DURATION_GRACE).inWholeMilliseconds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ private fun FilmstripTrimSelector(
valueMs = startMs,
rangeMs = 0f..durationMs.toFloat(),
onSetValueMs = { target ->
val clamped = target.coerceIn(0L, endMs - minGapMs)
val clamped = clampTrimStartMs(target, endMs, minGapMs)
onStartDrag(clamped)
onDragEnd()
},
Expand All @@ -407,7 +407,7 @@ private fun FilmstripTrimSelector(
valueMs = endMs,
rangeMs = 0f..durationMs.toFloat(),
onSetValueMs = { target ->
val clamped = target.coerceIn(startMs + minGapMs, durationMs)
val clamped = clampTrimEndMs(target, startMs, durationMs, minGapMs)
onEndDrag(clamped)
onDragEnd()
},
Expand Down Expand Up @@ -462,14 +462,14 @@ private fun FilmstripTrimSelector(
val targetMs = dragAnchorMs + pxToMs(change.position.x - dragAnchorPx)
when (dragging) {
TrimDragTarget.START -> {
val clamped = targetMs.coerceIn(0L, curEndMs - minGapMs)
val clamped = clampTrimStartMs(targetMs, curEndMs, minGapMs)
if (clamped != curStartMs) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
startDrag(clamped)
}
}
TrimDragTarget.END -> {
val clamped = targetMs.coerceIn(curStartMs + minGapMs, durationMs)
val clamped = clampTrimEndMs(targetMs, curStartMs, durationMs, minGapMs)
if (clamped != curEndMs) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
endDrag(clamped)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.github.stozo04.openloop.ui.components

/**
* Pure clamp math for the trim handles, kept free of Compose/Android imports so it is JVM-testable
* ([io.github.stozo04.openloop.ui.components.TrimHandleMathTest]) — same split as
* `media/BoomerangSequence.kt`.
*
* Kotlin's `coerceIn(min, max)` throws `IllegalArgumentException` when `max < min`, and both trim
* bounds are derived from runtime state: a clip shorter than the minimum trim window (e.g. a 335 ms
* capture vs the 400 ms gap) inverts the range — Crashlytics 7169b499 / issue #95, Lesson 025. Each
* derived bound is therefore clamped to keep the range valid; on a too-short clip both handles pin
* (start at 0, end at the clip duration), matching the disabled NEXT button on the Trim screen.
*/

/** START handle: clamp [targetMs] into `[0, endMs - minGapMs]`, flooring the upper bound at 0. */
internal fun clampTrimStartMs(targetMs: Long, endMs: Long, minGapMs: Long): Long =
targetMs.coerceIn(0L, (endMs - minGapMs).coerceAtLeast(0L))

/**
* END handle: clamp [targetMs] into `[startMs + minGapMs, durationMs]`, capping the lower bound at
* [durationMs].
*/
internal fun clampTrimEndMs(targetMs: Long, startMs: Long, durationMs: Long, minGapMs: Long): Long =
targetMs.coerceIn((startMs + minGapMs).coerceAtMost(durationMs), durationMs)
7 changes: 6 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,10 @@
<string name="snackbar_import_failed">Couldn\'t import that video.</string>
<string name="import_too_long_title">That clip\'s a bit long</string>
<string name="import_too_long_body">OpenLoop makes loops from videos up to 30 seconds. Pick a shorter clip and we\'ll loop it.</string>
<string name="import_too_long_button">Got it</string>
<string name="import_length_dialog_button">Got it</string>

<!-- Clip shorter than the minimum loopable window (issue #95 follow-up) -->
<string name="import_too_short_title">That clip\'s a bit short</string>
<string name="import_too_short_body">OpenLoop needs at least half a second of video to make a loop. Pick a longer clip and we\'ll loop it.</string>
<string name="snackbar_capture_too_short">That was quick! Record a little longer to make a loop.</string>
</resources>
Loading