From 55e878467c856755c383414bdc795955b13b3640 Mon Sep 17 00:00:00 2001 From: deniscerri <64997243+deniscerri@users.noreply.github.com> Date: Sun, 26 Mar 2023 17:13:59 +0200 Subject: [PATCH] implemented download sections in audio/video tab --- app/build.gradle | 2 +- .../2.json | 389 ++++++++++++++++++ .../deniscerri/ytdlnis/database/DBManager.kt | 13 +- .../ytdlnis/database/models/DownloadItem.kt | 2 + .../database/viewmodel/DownloadViewModel.kt | 2 + .../downloadcard/CutVideoBottomSheetDialog.kt | 266 ++++++++++++ .../ui/downloadcard/DownloadAudioFragment.kt | 19 + .../ui/downloadcard/DownloadVideoFragment.kt | 19 + .../com/deniscerri/ytdlnis/util/InfoUtil.kt | 24 +- .../deniscerri/ytdlnis/work/DownloadWorker.kt | 39 +- app/src/main/res/drawable/ic_cut.xml | 5 + app/src/main/res/layout/cut_video_sheet.xml | 126 ++++++ .../res/layout/fragment_download_audio.xml | 179 ++++---- .../res/layout/fragment_download_video.xml | 74 +--- .../res/layout/template_shortcuts_list.xml | 2 +- app/src/main/res/values/arrays.xml | 8 + app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/locales_config.xml | 3 + build.gradle | 1 + 19 files changed, 990 insertions(+), 185 deletions(-) create mode 100644 app/schemas/com.deniscerri.ytdlnis.database.DBManager/2.json create mode 100644 app/src/main/java/com/deniscerri/ytdlnis/ui/downloadcard/CutVideoBottomSheetDialog.kt create mode 100644 app/src/main/res/drawable/ic_cut.xml create mode 100644 app/src/main/res/layout/cut_video_sheet.xml diff --git a/app/build.gradle b/app/build.gradle index 23b11d15..1a8fe3a0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -171,6 +171,6 @@ dependencies { implementation "com.squareup.okhttp3:logging-interceptor:4.7.2" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" - + implementation "com.google.android.exoplayer:exoplayer:2.18.5" } diff --git a/app/schemas/com.deniscerri.ytdlnis.database.DBManager/2.json b/app/schemas/com.deniscerri.ytdlnis.database.DBManager/2.json new file mode 100644 index 00000000..4e1cf764 --- /dev/null +++ b/app/schemas/com.deniscerri.ytdlnis.database.DBManager/2.json @@ -0,0 +1,389 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "01fbf28d229b951415b597cf87ed52d0", + "entities": [ + { + "tableName": "results", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `duration` TEXT NOT NULL, `thumb` TEXT NOT NULL, `website` TEXT NOT NULL, `playlistTitle` TEXT NOT NULL, `formats` TEXT NOT NULL, `creationTime` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumb", + "columnName": "thumb", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistTitle", + "columnName": "playlistTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "formats", + "columnName": "formats", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTime", + "columnName": "creationTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `duration` TEXT NOT NULL, `thumb` TEXT NOT NULL, `type` TEXT NOT NULL, `time` INTEGER NOT NULL, `downloadPath` TEXT NOT NULL, `website` TEXT NOT NULL, `format` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumb", + "columnName": "thumb", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadPath", + "columnName": "downloadPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `thumb` TEXT NOT NULL, `duration` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT NOT NULL, `downloadSections` TEXT NOT NULL DEFAULT '', `allFormats` TEXT NOT NULL, `downloadPath` TEXT NOT NULL, `website` TEXT NOT NULL, `downloadSize` TEXT NOT NULL, `playlistTitle` TEXT NOT NULL, `audioPreferences` TEXT NOT NULL, `videoPreferences` TEXT NOT NULL, `customFileNameTemplate` TEXT NOT NULL, `SaveThumb` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'Queued', `downloadStartTime` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumb", + "columnName": "thumb", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadSections", + "columnName": "downloadSections", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "allFormats", + "columnName": "allFormats", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadPath", + "columnName": "downloadPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadSize", + "columnName": "downloadSize", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistTitle", + "columnName": "playlistTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "audioPreferences", + "columnName": "audioPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoPreferences", + "columnName": "videoPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "customFileNameTemplate", + "columnName": "customFileNameTemplate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "SaveThumb", + "columnName": "SaveThumb", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Queued'" + }, + { + "fieldPath": "downloadStartTime", + "columnName": "downloadStartTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "commandTemplates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "searchHistory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "templateShortcuts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `content` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '01fbf28d229b951415b597cf87ed52d0')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdlnis/database/DBManager.kt b/app/src/main/java/com/deniscerri/ytdlnis/database/DBManager.kt index 8c345538..2ed1079a 100644 --- a/app/src/main/java/com/deniscerri/ytdlnis/database/DBManager.kt +++ b/app/src/main/java/com/deniscerri/ytdlnis/database/DBManager.kt @@ -1,18 +1,18 @@ package com.deniscerri.ytdlnis.database import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.TypeConverters +import androidx.room.* +import androidx.room.migration.AutoMigrationSpec import com.deniscerri.ytdlnis.database.dao.* import com.deniscerri.ytdlnis.database.models.* @TypeConverters(Converters::class) @Database( entities = [ResultItem::class, HistoryItem::class, DownloadItem::class, CommandTemplate::class, SearchHistoryItem::class, TemplateShortcut::class], - version = 1, - autoMigrations = [] + version = 2, + autoMigrations = [ + AutoMigration (from = 1, to = 2) + ] ) abstract class DBManager : RoomDatabase(){ abstract val resultDao : ResultDao @@ -39,5 +39,4 @@ abstract class DBManager : RoomDatabase(){ } } } - } \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdlnis/database/models/DownloadItem.kt b/app/src/main/java/com/deniscerri/ytdlnis/database/models/DownloadItem.kt index d8e4d3fb..83fb1e5d 100644 --- a/app/src/main/java/com/deniscerri/ytdlnis/database/models/DownloadItem.kt +++ b/app/src/main/java/com/deniscerri/ytdlnis/database/models/DownloadItem.kt @@ -16,6 +16,8 @@ data class DownloadItem( val duration: String, var type: DownloadViewModel.Type, var format: Format, + @ColumnInfo(defaultValue = "") + var downloadSections: String, val allFormats: ArrayList, var downloadPath: String, val website: String, diff --git a/app/src/main/java/com/deniscerri/ytdlnis/database/viewmodel/DownloadViewModel.kt b/app/src/main/java/com/deniscerri/ytdlnis/database/viewmodel/DownloadViewModel.kt index 6ae9262b..588e1bbd 100644 --- a/app/src/main/java/com/deniscerri/ytdlnis/database/viewmodel/DownloadViewModel.kt +++ b/app/src/main/java/com/deniscerri/ytdlnis/database/viewmodel/DownloadViewModel.kt @@ -131,6 +131,7 @@ class DownloadViewModel(application: Application) : AndroidViewModel(application resultItem.duration, type, getFormat(resultItem.formats, type), + "", resultItem.formats, downloadPath!!, resultItem.website, "", resultItem.playlistTitle, audioPreferences, videoPreferences,customFileNameTemplate!!, saveThumb, DownloadRepository.Status.Processing.toString(), 0 ) @@ -172,6 +173,7 @@ class DownloadViewModel(application: Application) : AndroidViewModel(application historyItem.duration, historyItem.type, historyItem.format, + "", ArrayList(), downloadPath!!, historyItem.website, "", "", audioPreferences, videoPreferences,customFileNameTemplate!!, saveThumb, DownloadRepository.Status.Processing.toString(), 0 ) diff --git a/app/src/main/java/com/deniscerri/ytdlnis/ui/downloadcard/CutVideoBottomSheetDialog.kt b/app/src/main/java/com/deniscerri/ytdlnis/ui/downloadcard/CutVideoBottomSheetDialog.kt new file mode 100644 index 00000000..3ef74b32 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdlnis/ui/downloadcard/CutVideoBottomSheetDialog.kt @@ -0,0 +1,266 @@ +package com.deniscerri.ytdlnis.ui.downloadcard + +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.DialogInterface +import android.net.Uri +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.util.DisplayMetrics +import android.util.Log +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.widget.* +import androidx.lifecycle.lifecycleScope +import com.deniscerri.ytdlnis.R +import com.deniscerri.ytdlnis.database.models.DownloadItem +import com.deniscerri.ytdlnis.util.FileUtil +import com.deniscerri.ytdlnis.util.InfoUtil +import com.deniscerri.ytdlnis.util.UiUtil +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ui.PlayerView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.slider.RangeSlider +import com.google.android.material.textfield.TextInputLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.* + + +class CutVideoBottomSheetDialog(private val item: DownloadItem, private val listener: VideoCutListener) : BottomSheetDialogFragment() { + private lateinit var behavior: BottomSheetBehavior + private lateinit var fileUtil: FileUtil + private lateinit var infoUtil: InfoUtil + private lateinit var uiUtil: UiUtil + private lateinit var player: Player + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + fileUtil = FileUtil() + uiUtil = UiUtil(fileUtil) + infoUtil = InfoUtil(requireActivity().applicationContext) + } + + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, style) + val view = LayoutInflater.from(context).inflate(R.layout.cut_video_sheet, null) + dialog.setContentView(view) + + dialog.setOnShowListener { + behavior = BottomSheetBehavior.from(view.parent as View) + val displayMetrics = DisplayMetrics() + requireActivity().windowManager.defaultDisplay.getMetrics(displayMetrics) + behavior.peekHeight = displayMetrics.heightPixels / 2 + } + + player = ExoPlayer.Builder(requireContext()).build() + val videoView = view.findViewById(R.id.video_view) + videoView.player = player + + val progress = view.findViewById(R.id.progress) + val rangeSlider = view.findViewById(R.id.rangeSlider) + val fromTextInput = view.findViewById(R.id.from_textinput) + val toTextInput = view.findViewById(R.id.to_textinput) + + val timeSeconds = convertStringToTimestamp(item.duration) + + if (item.downloadSections.isEmpty()){ + fromTextInput.editText!!.setText("00:00") + toTextInput.editText!!.setText(item.duration) + }else{ + val stamps = item.downloadSections.split("-") + fromTextInput.editText!!.setText(stamps[0]) + toTextInput.editText!!.setText(stamps[1]) + + val startSeconds = convertStringToTimestamp(stamps[0]) + val endSeconds = convertStringToTimestamp(stamps[1]) + + val startValue = (startSeconds.toFloat() / timeSeconds) * 100 + val endValue = (endSeconds.toFloat() / timeSeconds) * 100 + + rangeSlider.setValues(startValue, endValue) + } + + lifecycleScope.launch { + try { + val url = withContext(Dispatchers.IO){ + infoUtil.getStreamingUrl(item.url) + } + progress.visibility = View.GONE + player.addMediaItem(MediaItem.fromUri(Uri.parse(url))) + player.prepare() + player.seekTo((((rangeSlider.values[0].toInt() * timeSeconds) / 100) * 1000).toLong()) + player.play() + }catch (e: Exception){ + progress.visibility = View.GONE + videoView.visibility = View.GONE + e.printStackTrace() + } + } + + //poll video progress + lifecycleScope.launch { + audioProgress(player).collect { + val startTimestamp = (rangeSlider.values[0].toInt() * timeSeconds) / 100 + val endTimestamp = (rangeSlider.values[1].toInt() * timeSeconds) / 100 + if (it >= endTimestamp){ + player.seekTo((startTimestamp * 1000).toLong()) + } + } + } + + videoView.setOnClickListener { + if (player.isPlaying) player.stop() + else { + player.prepare() + player.play() + } + } + + + rangeSlider.addOnChangeListener { rangeSlider, value, fromUser -> + val values = rangeSlider.values + val startTimestamp = (values[0].toInt() * timeSeconds) / 100 + val endTimestamp = (values[1].toInt() * timeSeconds) / 100 + + val startTimestampString = infoUtil.formatIntegerDuration(startTimestamp) + val endTimestampString = infoUtil.formatIntegerDuration(endTimestamp) + + fromTextInput.editText!!.setText(startTimestampString) + toTextInput.editText!!.setText(endTimestampString) + + try { + player.seekTo((startTimestamp * 1000).toLong()) + }catch (ignored: Exception) {} + + listener.onChangeCut(startTimestampString, endTimestampString) + } + + fromTextInput.editText!!.setOnKeyListener(object : View.OnKeyListener { + + override fun onKey(p0: View?, keyCode: Int, event: KeyEvent?): Boolean { + if ((event!!.action == KeyEvent.ACTION_DOWN) && + (keyCode == KeyEvent.KEYCODE_ENTER)) { + + val startTimestamp = (rangeSlider.values[0].toInt() * timeSeconds) / 100 + val endTimestamp = (rangeSlider.values[1].toInt() * timeSeconds) / 100 + + fromTextInput.editText!!.clearFocus() + val seconds = convertStringToTimestamp(fromTextInput.editText!!.text.toString()) + if (seconds == 0) { + fromTextInput.editText!!.setText(infoUtil.formatIntegerDuration(startTimestamp)) + return true + } + + val startValue = (seconds.toFloat() / endTimestamp) * 100 + if (startValue > 100){ + fromTextInput.editText!!.setText(infoUtil.formatIntegerDuration(startTimestamp)) + return true + } + + rangeSlider.setValues(startValue, rangeSlider.values[1]) + val startValueTimeStampSeconds = (startValue.toInt() * timeSeconds) / 100 + fromTextInput.editText!!.setText(infoUtil.formatIntegerDuration(startValueTimeStampSeconds)) + + return true; + } + return false; + } + }) + + + toTextInput.editText!!.setOnKeyListener(object : View.OnKeyListener { + + override fun onKey(p0: View?, keyCode: Int, event: KeyEvent?): Boolean { + if ((event!!.action == KeyEvent.ACTION_DOWN) && + (keyCode == KeyEvent.KEYCODE_ENTER)) { + + val endTimestamp = (rangeSlider.values[1].toInt() * timeSeconds) / 100 + + toTextInput.editText!!.clearFocus() + val seconds = convertStringToTimestamp(toTextInput.editText!!.text.toString()) + if (seconds == 0) { + toTextInput.editText!!.setText(infoUtil.formatIntegerDuration(endTimestamp)) + return true + } + + val endValue = (seconds.toFloat() / endTimestamp) * 100 + if (endValue > 100 || endValue <= rangeSlider.values[0].toInt()){ + toTextInput.editText!!.setText(infoUtil.formatIntegerDuration(endTimestamp)) + return true + } + + rangeSlider.setValues(rangeSlider.values[0], endValue) + val endValueTimeStampSeconds = (endValue.toInt() * timeSeconds) / 100 + toTextInput.editText!!.setText(infoUtil.formatIntegerDuration(endValueTimeStampSeconds)) + + return true; + } + return false; + } + }) + + val cancelBtn = view.findViewById