Allow cutting milliseconds

This commit is contained in:
Max Jakobitsch 2025-02-25 00:28:16 +01:00
parent 1a597240fd
commit 0eabeb0556
2 changed files with 181 additions and 150 deletions

View file

@ -13,7 +13,12 @@ import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View 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.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.isVisible import androidx.core.view.isVisible
@ -30,12 +35,12 @@ import androidx.preference.PreferenceManager
import com.deniscerri.ytdl.R import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.database.models.ChapterItem import com.deniscerri.ytdl.database.models.ChapterItem
import com.deniscerri.ytdl.database.models.DownloadItem 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.database.viewmodel.ResultViewModel
import com.deniscerri.ytdl.util.Extensions.convertToTimestamp import com.deniscerri.ytdl.util.Extensions.convertToTimestamp
import com.deniscerri.ytdl.util.Extensions.setTextAndRecalculateWidth import com.deniscerri.ytdl.util.Extensions.setTextAndRecalculateWidth
import com.deniscerri.ytdl.util.Extensions.toStringDuration import com.deniscerri.ytdl.util.Extensions.toStringDuration
import com.deniscerri.ytdl.util.Extensions.toStringTimeStamp 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.UiUtil
import com.deniscerri.ytdl.util.VideoPlayerUtil import com.deniscerri.ytdl.util.VideoPlayerUtil
import com.google.android.material.bottomsheet.BottomSheetBehavior 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 forwardBtn : MaterialButton
private lateinit var muteBtn : MaterialButton private lateinit var muteBtn : MaterialButton
private lateinit var rangeSlider : RangeSlider private lateinit var rangeSlider : RangeSlider
private lateinit var fromTextInput : EditText private lateinit var startTextInput : EditText
private lateinit var toTextInput : EditText private lateinit var endTextInput : EditText
private lateinit var cancelBtn : Button private lateinit var cancelBtn : Button
private lateinit var okBtn : Button private lateinit var okBtn : Button
private lateinit var forceKeyframes: MaterialSwitch private lateinit var forceKeyframes: MaterialSwitch
@ -89,6 +94,9 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
private var itemDurationTimestamp = 0L private var itemDurationTimestamp = 0L
private lateinit var selectedCuts: MutableList<String> private lateinit var selectedCuts: MutableList<String>
private var startTimestamp = 0L
private var endTimestamp = 0L
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
resultViewModel = ViewModelProvider(this)[ResultViewModel::class.java] resultViewModel = ViewModelProvider(this)[ResultViewModel::class.java]
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -135,10 +143,18 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
forwardBtn = view.findViewById(R.id.forward) forwardBtn = view.findViewById(R.id.forward)
muteBtn = view.findViewById(R.id.mute) muteBtn = view.findViewById(R.id.mute)
rangeSlider = view.findViewById(R.id.rangeSlider) rangeSlider = view.findViewById(R.id.rangeSlider)
fromTextInput = view.findViewById(R.id.from_textinput_edittext)
fromTextInput.keyListener = DigitsKeyListener.getInstance("0123456789:.") startTextInput = view.findViewById(R.id.from_textinput_edittext)
toTextInput = view.findViewById(R.id.to_textinput_edittext) startTextInput.keyListener = DigitsKeyListener.getInstance("0123456789:.")
toTextInput.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) cancelBtn = view.findViewById(R.id.cancelButton)
okBtn = view.findViewById(R.id.okButton) okBtn = view.findViewById(R.id.okButton)
suggestedChips = view.findViewById(R.id.chapters) suggestedChips = view.findViewById(R.id.chapters)
@ -211,14 +227,12 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
} }
} }
//poll video progress //poll video progress
val pollProgressInterval = 200L
lifecycleScope.launch { lifecycleScope.launch {
videoProgress(player).collect { p -> videoProgress(player, pollProgressInterval).collect { currentTime ->
val currentTime = p.toStringDuration(Locale.US) durationText.text = "${currentTime.toStringTimeStamp()} / ${item.duration}"
durationText.text = "$currentTime / ${item.duration}" if (endTextInput.text.isNotBlank()) {
val startTimestamp = fromTextInput.text.toString().convertToTimestamp() if (currentTime >= endTimestamp || (!player.isPlaying && currentTime >= endTimestamp - pollProgressInterval)) {
if (toTextInput.text.isNotBlank()){
val endTimestamp = toTextInput.text.toString().convertToTimestamp()
if (p >= endTimestamp / 1000 || (!player.isPlaying && p >= endTimestamp / 1000 - 1)){
player.prepare() player.prepare()
player.seekTo(startTimestamp) player.seekTo(startTimestamp)
} }
@ -259,7 +273,7 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
rewindBtn.setOnClickListener { rewindBtn.setOnClickListener {
try { try {
val stmp = fromTextInput.text.toString().convertToTimestamp() val stmp = startTextInput.text.toString().convertToTimestamp()
player.seekTo(stmp) player.seekTo(stmp)
player.play() player.play()
}catch (ignored: Exception) {} }catch (ignored: Exception) {}
@ -267,10 +281,7 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
forwardBtn.setOnClickListener { forwardBtn.setOnClickListener {
kotlin.runCatching { kotlin.runCatching {
val startTimestamp = fromTextInput.text.toString().convertToTimestamp() player.seekTo(max(startTimestamp, endTimestamp - 1500))
var endTimestamp = toTextInput.text.toString().convertToTimestamp() - 1500
if (endTimestamp < startTimestamp) endTimestamp = startTimestamp
player.seekTo(endTimestamp)
player.play() player.play()
} }
} }
@ -286,14 +297,44 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
} }
@SuppressLint("SetTextI18n", "ClickableViewAccessibility") private fun setStartTimestamp(
private fun initCutSection(){ millis: Long,
fromTextInput.setTextAndRecalculateWidth("0:00") updateTextInput: Boolean = true,
toTextInput.setTextAndRecalculateWidth(item.duration) 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 private fun setEndTimestamp(
rangeSlider.valueTo = (itemDurationTimestamp / 1000).toFloat() millis: Long,
rangeSlider.setValues(0F, (itemDurationTimestamp / 1000).toFloat()) 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 rangeSlider.setOnTouchListener { _, event -> // Handle touch events here
when (event.action) { when (event.action) {
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
@ -352,77 +393,42 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
// } // }
// } // }
fromTextInput.setOnKeyListener(object : View.OnKeyListener { startTextInput.setOnFocusChangeListener { view, hasFocus ->
if (!hasFocus) {
override fun onKey(p0: View?, keyCode: Int, event: KeyEvent?): Boolean { updateFromStartTextInput(startTextInput.text.toString())
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.setOnEditorActionListener { view, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH ||
toTextInput.setOnKeyListener(object : View.OnKeyListener { actionId == EditorInfo.IME_ACTION_DONE ||
event != null &&
override fun onKey(p0: View?, keyCode: Int, event: KeyEvent?): Boolean { event.action == KeyEvent.ACTION_DOWN &&
if ((event!!.action == KeyEvent.ACTION_DOWN) && event.keyCode == KeyEvent.KEYCODE_ENTER
(keyCode == KeyEvent.KEYCODE_ENTER)) { ) {
updateFromStartTextInput(startTextInput.text.toString())
toTextInput.clearFocus() true
val timestamp = fromTextInput.text.toString().convertToTimestamp() } else {
var endstamp = toTextInput.text.toString().convertToTimestamp() false
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
} }
}) }
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 { cancelBtn.setOnClickListener {
if (chipGroup.childCount == 0){ if (chipGroup.childCount == 0){
@ -437,7 +443,11 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
okBtn.isEnabled = false okBtn.isEnabled = false
okBtn.setOnClickListener { okBtn.setOnClickListener {
forceKeyframes.isVisible = true 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() chip.performClick()
cutSection.visibility = View.GONE cutSection.visibility = View.GONE
cutListSection.visibility = View.VISIBLE cutListSection.visibility = View.VISIBLE
@ -446,26 +456,41 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
populateSuggestedChapters() populateSuggestedChapters()
} }
private fun updateFromSlider(){ private fun updateFromStartTextInput(text: String) {
val values = rangeSlider.values val timestamp = text.tryConvertToTimestamp()
val startTimestamp = values[0].toLong() * 1000 if (timestamp == null) {
val endTimestamp = values[1].toLong() * 1000 startTextInput.setTextAndRecalculateWidth(startTimestamp.toStringTimeStamp(forceMillis = true))
} else if (timestamp != startTimestamp) {
setStartTimestamp(timestamp, updateTextInput = false)
player.seekTo(timestamp)
player.play()
}
}
val startTimestampString = startTimestamp.toStringTimeStamp() private fun updateFromEndTextInput(text: String) {
val endTimestampString = endTimestamp.toStringTimeStamp() 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 val draggedFromBeginning = rangeSlider.focusedThumbIndex != 1
fromTextInput.setTextAndRecalculateWidth(startTimestampString) if (draggedFromBeginning) {
toTextInput.setTextAndRecalculateWidth(endTimestampString) setStartTimestamp(rangeSlider.values[0].toLong(), updateSlider = false)
} else {
setEndTimestamp(rangeSlider.values[1].toLong(), updateSlider = false)
okBtn.isEnabled = values[0] != 0F || values[1] != (itemDurationTimestamp / 1000).toFloat() }
try { try {
if (draggedFromBeginning){ if (draggedFromBeginning) {
player.seekTo(startTimestamp) player.seekTo(startTimestamp)
}else{ } else {
player.seekTo(max(startTimestamp, endTimestamp - 1500)) player.seekTo(max(startTimestamp, endTimestamp - 1500))
} }
player.play() player.play()
@ -509,7 +534,7 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
newCutBtn.setOnClickListener { newCutBtn.setOnClickListener {
cutSection.visibility = View.VISIBLE cutSection.visibility = View.VISIBLE
cutListSection.visibility = View.GONE cutListSection.visibility = View.GONE
rangeSlider.setValues(0F, ( itemDurationTimestamp / 1000).toFloat()) resetCutTimestamps()
player.seekTo(0) player.seekTo(0)
suggestedChips.children.apply { suggestedChips.children.apply {
this.forEach { this.forEach {
@ -551,11 +576,12 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
chip.setOnClickListener { chip.setOnClickListener {
if (chip.isChecked) { if (chip.isChecked) {
rangeSlider.setValues((startTimestamp / 1000).toFloat(), (endTimestamp / 1000).toFloat()) setCutTimestamps(startTimestamp, endTimestamp)
player.prepare() player.prepare()
player.seekTo(startTimestamp) player.seekTo(startTimestamp)
player.play() player.play()
}else { }else {
// TODO reset timestamps?
player.seekTo(0) player.seekTo(0)
player.pause() player.pause()
} }
@ -635,11 +661,13 @@ class CutVideoBottomSheetDialog(private val _item: DownloadItem? = null, private
return chip 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) { while (true) {
emit((player!!.currentPosition / 1000).toInt()) emit(player!!.currentPosition)
delay(1000) delay(interval)
} }
}.flowOn(Dispatchers.Main) }.flowOn(Dispatchers.Main)

View file

@ -7,7 +7,6 @@ import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Outline import android.graphics.Outline
import android.graphics.Paint import android.graphics.Paint
import android.graphics.PorterDuff import android.graphics.PorterDuff
@ -65,6 +64,11 @@ import java.util.Calendar
import java.util.Locale import java.util.Locale
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.math.abs 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 { object Extensions {
@ -346,45 +350,44 @@ object Extensions {
) )
} }
fun Long.toStringTimeStamp() : String { fun Long.toStringTimeStamp(forceMillis: Boolean = false, showMillisIfNonZero : Boolean = false): String {
var tmp = this return this.milliseconds.toComponents { hours, minutes, seconds, nanoseconds ->
val millis = ((tmp % 1000) / 100).toInt() buildString {
tmp /= 1000 if (hours > 0) {
val hours = (tmp / 3600).toInt() append(hours)
tmp %= 3600 append(':')
val minutes = (tmp / 60).toInt() }
tmp %= 60 append(minutes.toString().padStart(if (hours > 0) 2 else 1, '0'))
val seconds = tmp.toInt() append(':')
append(seconds.toString().padStart(2, '0'))
var res = "${minutes.toString().padStart(if (hours > 0) 2 else 1, '0')}:${seconds.toString().padStart(2, '0')}" val millis = nanoseconds / 1_000_000
if (hours > 0){ if (forceMillis || showMillisIfNonZero && millis > 0) {
res = "${hours}:" + res 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 { fun String.convertToTimestamp(): Long =
return try { kotlin.runCatching { tryConvertToTimestamp() }.getOrNull() ?: 0L
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 }
var times = 60 private val timeRegex =
for (i in timeArray.lastIndex - 1 downTo 0) { Regex("""^(?:(?:(?<hours>\d+):)?(?<minutes>\d{1,2}):)?(?<seconds>\d+)(?:\.(?<decimals>\d+))?$""")
timeSeconds += timeArray[i].toInt() * times
times *= 60
}
(timeSeconds * 1000) + millis * 100 fun String.tryConvertToTimestamp(): Long? {
}catch (e: Exception){ val match = timeRegex.matchEntire(this.trim()) ?: return null
e.printStackTrace()
0L 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 { fun String.convertNetscapeToSetCookie(): String {