From 0eabeb0556d247d5dedcecc6adf99348422a0ab0 Mon Sep 17 00:00:00 2001 From: Max Jakobitsch Date: Tue, 25 Feb 2025 00:28:16 +0100 Subject: [PATCH] Allow cutting milliseconds --- .../downloadcard/CutVideoBottomSheetDialog.kt | 260 ++++++++++-------- .../com/deniscerri/ytdl/util/Extensions.kt | 71 ++--- 2 files changed, 181 insertions(+), 150 deletions(-) diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/CutVideoBottomSheetDialog.kt b/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/CutVideoBottomSheetDialog.kt index 8de5cfbb..1f465894 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/CutVideoBottomSheetDialog.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/CutVideoBottomSheetDialog.kt @@ -13,7 +13,12 @@ import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.View -import android.widget.* +import android.view.inputmethod.EditorInfo +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.children import androidx.core.view.isVisible @@ -30,12 +35,12 @@ import androidx.preference.PreferenceManager import com.deniscerri.ytdl.R import com.deniscerri.ytdl.database.models.ChapterItem import com.deniscerri.ytdl.database.models.DownloadItem -import com.deniscerri.ytdl.database.viewmodel.CommandTemplateViewModel import com.deniscerri.ytdl.database.viewmodel.ResultViewModel import com.deniscerri.ytdl.util.Extensions.convertToTimestamp import com.deniscerri.ytdl.util.Extensions.setTextAndRecalculateWidth import com.deniscerri.ytdl.util.Extensions.toStringDuration import com.deniscerri.ytdl.util.Extensions.toStringTimeStamp +import com.deniscerri.ytdl.util.Extensions.tryConvertToTimestamp import com.deniscerri.ytdl.util.UiUtil import com.deniscerri.ytdl.util.VideoPlayerUtil import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -71,8 +76,8 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private private lateinit var forwardBtn : MaterialButton private lateinit var muteBtn : MaterialButton private lateinit var rangeSlider : RangeSlider - private lateinit var fromTextInput : EditText - private lateinit var toTextInput : EditText + private lateinit var startTextInput : EditText + private lateinit var endTextInput : EditText private lateinit var cancelBtn : Button private lateinit var okBtn : Button private lateinit var forceKeyframes: MaterialSwitch @@ -89,6 +94,9 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private private var itemDurationTimestamp = 0L private lateinit var selectedCuts: MutableList + private var startTimestamp = 0L + private var endTimestamp = 0L + override fun onCreate(savedInstanceState: Bundle?) { resultViewModel = ViewModelProvider(this)[ResultViewModel::class.java] super.onCreate(savedInstanceState) @@ -135,10 +143,18 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private forwardBtn = view.findViewById(R.id.forward) muteBtn = view.findViewById(R.id.mute) rangeSlider = view.findViewById(R.id.rangeSlider) - fromTextInput = view.findViewById(R.id.from_textinput_edittext) - fromTextInput.keyListener = DigitsKeyListener.getInstance("0123456789:.") - toTextInput = view.findViewById(R.id.to_textinput_edittext) - toTextInput.keyListener = DigitsKeyListener.getInstance("0123456789:.") + + startTextInput = view.findViewById(R.id.from_textinput_edittext) + startTextInput.keyListener = DigitsKeyListener.getInstance("0123456789:.") + startTextInput.imeOptions = EditorInfo.IME_ACTION_DONE + startTextInput.inputType = EditorInfo.TYPE_NUMBER_FLAG_DECIMAL + startTextInput.maxLines = 1 + endTextInput = view.findViewById(R.id.to_textinput_edittext) + endTextInput.keyListener = DigitsKeyListener.getInstance("0123456789:.") + endTextInput.imeOptions = EditorInfo.IME_ACTION_DONE + endTextInput.inputType = EditorInfo.TYPE_NUMBER_FLAG_DECIMAL + endTextInput.maxLines = 1 + cancelBtn = view.findViewById(R.id.cancelButton) okBtn = view.findViewById(R.id.okButton) suggestedChips = view.findViewById(R.id.chapters) @@ -211,14 +227,12 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private } } //poll video progress + val pollProgressInterval = 200L lifecycleScope.launch { - videoProgress(player).collect { p -> - val currentTime = p.toStringDuration(Locale.US) - durationText.text = "$currentTime / ${item.duration}" - val startTimestamp = fromTextInput.text.toString().convertToTimestamp() - if (toTextInput.text.isNotBlank()){ - val endTimestamp = toTextInput.text.toString().convertToTimestamp() - if (p >= endTimestamp / 1000 || (!player.isPlaying && p >= endTimestamp / 1000 - 1)){ + videoProgress(player, pollProgressInterval).collect { currentTime -> + durationText.text = "${currentTime.toStringTimeStamp()} / ${item.duration}" + if (endTextInput.text.isNotBlank()) { + if (currentTime >= endTimestamp || (!player.isPlaying && currentTime >= endTimestamp - pollProgressInterval)) { player.prepare() player.seekTo(startTimestamp) } @@ -259,7 +273,7 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private rewindBtn.setOnClickListener { try { - val stmp = fromTextInput.text.toString().convertToTimestamp() + val stmp = startTextInput.text.toString().convertToTimestamp() player.seekTo(stmp) player.play() }catch (ignored: Exception) {} @@ -267,10 +281,7 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private forwardBtn.setOnClickListener { kotlin.runCatching { - val startTimestamp = fromTextInput.text.toString().convertToTimestamp() - var endTimestamp = toTextInput.text.toString().convertToTimestamp() - 1500 - if (endTimestamp < startTimestamp) endTimestamp = startTimestamp - player.seekTo(endTimestamp) + player.seekTo(max(startTimestamp, endTimestamp - 1500)) player.play() } } @@ -286,14 +297,44 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private } - @SuppressLint("SetTextI18n", "ClickableViewAccessibility") - private fun initCutSection(){ - fromTextInput.setTextAndRecalculateWidth("0:00") - toTextInput.setTextAndRecalculateWidth(item.duration) + private fun setStartTimestamp( + millis: Long, + updateTextInput: Boolean = true, + updateSlider: Boolean = true + ) { + startTimestamp = millis + if (updateSlider) rangeSlider.setValues(millis.toFloat(), endTimestamp.toFloat()) + if (updateTextInput) { + startTextInput.setTextAndRecalculateWidth(millis.toStringTimeStamp(forceMillis = true)) + } + okBtn.isEnabled = startTimestamp != 0L || endTimestamp != itemDurationTimestamp + } - rangeSlider.valueFrom = 0f - rangeSlider.valueTo = (itemDurationTimestamp / 1000).toFloat() - rangeSlider.setValues(0F, (itemDurationTimestamp / 1000).toFloat()) + private fun setEndTimestamp( + millis: Long, + updateTextInput: Boolean = true, + updateSlider: Boolean = true + ) { + endTimestamp = millis + if (updateSlider) rangeSlider.setValues(startTimestamp.toFloat(), millis.toFloat()) + if (updateTextInput) { + endTextInput.setTextAndRecalculateWidth(millis.toStringTimeStamp(forceMillis = true)) + } + okBtn.isEnabled = startTimestamp != 0L || endTimestamp != itemDurationTimestamp + } + + private fun setCutTimestamps(startTimestamp: Long, endTimestamp: Long) { + setStartTimestamp(startTimestamp) + setEndTimestamp(endTimestamp) + } + + private fun resetCutTimestamps() = setCutTimestamps(0L, itemDurationTimestamp) + + @SuppressLint("SetTextI18n", "ClickableViewAccessibility") + private fun initCutSection() { + rangeSlider.valueFrom = 0F + rangeSlider.valueTo = itemDurationTimestamp.toFloat() + resetCutTimestamps() rangeSlider.setOnTouchListener { _, event -> // Handle touch events here when (event.action) { MotionEvent.ACTION_MOVE -> { @@ -352,77 +393,42 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private // } // } - fromTextInput.setOnKeyListener(object : View.OnKeyListener { - - override fun onKey(p0: View?, keyCode: Int, event: KeyEvent?): Boolean { - if ((event!!.action == KeyEvent.ACTION_DOWN) && - (keyCode == KeyEvent.KEYCODE_ENTER)) { - - var startTimestamp = rangeSlider.valueFrom.toInt() - - fromTextInput.clearFocus() - var timestamp = fromTextInput.text.toString().convertToTimestamp() - val endstamp = toTextInput.text.toString().convertToTimestamp() - - if (timestamp == 0L) { - fromTextInput.setTextAndRecalculateWidth(startTimestamp.toStringDuration(Locale.US)) - }else if (timestamp > endstamp){ - startTimestamp = 0 - timestamp = 0 - fromTextInput.setTextAndRecalculateWidth(startTimestamp.toStringDuration(Locale.US)) - }else{ - fromTextInput.setTextAndRecalculateWidth(fromTextInput.text.toString()) - } - - - rangeSlider.setValues((timestamp / 1000).toFloat(), (endstamp / 1000).toFloat()) - okBtn.isEnabled = timestamp != 0L || endstamp != itemDurationTimestamp - try { - player.seekTo(timestamp) - player.play() - }catch (ignored: Exception) {} - - return true - } - return false + startTextInput.setOnFocusChangeListener { view, hasFocus -> + if (!hasFocus) { + updateFromStartTextInput(startTextInput.text.toString()) } - }) - - - toTextInput.setOnKeyListener(object : View.OnKeyListener { - - override fun onKey(p0: View?, keyCode: Int, event: KeyEvent?): Boolean { - if ((event!!.action == KeyEvent.ACTION_DOWN) && - (keyCode == KeyEvent.KEYCODE_ENTER)) { - - toTextInput.clearFocus() - val timestamp = fromTextInput.text.toString().convertToTimestamp() - var endstamp = toTextInput.text.toString().convertToTimestamp() - - if (endstamp > itemDurationTimestamp){ - endstamp = itemDurationTimestamp - } - if (endstamp == 0L) { - endstamp = itemDurationTimestamp - } - if (endstamp <= timestamp){ - endstamp = timestamp + 1000 - } - - toTextInput.setTextAndRecalculateWidth(endstamp.toStringTimeStamp()) - - rangeSlider.setValues((timestamp / 1000).toFloat(), (endstamp / 1000).toFloat()) - okBtn.isEnabled = timestamp != 0L || endstamp != itemDurationTimestamp - try { - player.seekTo(endstamp - 1500) - player.play() - }catch (ignored: Exception) {} - - return true - } - return false + } + startTextInput.setOnEditorActionListener { view, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_SEARCH || + actionId == EditorInfo.IME_ACTION_DONE || + event != null && + event.action == KeyEvent.ACTION_DOWN && + event.keyCode == KeyEvent.KEYCODE_ENTER + ) { + updateFromStartTextInput(startTextInput.text.toString()) + true + } else { + false } - }) + } + endTextInput.setOnFocusChangeListener { view, hasFocus -> + if (!hasFocus) { + updateFromEndTextInput(endTextInput.text.toString()) + } + } + endTextInput.setOnEditorActionListener { view, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_SEARCH || + actionId == EditorInfo.IME_ACTION_DONE || + event != null && + event.action == KeyEvent.ACTION_DOWN && + event.keyCode == KeyEvent.KEYCODE_ENTER + ) { + updateFromEndTextInput(endTextInput.text.toString()) + true + } else { + false + } + } cancelBtn.setOnClickListener { if (chipGroup.childCount == 0){ @@ -437,7 +443,11 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private okBtn.isEnabled = false okBtn.setOnClickListener { forceKeyframes.isVisible = true - val chip = createChip("${fromTextInput.text}-${toTextInput.text}") + updateFromStartTextInput(startTextInput.text.toString()) + updateFromEndTextInput(endTextInput.text.toString()) + val chip = createChip( + startTimestamp.toStringTimeStamp(showMillisIfNonZero = true) + + "-${endTimestamp.toStringTimeStamp(showMillisIfNonZero = true)}") chip.performClick() cutSection.visibility = View.GONE cutListSection.visibility = View.VISIBLE @@ -446,26 +456,41 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private populateSuggestedChapters() } - private fun updateFromSlider(){ - val values = rangeSlider.values - val startTimestamp = values[0].toLong() * 1000 - val endTimestamp = values[1].toLong() * 1000 + private fun updateFromStartTextInput(text: String) { + val timestamp = text.tryConvertToTimestamp() + if (timestamp == null) { + startTextInput.setTextAndRecalculateWidth(startTimestamp.toStringTimeStamp(forceMillis = true)) + } else if (timestamp != startTimestamp) { + setStartTimestamp(timestamp, updateTextInput = false) + player.seekTo(timestamp) + player.play() + } + } - val startTimestampString = startTimestamp.toStringTimeStamp() - val endTimestampString = endTimestamp.toStringTimeStamp() + private fun updateFromEndTextInput(text: String) { + val timestamp = text.tryConvertToTimestamp() + if (timestamp == null) { + endTextInput.setTextAndRecalculateWidth(endTimestamp.toStringTimeStamp(forceMillis = true)) + } else if (timestamp != endTimestamp) { + setEndTimestamp(timestamp, updateTextInput = false) + player.seekTo(timestamp-1500) + player.play() + } + } + private fun updateFromSlider() { val draggedFromBeginning = rangeSlider.focusedThumbIndex != 1 - fromTextInput.setTextAndRecalculateWidth(startTimestampString) - toTextInput.setTextAndRecalculateWidth(endTimestampString) - - - okBtn.isEnabled = values[0] != 0F || values[1] != (itemDurationTimestamp / 1000).toFloat() + if (draggedFromBeginning) { + setStartTimestamp(rangeSlider.values[0].toLong(), updateSlider = false) + } else { + setEndTimestamp(rangeSlider.values[1].toLong(), updateSlider = false) + } try { - if (draggedFromBeginning){ + if (draggedFromBeginning) { player.seekTo(startTimestamp) - }else{ + } else { player.seekTo(max(startTimestamp, endTimestamp - 1500)) } player.play() @@ -509,7 +534,7 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private newCutBtn.setOnClickListener { cutSection.visibility = View.VISIBLE cutListSection.visibility = View.GONE - rangeSlider.setValues(0F, ( itemDurationTimestamp / 1000).toFloat()) + resetCutTimestamps() player.seekTo(0) suggestedChips.children.apply { this.forEach { @@ -551,11 +576,12 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private chip.setOnClickListener { if (chip.isChecked) { - rangeSlider.setValues((startTimestamp / 1000).toFloat(), (endTimestamp / 1000).toFloat()) + setCutTimestamps(startTimestamp, endTimestamp) player.prepare() player.seekTo(startTimestamp) player.play() }else { + // TODO reset timestamps? player.seekTo(0) player.pause() } @@ -635,11 +661,13 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private return chip } - - private fun videoProgress(player: ExoPlayer?) = flow { + /** + * Emits current video timestamp (in milliseconds) every `interval` milliseconds. + */ + private fun videoProgress(player: ExoPlayer?, interval: Long = 200) = flow { while (true) { - emit((player!!.currentPosition / 1000).toInt()) - delay(1000) + emit(player!!.currentPosition) + delay(interval) } }.flowOn(Dispatchers.Main) diff --git a/app/src/main/java/com/deniscerri/ytdl/util/Extensions.kt b/app/src/main/java/com/deniscerri/ytdl/util/Extensions.kt index 5c944250..0dd2e5e3 100644 --- a/app/src/main/java/com/deniscerri/ytdl/util/Extensions.kt +++ b/app/src/main/java/com/deniscerri/ytdl/util/Extensions.kt @@ -7,7 +7,6 @@ import android.content.Context import android.content.res.Resources import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Color import android.graphics.Outline import android.graphics.Paint import android.graphics.PorterDuff @@ -65,6 +64,11 @@ import java.util.Calendar import java.util.Locale import java.util.regex.Pattern import kotlin.math.abs +import kotlin.math.pow +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds object Extensions { @@ -346,45 +350,44 @@ object Extensions { ) } - fun Long.toStringTimeStamp() : String { - var tmp = this - val millis = ((tmp % 1000) / 100).toInt() - tmp /= 1000 - val hours = (tmp / 3600).toInt() - tmp %= 3600 - val minutes = (tmp / 60).toInt() - tmp %= 60 - val seconds = tmp.toInt() + fun Long.toStringTimeStamp(forceMillis: Boolean = false, showMillisIfNonZero : Boolean = false): String { + return this.milliseconds.toComponents { hours, minutes, seconds, nanoseconds -> + buildString { + if (hours > 0) { + append(hours) + append(':') + } + append(minutes.toString().padStart(if (hours > 0) 2 else 1, '0')) + append(':') + append(seconds.toString().padStart(2, '0')) - var res = "${minutes.toString().padStart(if (hours > 0) 2 else 1, '0')}:${seconds.toString().padStart(2, '0')}" - if (hours > 0){ - res = "${hours}:" + res + val millis = nanoseconds / 1_000_000 + if (forceMillis || showMillisIfNonZero && millis > 0) { + append('.') + var millisString = millis.toString().padStart(3, '0') + if (showMillisIfNonZero) millisString = millisString.trimEnd('0') + append(millisString) + } + } } - if (millis > 0){ - res += ".${millis}" - } - - return res } - fun String.convertToTimestamp() : Long { - return try { - val timeArray = this.split(":") - val secondsMillis = timeArray[timeArray.lastIndex] - var timeSeconds = secondsMillis.split(".")[0].toLong() - val millis = kotlin.runCatching { secondsMillis.split(".")[1].toInt() }.getOrElse { 0 } + fun String.convertToTimestamp(): Long = + kotlin.runCatching { tryConvertToTimestamp() }.getOrNull() ?: 0L - var times = 60 - for (i in timeArray.lastIndex - 1 downTo 0) { - timeSeconds += timeArray[i].toInt() * times - times *= 60 - } + private val timeRegex = + Regex("""^(?:(?:(?\d+):)?(?\d{1,2}):)?(?\d+)(?:\.(?\d+))?$""") - (timeSeconds * 1000) + millis * 100 - }catch (e: Exception){ - e.printStackTrace() - 0L - } + fun String.tryConvertToTimestamp(): Long? { + val match = timeRegex.matchEntire(this.trim()) ?: return null + + val hours = match.groups["hours"]?.value?.toInt() ?: 0 + val minutes = match.groups["minutes"]?.value?.toInt() ?: 0 + val seconds = match.groups["seconds"]!!.value.toInt() + val millis = match.groups["decimals"]?.value + ?.take(3)?.padEnd(3,'0')?.toInt() ?: 0 + + return (hours.hours + minutes.minutes + seconds.seconds + millis.milliseconds).inWholeMilliseconds } fun String.convertNetscapeToSetCookie(): String {