mirror of https://github.com/deniscerri/ytdlnis
1.7.6 beta
parent
7f0cde7774
commit
0ce383f33e
@ -0,0 +1,12 @@
|
||||
package com.deniscerri.ytdl.database.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class AlreadyExistsItem(
|
||||
var downloadItem: DownloadItem,
|
||||
var historyID: Long? = null
|
||||
) : Parcelable
|
||||
@ -0,0 +1,616 @@
|
||||
package com.deniscerri.ytdl.database.viewmodel
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Parcelable
|
||||
import android.util.DisplayMetrics
|
||||
import android.widget.Toast
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.afollestad.materialdialogs.utils.MDUtil.getStringArray
|
||||
import com.deniscerri.ytdl.App
|
||||
import com.deniscerri.ytdl.R
|
||||
import com.deniscerri.ytdl.database.DBManager
|
||||
import com.deniscerri.ytdl.database.dao.CommandTemplateDao
|
||||
import com.deniscerri.ytdl.database.dao.DownloadDao
|
||||
import com.deniscerri.ytdl.database.models.AudioPreferences
|
||||
import com.deniscerri.ytdl.database.models.CommandTemplate
|
||||
import com.deniscerri.ytdl.database.models.DownloadItem
|
||||
import com.deniscerri.ytdl.database.models.Format
|
||||
import com.deniscerri.ytdl.database.models.ResultItem
|
||||
import com.deniscerri.ytdl.database.models.VideoPreferences
|
||||
import com.deniscerri.ytdl.database.repository.DownloadRepository
|
||||
import com.deniscerri.ytdl.database.repository.HistoryRepository
|
||||
import com.deniscerri.ytdl.database.repository.ResultRepository
|
||||
import com.deniscerri.ytdl.database.viewmodel.DownloadViewModel.Type
|
||||
import com.deniscerri.ytdl.util.Extensions.toListString
|
||||
import com.deniscerri.ytdl.util.FileUtil
|
||||
import com.deniscerri.ytdl.util.InfoUtil
|
||||
import com.deniscerri.ytdl.work.AlarmScheduler
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
class SharedDownloadViewModel(private val context: Context) {
|
||||
private val dbManager: DBManager = DBManager.getInstance(context)
|
||||
val repository : DownloadRepository
|
||||
private val sharedPreferences: SharedPreferences
|
||||
private val commandTemplateDao: CommandTemplateDao
|
||||
private val infoUtil : InfoUtil
|
||||
|
||||
private var bestVideoFormat : Format
|
||||
private var bestAudioFormat : Format
|
||||
private var defaultVideoFormats : MutableList<Format>
|
||||
|
||||
private val videoQualityPreference: String
|
||||
private val formatIDPreference: List<String>
|
||||
private val audioFormatIDPreference: List<String>
|
||||
private val resources : Resources
|
||||
private var extraCommandsForAudio: String = ""
|
||||
private var extraCommandsForVideo: String = ""
|
||||
|
||||
private var audioContainer: String?
|
||||
private var videoContainer: String?
|
||||
private var videoCodec: String?
|
||||
private var audioCodec: String?
|
||||
private val dao: DownloadDao
|
||||
private val historyRepository: HistoryRepository
|
||||
private val resultRepository: ResultRepository
|
||||
|
||||
@Parcelize
|
||||
data class AlreadyExistsIDs(
|
||||
var downloadItemID: Long,
|
||||
var historyItemID : Long?
|
||||
) : Parcelable
|
||||
|
||||
val alreadyExistsUiState: MutableStateFlow<List<AlreadyExistsIDs>> = MutableStateFlow(
|
||||
mutableListOf()
|
||||
)
|
||||
|
||||
private val urlsForAudioType = listOf(
|
||||
"music",
|
||||
"audio",
|
||||
"soundcloud"
|
||||
)
|
||||
|
||||
init {
|
||||
dao = dbManager.downloadDao
|
||||
repository = DownloadRepository(dao)
|
||||
historyRepository = HistoryRepository(dbManager.historyDao)
|
||||
resultRepository = ResultRepository(dbManager.resultDao, context)
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
commandTemplateDao = DBManager.getInstance(context).commandTemplateDao
|
||||
infoUtil = InfoUtil(context)
|
||||
|
||||
CoroutineScope(SupervisorJob()).launch(Dispatchers.IO) {
|
||||
if (sharedPreferences.getBoolean("use_extra_commands", false)){
|
||||
extraCommandsForAudio = commandTemplateDao.getAllTemplatesAsExtraCommandsForAudio().joinToString(" ")
|
||||
extraCommandsForVideo = commandTemplateDao.getAllTemplatesAsExtraCommandsForVideo().joinToString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
videoQualityPreference = sharedPreferences.getString("video_quality", "best").toString()
|
||||
formatIDPreference = sharedPreferences.getString("format_id", "").toString().split(",").filter { it.isNotEmpty() }
|
||||
audioFormatIDPreference = sharedPreferences.getString("format_id_audio", "").toString().split(",").filter { it.isNotEmpty() }
|
||||
|
||||
val confTmp = Configuration(context.resources.configuration)
|
||||
confTmp.setLocale(Locale(sharedPreferences.getString("app_language", "en")!!))
|
||||
val metrics = DisplayMetrics()
|
||||
resources = Resources(context.assets, metrics, confTmp)
|
||||
|
||||
|
||||
videoContainer = sharedPreferences.getString("video_format", "Default")
|
||||
defaultVideoFormats = infoUtil.getGenericVideoFormats(resources)
|
||||
bestVideoFormat = defaultVideoFormats.first()
|
||||
|
||||
audioContainer = sharedPreferences.getString("audio_format", "mp3")
|
||||
bestAudioFormat = if (audioFormatIDPreference.isEmpty()){
|
||||
infoUtil.getGenericAudioFormats(resources).first()
|
||||
}else{
|
||||
Format(
|
||||
audioFormatIDPreference.first().split("+").first(),
|
||||
audioContainer!!,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
audioFormatIDPreference.first().split("+").first()
|
||||
)
|
||||
}
|
||||
|
||||
videoCodec = sharedPreferences.getString("video_codec", "")
|
||||
audioCodec = sharedPreferences.getString("audio_codec", "")
|
||||
}
|
||||
|
||||
|
||||
fun getDownloadType(t: Type? = null, url: String) : Type {
|
||||
var type = t
|
||||
|
||||
if (type == null){
|
||||
val preferredDownloadType = sharedPreferences.getString("preferred_download_type", Type.auto.toString())
|
||||
type = if (sharedPreferences.getBoolean("remember_download_type", false)){
|
||||
Type.valueOf(sharedPreferences.getString("last_used_download_type",
|
||||
preferredDownloadType)!!)
|
||||
}else{
|
||||
Type.valueOf(preferredDownloadType!!)
|
||||
}
|
||||
}
|
||||
|
||||
return when(type){
|
||||
Type.auto -> {
|
||||
if (urlsForAudioType.any { url.contains(it) }){
|
||||
Type.audio
|
||||
}else{
|
||||
Type.video
|
||||
}
|
||||
}
|
||||
|
||||
else -> type
|
||||
}
|
||||
}
|
||||
|
||||
fun createDownloadItemFromResult(result: ResultItem?, url: String = "", givenType: Type) : DownloadItem {
|
||||
val resultItem = result ?: createEmptyResultItem(url)
|
||||
|
||||
val embedSubs = sharedPreferences.getBoolean("embed_subtitles", false)
|
||||
val saveSubs = sharedPreferences.getBoolean("write_subtitles", false)
|
||||
val saveAutoSubs = sharedPreferences.getBoolean("write_auto_subtitles", false)
|
||||
val addChapters = sharedPreferences.getBoolean("add_chapters", false)
|
||||
val saveThumb = sharedPreferences.getBoolean("write_thumbnail", false)
|
||||
val embedThumb = sharedPreferences.getBoolean("embed_thumbnail", false)
|
||||
val cropThumb = sharedPreferences.getBoolean("crop_thumbnail", false)
|
||||
|
||||
var type = getDownloadType(givenType, resultItem.url)
|
||||
if(type == Type.command && commandTemplateDao.getTotalNumber() == 0) type = Type.video
|
||||
|
||||
val customFileNameTemplate = when(type) {
|
||||
Type.audio -> sharedPreferences.getString("file_name_template_audio", "%(uploader)s - %(title)s")
|
||||
Type.video -> sharedPreferences.getString("file_name_template", "%(uploader)s - %(title)s")
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val downloadPath = when(type){
|
||||
Type.audio -> sharedPreferences.getString("music_path", FileUtil.getDefaultAudioPath())
|
||||
Type.video -> sharedPreferences.getString("video_path", FileUtil.getDefaultVideoPath())
|
||||
else -> sharedPreferences.getString("command_path", FileUtil.getDefaultCommandPath())
|
||||
}
|
||||
|
||||
val container = when(type){
|
||||
Type.audio -> sharedPreferences.getString("audio_format", "")
|
||||
else -> sharedPreferences.getString("video_format", "")
|
||||
}
|
||||
|
||||
|
||||
val sponsorblock = sharedPreferences.getStringSet("sponsorblock_filters", emptySet())
|
||||
|
||||
val audioPreferences = AudioPreferences(embedThumb, cropThumb,false, ArrayList(sponsorblock!!))
|
||||
|
||||
|
||||
val preferredAudioFormats = getPreferredAudioFormats(resultItem.formats)
|
||||
|
||||
val videoPreferences = VideoPreferences(
|
||||
embedSubs,
|
||||
addChapters, false,
|
||||
ArrayList(sponsorblock),
|
||||
saveSubs,
|
||||
saveAutoSubs,
|
||||
audioFormatIDs = preferredAudioFormats
|
||||
)
|
||||
|
||||
val extraCommands = when(type){
|
||||
Type.audio -> extraCommandsForAudio
|
||||
Type.video -> extraCommandsForVideo
|
||||
else -> ""
|
||||
}
|
||||
|
||||
return DownloadItem(0,
|
||||
resultItem.url,
|
||||
resultItem.title,
|
||||
resultItem.author,
|
||||
resultItem.thumb,
|
||||
resultItem.duration,
|
||||
type,
|
||||
getFormat(resultItem.formats, type),
|
||||
container!!,
|
||||
"",
|
||||
resultItem.formats,
|
||||
downloadPath!!, resultItem.website,
|
||||
"",
|
||||
resultItem.playlistTitle,
|
||||
audioPreferences,
|
||||
videoPreferences,
|
||||
extraCommands,
|
||||
customFileNameTemplate!!,
|
||||
saveThumb,
|
||||
DownloadRepository.Status.Queued.toString(), 0, null, playlistURL = resultItem.playlistURL, playlistIndex = resultItem.playlistIndex
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
fun createResultItemFromDownload(downloadItem: DownloadItem) : ResultItem {
|
||||
return ResultItem(
|
||||
0,
|
||||
downloadItem.url,
|
||||
downloadItem.title,
|
||||
downloadItem.author,
|
||||
downloadItem.duration,
|
||||
downloadItem.thumb,
|
||||
downloadItem.website,
|
||||
downloadItem.playlistTitle,
|
||||
downloadItem.allFormats,
|
||||
"",
|
||||
arrayListOf(),
|
||||
downloadItem.playlistURL,
|
||||
downloadItem.playlistIndex,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
fun createEmptyResultItem(url: String) : ResultItem {
|
||||
return ResultItem(
|
||||
0,
|
||||
url,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
arrayListOf(),
|
||||
"",
|
||||
arrayListOf(),
|
||||
"",
|
||||
null,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
private fun getPreferredAudioRequirements(): MutableList<(Format) -> Int> {
|
||||
val requirements: MutableList<(Format) -> Int> = mutableListOf()
|
||||
requirements.add {it: Format -> if (audioFormatIDPreference.contains(it.format_id)) 10 else 0}
|
||||
|
||||
sharedPreferences.getString("audio_language", "")?.apply {
|
||||
if (this.isNotBlank()){
|
||||
requirements.add { it: Format -> if (it.lang?.contains(this) == true) 3 else 0 }
|
||||
}
|
||||
}
|
||||
|
||||
requirements.add {it: Format -> if ("^(${audioCodec}).+$".toRegex(RegexOption.IGNORE_CASE).matches(it.acodec)) 2 else 0}
|
||||
requirements.add {it: Format -> if (it.container == audioContainer) 1 else 0 }
|
||||
return requirements
|
||||
}
|
||||
|
||||
//requirement and importance
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun getPreferredVideoRequirements(): MutableList<(Format) -> Int> {
|
||||
val requirements: MutableList<(Format) -> Int> = mutableListOf()
|
||||
//format id
|
||||
requirements.add { it: Format -> if (formatIDPreference.contains(it.format_id)) 20 else 0 }
|
||||
//resolutions
|
||||
context.getStringArray(R.array.video_formats_values)
|
||||
.filter { it.contains("_") }
|
||||
.map{ it.split("_")[0].dropLast(1)
|
||||
}.toMutableList().apply {
|
||||
when(videoQualityPreference) {
|
||||
"worst" -> {
|
||||
requirements.add { it: Format -> if (it.format_note.contains("worst", ignoreCase = true)) (15) else 0 }
|
||||
}
|
||||
"best" -> {
|
||||
requirements.add { it: Format -> if (it.format_note.contains("best", ignoreCase = true)) (15) else 0 }
|
||||
}
|
||||
else -> {
|
||||
val preferenceIndex = this.indexOfFirst { videoQualityPreference.contains(it) }
|
||||
val preference = this[preferenceIndex]
|
||||
for(i in 0..preferenceIndex){
|
||||
removeAt(0)
|
||||
}
|
||||
add(0, preference)
|
||||
forEachIndexed { index, res ->
|
||||
requirements.add { it: Format -> if (it.format_note.contains(res, ignoreCase = true)) (15 - index - 1) else 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
requirements.add { it: Format -> if ("^(${videoCodec})(.+)?$".toRegex(RegexOption.IGNORE_CASE).matches(it.vcodec)) 5 else 0 }
|
||||
requirements.add { it: Format -> if (it.acodec == "none" || it.acodec == "") 1 else 0 }
|
||||
requirements.add { it: Format ->
|
||||
if (videoContainer == "mp4")
|
||||
if (it.container.equals("mpeg_4", true)) 1 else 0
|
||||
else
|
||||
if (it.container.equals(videoContainer, true)) 1 else 0
|
||||
}
|
||||
return requirements
|
||||
}
|
||||
|
||||
fun getFormat(formats: List<Format>, type: Type) : Format {
|
||||
when(type) {
|
||||
Type.audio -> {
|
||||
return cloneFormat (
|
||||
try {
|
||||
val theFormats = formats.filter { it.vcodec.isBlank() || it.vcodec == "none" }
|
||||
val requirements = getPreferredAudioRequirements()
|
||||
theFormats.maxByOrNull { f -> requirements.sumOf{ req -> req(f)} } ?: throw Exception()
|
||||
}catch (e: Exception){
|
||||
bestAudioFormat
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
Type.video -> {
|
||||
return cloneFormat(
|
||||
try {
|
||||
val theFormats = formats.filter { it.vcodec.isNotBlank() && it.vcodec != "none" }.ifEmpty {
|
||||
defaultVideoFormats.sortedByDescending { it.filesize }
|
||||
}
|
||||
when (videoQualityPreference) {
|
||||
"worst" -> {
|
||||
theFormats.last()
|
||||
}
|
||||
else /*best*/ -> {
|
||||
val requirements = getPreferredVideoRequirements()
|
||||
theFormats.run {
|
||||
if (sharedPreferences.getBoolean("prefer_smaller_formats", false)){
|
||||
sortedBy { it.filesize }.maxByOrNull { f -> requirements.sumOf { req -> req(f) } } ?: throw Exception()
|
||||
}else{
|
||||
sortedByDescending { it.filesize }.maxByOrNull { f ->
|
||||
val summ = requirements.sumOf { req -> req(f) }
|
||||
summ
|
||||
} ?: throw Exception()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}catch (e: Exception){
|
||||
bestVideoFormat
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val lastUsedCommandTemplate = sharedPreferences.getString("lastCommandTemplateUsed", "")!!
|
||||
val c = if (lastUsedCommandTemplate.isBlank()){
|
||||
commandTemplateDao.getFirst() ?: CommandTemplate(0,"","", useAsExtraCommand = false, useAsExtraCommandAudio = false, useAsExtraCommandVideo = false)
|
||||
}else{
|
||||
commandTemplateDao.getTemplateByContent(lastUsedCommandTemplate) ?: CommandTemplate(0, "", lastUsedCommandTemplate, useAsExtraCommand = false, useAsExtraCommandAudio = false, useAsExtraCommandVideo = false)
|
||||
}
|
||||
return generateCommandFormat(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPreferredAudioFormats(formats: List<Format>) : ArrayList<String>{
|
||||
val preferredAudioFormats = arrayListOf<String>()
|
||||
for (f in formats.sortedBy { it.format_id }){
|
||||
val fId = audioFormatIDPreference.sorted().find { it.contains(f.format_id) }
|
||||
if (fId != null) {
|
||||
if (fId.split("+").all { formats.map { f-> f.format_id }.contains(it) }){
|
||||
preferredAudioFormats.addAll(fId.split("+"))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (preferredAudioFormats.isEmpty()){
|
||||
val audioF = getFormat(formats, Type.audio)
|
||||
if (!infoUtil.getGenericAudioFormats(resources).contains(audioF)){
|
||||
preferredAudioFormats.add(audioF.format_id)
|
||||
}
|
||||
}
|
||||
return preferredAudioFormats
|
||||
}
|
||||
|
||||
fun generateCommandFormat(c: CommandTemplate) : Format {
|
||||
return Format(
|
||||
c.title,
|
||||
c.id.toString(),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
c.content.replace("\n", " ")
|
||||
)
|
||||
}
|
||||
|
||||
private fun cloneFormat(item: Format) : Format {
|
||||
val string = Gson().toJson(item, Format::class.java)
|
||||
return Gson().fromJson(string, Format::class.java)
|
||||
}
|
||||
|
||||
suspend fun queueDownloads(items: List<DownloadItem>, ign : Boolean = false) : List<AlreadyExistsIDs> {
|
||||
val context = App.instance
|
||||
val alarmScheduler = AlarmScheduler(context)
|
||||
val queuedItems = mutableListOf<DownloadItem>()
|
||||
//download id, history item id
|
||||
//history item id if the existing item is already downloaded
|
||||
val existingItemIDs = mutableListOf<AlreadyExistsIDs>()
|
||||
|
||||
if (items.any { it.playlistTitle.isEmpty() } && items.size > 1){
|
||||
items.forEachIndexed { index, it -> it.playlistTitle = "Various[${index+1}]" }
|
||||
}
|
||||
|
||||
val downloadArchive = runCatching { File(FileUtil.getDownloadArchivePath(context)).useLines { it.toList() } }.getOrElse { listOf() }
|
||||
.map { it.split(" ")[1] }
|
||||
items.forEach {
|
||||
if (! listOf(DownloadRepository.Status.ActivePaused, DownloadRepository.Status.Scheduled).toListString().contains(it.status))
|
||||
it.status = DownloadRepository.Status.Queued.toString()
|
||||
var alreadyExists = false
|
||||
|
||||
val checkDuplicate = sharedPreferences.getString("prevent_duplicate_downloads", "")!!
|
||||
if (checkDuplicate.isNotEmpty() && !ign){
|
||||
when(checkDuplicate){
|
||||
"download_archive" -> {
|
||||
if (downloadArchive.any { d -> it.url.contains(d) }){
|
||||
alreadyExists = true
|
||||
if (it.id == 0L) {
|
||||
it.status = DownloadRepository.Status.Processing.toString()
|
||||
val id = runBlocking {
|
||||
repository.insert(it)
|
||||
}
|
||||
it.id = id
|
||||
}
|
||||
existingItemIDs.add(AlreadyExistsIDs(it.id, null))
|
||||
}
|
||||
}
|
||||
"url_type" -> {
|
||||
val activeAndQueuedDownloads = withContext(Dispatchers.IO){
|
||||
repository.getActiveAndQueuedDownloads()
|
||||
}
|
||||
val existingDownload = activeAndQueuedDownloads.firstOrNull{d ->
|
||||
d.id = 0
|
||||
d.logID = null
|
||||
d.customFileNameTemplate = it.customFileNameTemplate
|
||||
d.status = DownloadRepository.Status.Queued.toString()
|
||||
d.toString() == it.toString()
|
||||
}
|
||||
|
||||
if (existingDownload != null){
|
||||
it.status = DownloadRepository.Status.Processing.toString()
|
||||
val id = runBlocking {
|
||||
repository.insert(it)
|
||||
}
|
||||
it.id = id
|
||||
alreadyExists = true
|
||||
existingItemIDs.add(AlreadyExistsIDs(it.id, null))
|
||||
}else{
|
||||
//check if downloaded and file exists
|
||||
val history = withContext(Dispatchers.IO){
|
||||
historyRepository.getAllByURL(it.url).filter { item -> item.downloadPath.any { path -> FileUtil.exists(path) } }
|
||||
}
|
||||
|
||||
val existingHistoryItem = history.firstOrNull {
|
||||
h -> h.type == it.type
|
||||
}
|
||||
|
||||
if (existingHistoryItem != null){
|
||||
alreadyExists = true
|
||||
it.status = DownloadRepository.Status.Processing.toString()
|
||||
val id = runBlocking {
|
||||
repository.insert(it)
|
||||
}
|
||||
existingItemIDs.add(AlreadyExistsIDs(id, existingHistoryItem.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
"config" -> {
|
||||
val currentCommand = infoUtil.buildYoutubeDLRequest(it)
|
||||
val parsedCurrentCommand = infoUtil.parseYTDLRequestString(currentCommand)
|
||||
val activeAndQueuedDownloads = withContext(Dispatchers.IO){
|
||||
repository.getActiveAndQueuedDownloads()
|
||||
}
|
||||
val existingDownload = activeAndQueuedDownloads.firstOrNull{d ->
|
||||
d.id = 0
|
||||
d.logID = null
|
||||
d.customFileNameTemplate = it.customFileNameTemplate
|
||||
d.status = DownloadRepository.Status.Queued.toString()
|
||||
d.toString() == it.toString()
|
||||
}
|
||||
|
||||
if (existingDownload != null){
|
||||
it.status = DownloadRepository.Status.Processing.toString()
|
||||
val id = runBlocking {
|
||||
repository.insert(it)
|
||||
}
|
||||
alreadyExists = true
|
||||
existingItemIDs.add(AlreadyExistsIDs(id, null))
|
||||
}else{
|
||||
//check if downloaded and file exists
|
||||
val history = withContext(Dispatchers.IO){
|
||||
historyRepository.getAllByURL(it.url).filter { item -> item.downloadPath.any { path -> FileUtil.exists(path) } }
|
||||
}
|
||||
|
||||
val existingHistoryItem = history.firstOrNull {
|
||||
h -> h.command.replace("(-P \"(.*?)\")|(--trim-filenames \"(.*?)\")".toRegex(), "") == parsedCurrentCommand.replace("(-P \"(.*?)\")|(--trim-filenames \"(.*?)\")".toRegex(), "")
|
||||
}
|
||||
|
||||
if (existingHistoryItem != null){
|
||||
alreadyExists = true
|
||||
it.status = DownloadRepository.Status.Processing.toString()
|
||||
val id = runBlocking {
|
||||
repository.insert(it)
|
||||
}
|
||||
existingItemIDs.add(AlreadyExistsIDs(id, existingHistoryItem.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!alreadyExists){
|
||||
if (it.id == 0L){
|
||||
val id = runBlocking {
|
||||
repository.insert(it)
|
||||
}
|
||||
it.id = id
|
||||
}else if (listOf(DownloadRepository.Status.Queued, DownloadRepository.Status.Scheduled).toListString().contains(it.status)){
|
||||
withContext(Dispatchers.IO){
|
||||
repository.update(it)
|
||||
}
|
||||
}
|
||||
|
||||
queuedItems.add(it)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (existingItemIDs.isNotEmpty()){
|
||||
alreadyExistsUiState.value = existingItemIDs.toList()
|
||||
}
|
||||
|
||||
|
||||
//if scheduler is on
|
||||
val useScheduler = sharedPreferences.getBoolean("use_scheduler", false)
|
||||
if (useScheduler && !alarmScheduler.isDuringTheScheduledTime()){
|
||||
if (alarmScheduler.canSchedule()){
|
||||
alarmScheduler.schedule()
|
||||
}else{
|
||||
sharedPreferences.edit().putBoolean("use_scheduler", false).apply()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, context.getString(R.string.enable_alarm_permission), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}else{
|
||||
if (queuedItems.isNotEmpty()){
|
||||
repository.startDownloadWorker(queuedItems, context)
|
||||
|
||||
if(!useScheduler){
|
||||
queuedItems.filter { it.downloadStartTime != 0L && (it.title.isEmpty() || it.author.isEmpty() || it.thumb.isEmpty()) }.forEach {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
runCatching {
|
||||
resultRepository.updateDownloadItem(it)?.apply {
|
||||
repository.updateWithoutUpsert(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}else{
|
||||
queuedItems.filter { it.title.isEmpty() || it.author.isEmpty() || it.thumb.isEmpty() }.forEach {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
runCatching {
|
||||
resultRepository.updateDownloadItem(it)?.apply {
|
||||
repository.updateWithoutUpsert(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return existingItemIDs
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package com.deniscerri.ytdl.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.deniscerri.ytdl.work.CancelScheduledDownloadWorker
|
||||
import com.deniscerri.ytdl.work.DownloadWorker
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class CancelScheduleAlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, p1: Intent?) {
|
||||
ctx?.apply {
|
||||
val workConstraints = Constraints.Builder()
|
||||
val workRequest2 = OneTimeWorkRequestBuilder<CancelScheduledDownloadWorker>()
|
||||
.addTag("cancelScheduledDownload")
|
||||
.setConstraints(workConstraints.build())
|
||||
.setInitialDelay( 0L, TimeUnit.MILLISECONDS)
|
||||
|
||||
WorkManager.getInstance(this).enqueueUniqueWork(
|
||||
System.currentTimeMillis().toString(),
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest2.build()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.deniscerri.ytdl.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.deniscerri.ytdl.work.DownloadWorker
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ScheduleAlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, p1: Intent?) {
|
||||
ctx?.apply {
|
||||
val workConstraints = Constraints.Builder()
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
val allowMeteredNetworks = preferences.getBoolean("metered_networks", true)
|
||||
if (!allowMeteredNetworks) workConstraints.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.addTag("scheduledDownload")
|
||||
.addTag("download")
|
||||
.setConstraints(workConstraints.build())
|
||||
.setInitialDelay(0L, TimeUnit.MILLISECONDS)
|
||||
|
||||
WorkManager.getInstance(this).enqueueUniqueWork(
|
||||
System.currentTimeMillis().toString(),
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest.build()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,136 @@
|
||||
package com.deniscerri.ytdl.services
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.IntentCompat
|
||||
import com.deniscerri.ytdl.database.DBManager
|
||||
import com.deniscerri.ytdl.database.models.DownloadItem
|
||||
import com.deniscerri.ytdl.database.models.ResultItem
|
||||
import com.deniscerri.ytdl.database.repository.DownloadRepository
|
||||
import com.deniscerri.ytdl.database.repository.ResultRepository
|
||||
import com.deniscerri.ytdl.database.viewmodel.DownloadViewModel
|
||||
import com.deniscerri.ytdl.database.viewmodel.SharedDownloadViewModel
|
||||
import com.deniscerri.ytdl.util.NotificationUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class ProcessDownloadsInBackgroundService : Service() {
|
||||
|
||||
private val binder: IBinder = LocalBinder()
|
||||
private val queueProcessingDownloadsJobList = mutableListOf<Job>()
|
||||
private lateinit var repository: DownloadRepository
|
||||
private lateinit var resultRepository: ResultRepository
|
||||
private lateinit var downloadViewModel: SharedDownloadViewModel
|
||||
inner class LocalBinder: Binder() {
|
||||
val service: ProcessDownloadsInBackgroundService
|
||||
get() = this@ProcessDownloadsInBackgroundService
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val dbManager = DBManager.getInstance(this)
|
||||
repository = DownloadRepository(dbManager.downloadDao)
|
||||
resultRepository = ResultRepository(dbManager.resultDao, this)
|
||||
downloadViewModel = SharedDownloadViewModel(this)
|
||||
}
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
val notificationUtil = NotificationUtil(this)
|
||||
startForeground(System.currentTimeMillis().toInt(), notificationUtil.createProcessingDownloads())
|
||||
|
||||
val itemType = intent.getStringExtra("itemType") ?: ""
|
||||
val itemIDs = intent.getLongArrayExtra("itemIDs") ?: longArrayOf()
|
||||
val jobData = DownloadViewModel.ProcessingItemsJob(
|
||||
itemType = itemType,
|
||||
itemIDs = itemIDs.toList()
|
||||
)
|
||||
val timeInMillis = intent.getLongExtra("timeInMillis", 0)
|
||||
CoroutineScope(SupervisorJob()).launch(Dispatchers.IO) {
|
||||
runJob(jobData, timeInMillis)
|
||||
}
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
private fun DownloadItem.setAsScheduling(timeInMillis: Long) {
|
||||
status = DownloadRepository.Status.Scheduled.toString()
|
||||
downloadStartTime = timeInMillis
|
||||
}
|
||||
|
||||
private suspend fun runJob(jobData: DownloadViewModel.ProcessingItemsJob, timeInMillis: Long = 0) {
|
||||
val job = CoroutineScope(SupervisorJob()).launch(Dispatchers.IO) {
|
||||
if (jobData.itemType != "") {
|
||||
val itemIDS = jobData.itemIDs
|
||||
val processingType = jobData.itemType
|
||||
when(processingType) {
|
||||
ResultItem::class.java.toString() -> {
|
||||
itemIDS.chunked(100).map { ids ->
|
||||
resultRepository.getAllByIDs(ids).map {
|
||||
downloadViewModel.createDownloadItemFromResult(
|
||||
result = it, givenType = DownloadViewModel.Type.valueOf(
|
||||
downloadViewModel.getDownloadType(url = it.url).toString()
|
||||
)
|
||||
)
|
||||
}.apply {
|
||||
if (timeInMillis > 0) {
|
||||
this.forEach {
|
||||
it.setAsScheduling(timeInMillis)
|
||||
}
|
||||
}
|
||||
downloadViewModel.queueDownloads(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DownloadItem::class.java.toString() -> {
|
||||
itemIDS.chunked(100).map { ids ->
|
||||
repository.getAllItemsByIDs(ids).apply {
|
||||
if (timeInMillis > 0) {
|
||||
this.forEach {
|
||||
it.setAsScheduling(timeInMillis)
|
||||
}
|
||||
}
|
||||
downloadViewModel.queueDownloads(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}else {
|
||||
repository.getProcessingDownloads().apply {
|
||||
if (timeInMillis > 0){
|
||||
this.forEach {
|
||||
it.setAsScheduling(timeInMillis)
|
||||
}
|
||||
}
|
||||
|
||||
downloadViewModel.queueDownloads(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
job.invokeOnCompletion {
|
||||
queueProcessingDownloadsJobList.remove(job)
|
||||
if (queueProcessingDownloadsJobList.isEmpty()){
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
queueProcessingDownloadsJobList.add(job)
|
||||
}
|
||||
|
||||
fun cancelAllProcessingJobs(){
|
||||
queueProcessingDownloadsJobList.onEach { it.cancel(CancellationException()) }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.deniscerri.ytdl.ui.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.deniscerri.ytdl.R
|
||||
import com.deniscerri.ytdl.databinding.SortableTextItemBinding
|
||||
|
||||
class SortableTextItemAdapter(
|
||||
val items: MutableList<Pair<String, String>>
|
||||
) : RecyclerView.Adapter<SortableTextItemAdapter.ViewHolder>() {
|
||||
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val textView: TextView
|
||||
|
||||
init {
|
||||
textView = itemView.findViewById(R.id.textContent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = SortableTextItemBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return ViewHolder(binding.root)
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items.toList()[position]
|
||||
holder.textView.text = item.second
|
||||
holder.textView.tag = item.first
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
package com.deniscerri.ytdl.ui.downloadcard
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.View
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.deniscerri.ytdl.R
|
||||
import com.deniscerri.ytdl.database.models.AlreadyExistsItem
|
||||
import com.deniscerri.ytdl.database.models.DownloadItem
|
||||
import com.deniscerri.ytdl.database.viewmodel.DownloadViewModel
|
||||
import com.deniscerri.ytdl.database.viewmodel.HistoryViewModel
|
||||
import com.deniscerri.ytdl.database.viewmodel.ResultViewModel
|
||||
import com.deniscerri.ytdl.database.viewmodel.SharedDownloadViewModel.AlreadyExistsIDs
|
||||
import com.deniscerri.ytdl.ui.adapter.AlreadyExistsAdapter
|
||||
import com.deniscerri.ytdl.util.Extensions.enableFastScroll
|
||||
import com.deniscerri.ytdl.util.UiUtil
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.elevation.SurfaceColors
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
class DownloadsAlreadyExistDialog : BottomSheetDialogFragment(), AlreadyExistsAdapter.OnItemClickListener {
|
||||
private var activity: Activity? = null
|
||||
private lateinit var downloadViewModel : DownloadViewModel
|
||||
private lateinit var resultViewModel : ResultViewModel
|
||||
private lateinit var historyViewModel : HistoryViewModel
|
||||
|
||||
private var duplicateIDs : MutableList<AlreadyExistsIDs> = mutableListOf()
|
||||
private lateinit var duplicates: MutableList<AlreadyExistsItem>
|
||||
private lateinit var preferences: SharedPreferences
|
||||
private lateinit var adapter: AlreadyExistsAdapter
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
activity = getActivity()
|
||||
downloadViewModel = ViewModelProvider(requireActivity())[DownloadViewModel::class.java]
|
||||
resultViewModel = ViewModelProvider(requireActivity())[ResultViewModel::class.java]
|
||||
historyViewModel = ViewModelProvider(requireActivity())[HistoryViewModel::class.java]
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
|
||||
kotlin.runCatching {
|
||||
duplicateIDs = if (Build.VERSION.SDK_INT >= 33){
|
||||
arguments?.getParcelableArrayList("duplicates", AlreadyExistsIDs::class.java)!!.toMutableList()
|
||||
}else{
|
||||
arguments?.getParcelableArrayList<AlreadyExistsIDs>("duplicates")!!.toMutableList()
|
||||
}
|
||||
|
||||
if (duplicateIDs.isEmpty()){
|
||||
dismiss()
|
||||
}
|
||||
}.onFailure {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun setupDialog(dialog: Dialog, style: Int) {
|
||||
super.setupDialog(dialog, style)
|
||||
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_already_exists_dialog, null)
|
||||
dialog.setContentView(view)
|
||||
dialog.window?.navigationBarColor = SurfaceColors.SURFACE_1.getColor(requireActivity())
|
||||
dialog.setOnShowListener {
|
||||
val behavior = BottomSheetBehavior.from(view.parent as View)
|
||||
val displayMetrics = DisplayMetrics()
|
||||
requireActivity().windowManager.defaultDisplay.getMetrics(displayMetrics)
|
||||
if(resources.getBoolean(R.bool.isTablet) || resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE){
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
behavior.peekHeight = displayMetrics.heightPixels
|
||||
}
|
||||
}
|
||||
|
||||
adapter = AlreadyExistsAdapter(this, requireActivity())
|
||||
recyclerView = view.findViewById(R.id.downloadMultipleRecyclerview)
|
||||
recyclerView.adapter = adapter
|
||||
recyclerView.enableFastScroll()
|
||||
|
||||
runBlocking {
|
||||
val items = withContext(Dispatchers.IO){
|
||||
downloadViewModel.getAllByIDs(duplicateIDs.map { it.downloadItemID })
|
||||
}
|
||||
duplicates = items.map { item -> AlreadyExistsItem(item, duplicateIDs.firstOrNull { it.downloadItemID == item.id }?.historyItemID) }.toMutableList()
|
||||
adapter.submitList(duplicates.toList())
|
||||
}
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.bottomsheet_download_button).setOnClickListener {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
downloadViewModel.deleteProcessing()
|
||||
val items = duplicates.map { it.downloadItem }
|
||||
items.forEach { it.id = 0 }
|
||||
downloadViewModel.queueDownloads(items, true)
|
||||
withContext(Dispatchers.Main){
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
super.onDismiss(dialog)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
downloadViewModel.deleteProcessing()
|
||||
downloadViewModel.deleteAllWithID(duplicates.map { it.downloadItem.id })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
override fun onEditItem(alreadyExistsItem: AlreadyExistsItem, position: Int) {
|
||||
val resultItem = downloadViewModel.createResultItemFromDownload(alreadyExistsItem.downloadItem)
|
||||
val onItemUpdated = object: ConfigureDownloadBottomSheetDialog.OnDownloadItemUpdateListener {
|
||||
override fun onDownloadItemUpdate(
|
||||
resultItemID: Long,
|
||||
item: DownloadItem
|
||||
) {
|
||||
val currentIndex = duplicates.indexOf(alreadyExistsItem)
|
||||
val current = duplicates[currentIndex]
|
||||
duplicates[currentIndex] = AlreadyExistsItem(item, current.historyID)
|
||||
adapter.submitList(duplicates)
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
val bottomSheet = ConfigureDownloadBottomSheetDialog(resultItem, alreadyExistsItem.downloadItem, onItemUpdated)
|
||||
bottomSheet.show(requireActivity().supportFragmentManager, "configureDownloadSingleSheet")
|
||||
}
|
||||
|
||||
override fun onDeleteItem(alreadyExistsItem: AlreadyExistsItem, position: Int) {
|
||||
UiUtil.showGenericDeleteDialog(requireContext(), alreadyExistsItem.downloadItem.title) {
|
||||
if (alreadyExistsItem.historyID == null) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
downloadViewModel.deleteDownload(alreadyExistsItem.downloadItem.id)
|
||||
}
|
||||
}
|
||||
duplicates.remove(alreadyExistsItem)
|
||||
if (duplicates.isEmpty()) {
|
||||
dismiss()
|
||||
}
|
||||
adapter.submitList(duplicates)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShowHistoryItem(historyItemID: Long) {
|
||||
lifecycleScope.launch {
|
||||
val historyItem = withContext(Dispatchers.IO){
|
||||
downloadViewModel.getHistoryItemById(historyItemID)
|
||||
}
|
||||
UiUtil.showHistoryItemDetailsCard(historyItem, requireActivity(), isPresent = true,
|
||||
removeItem = { item, deleteFile ->
|
||||
historyViewModel.delete(item, deleteFile)
|
||||
},
|
||||
redownloadItem = { },
|
||||
redownloadShowDownloadCard = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:id="@+id/parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
||||
android:id="@+id/drag_handle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/linearLayout3"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="20dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/drag_handle">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/hours_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintCircle="@id/parent"
|
||||
app:layout_constraintCircleRadius="0dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.0">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:text="@string/hour" />
|
||||
|
||||
<NumberPicker
|
||||
android:id="@+id/hours"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="100dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/minutes_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintCircle="@id/parent"
|
||||
app:layout_constraintCircleRadius="0dp"
|
||||
app:layout_constraintStart_toEndOf="@id/hours_container"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.0">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:text="@string/minute" />
|
||||
|
||||
<NumberPicker
|
||||
android:id="@+id/minutes"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="100dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/seconds_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintCircle="@id/parent"
|
||||
app:layout_constraintCircleRadius="0dp"
|
||||
app:layout_constraintStart_toEndOf="@id/minutes_container"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.0">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:text="@string/second" />
|
||||
|
||||
<NumberPicker
|
||||
android:id="@+id/seconds"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="100dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/milliseconds_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintCircle="@id/parent"
|
||||
app:layout_constraintCircleRadius="0dp"
|
||||
app:layout_constraintStart_toEndOf="@id/seconds_container"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.0">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:text="@string/milliseconds" />
|
||||
|
||||
<NumberPicker
|
||||
android:id="@+id/milliseconds"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="100dp"
|
||||
android:layout_gravity="center_horizontal" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="20dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="20dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bottom_sheet_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/download_already_exists"
|
||||
android:textSize="18sp"
|
||||
android:maxLines="2"
|
||||
android:layout_marginTop="5dp"
|
||||
android:singleLine="false"
|
||||
app:layout_constraintEnd_toStartOf="@+id/bottomsheet_download_button"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bottom_sheet_subtitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/download_already_exists_summary"
|
||||
android:textSize="11sp"
|
||||
app:layout_constraintEnd_toStartOf="@+id/bottomsheet_download_button"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/bottom_sheet_title" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/bottomsheet_download_button"
|
||||
style="@style/Widget.Material3.Button.ElevatedButton.Icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:autoLink="all"
|
||||
android:text="@string/download"
|
||||
app:icon="@drawable/ic_down"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/downloadMultipleRecyclerview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="20dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:paddingVertical="10dp"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:layout_height="wrap_content"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/drag_view"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|center_vertical"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingStart="0dp"
|
||||
android:tintMode="src_in"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_drag_handle"
|
||||
app:tint="?attr/colorControlNormal"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textContent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textStyle="bold"
|
||||
android:textSize="17sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toEndOf="@id/drag_view"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/edit"
|
||||
android:title="@string/edit" />
|
||||
|
||||
<item
|
||||
android:id="@+id/delete"
|
||||
android:title="@string/Remove" />
|
||||
|
||||
<item
|
||||
android:id="@+id/copy_url"
|
||||
android:icon="@drawable/ic_copy"
|
||||
android:title="@string/copy_url" />
|
||||
</menu>
|
||||
@ -0,0 +1,42 @@
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:context=".MainActivity" >
|
||||
|
||||
<item
|
||||
android:id="@+id/select_between"
|
||||
android:visible="false"
|
||||
android:title="@string/select_between"
|
||||
android:icon="@drawable/ic_select_between"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/delete_results"
|
||||
android:title="@string/remove_results"
|
||||
android:icon="@drawable/baseline_delete_24"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/download"
|
||||
android:title="@string/download"
|
||||
android:icon="@drawable/baseline_download_24"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/select_all"
|
||||
android:title="@string/select_all"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/invert_selected"
|
||||
android:title="@string/invert_selected"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/copy_urls"
|
||||
android:icon="@drawable/ic_delete_all"
|
||||
android:title="@string/copy_urls"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#Wed Oct 18 18:36:39 CEST 2023
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
Loading…
Reference in New Issue