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.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<String>
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)

View file

@ -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("""^(?:(?:(?<hours>\d+):)?(?<minutes>\d{1,2}):)?(?<seconds>\d+)(?:\.(?<decimals>\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 {