add batch download

This commit is contained in:
AmirHossein Abdolmotallebi 2024-09-22 03:47:14 +03:30
parent 55c4cd0279
commit bf4449c910
7 changed files with 685 additions and 24 deletions

View File

@ -4,6 +4,7 @@ import com.abdownloadmanager.desktop.pages.addDownload.AddDownloadComponent
import com.abdownloadmanager.desktop.pages.addDownload.AddDownloadConfig
import com.abdownloadmanager.desktop.pages.addDownload.multiple.AddMultiDownloadComponent
import com.abdownloadmanager.desktop.pages.addDownload.single.AddSingleDownloadComponent
import com.abdownloadmanager.desktop.pages.batchdownload.BatchDownloadComponent
import com.abdownloadmanager.desktop.pages.home.HomeComponent
import com.abdownloadmanager.desktop.pages.queue.QueuesComponent
import com.abdownloadmanager.desktop.pages.settings.SettingsComponent
@ -46,13 +47,13 @@ import kotlin.system.exitProcess
sealed interface AppEffects {
data class SimpleNotificationNotification(
val notificationModel: NotificationModel
val notificationModel: NotificationModel,
) : AppEffects
}
interface NotificationSender{
fun sendDialogNotification(title: String,description: String,type: MessageDialogType)
fun sendNotification(tag: Any,title:String,description: String,type: NotificationType)
interface NotificationSender {
fun sendDialogNotification(title: String, description: String, type: MessageDialogType)
fun sendNotification(tag: Any, title: String, description: String, type: NotificationType)
}
class AppComponent(
@ -117,6 +118,28 @@ class AppComponent(
}
).subscribeAsStateFlow()
class BatchDownloadConfig
private val batchDownload = SlotNavigation<BatchDownloadConfig>()
val batchDownloadSlot = childSlot(
batchDownload,
serializer = null,
key = "batchDownload",
childFactory = { _: BatchDownloadConfig, componentContext: ComponentContext ->
BatchDownloadComponent(
ctx = componentContext,
onClose = this::closeBatchDownload,
importLinks = {
openAddDownloadDialog(it.map {
DownloadCredentials(
link = it
)
})
}
)
}
).subscribeAsStateFlow()
fun openSettings() {
scope.launch {
@ -259,11 +282,11 @@ class AppComponent(
type: MessageDialogType,
) {
beep()
newDialogMessage(MessageDialogModel(title = title, description = description, type = type,))
newDialogMessage(MessageDialogModel(title = title, description = description, type = type))
}
private fun beep() {
if (appSettings.notificationSound.value){
if (appSettings.notificationSound.value) {
Toolkit.getDefaultToolkit().beep()
}
}
@ -285,6 +308,7 @@ class AppComponent(
)
)
}
init {
downloadSystem
.downloadEvents
@ -309,6 +333,7 @@ class AppComponent(
IntegrationResult.Inactive -> {
IntegrationPortBroadcaster.setIntegrationPortInFile(null)
}
is IntegrationResult.Success -> {
IntegrationPortBroadcaster.setIntegrationPortInFile(it.port)
}
@ -317,7 +342,7 @@ class AppComponent(
}
private fun onNewDownloadEvent(it: DownloadManagerEvents) {
if (it.context[ResumedBy]?.by !is User){
if (it.context[ResumedBy]?.by !is User) {
//only notify events that is started by user
return
}
@ -337,19 +362,19 @@ class AppComponent(
return
}
var isMaxTryReachedError = false
val actualCause = if (exception is TooManyErrorException){
isMaxTryReachedError=true
val actualCause = if (exception is TooManyErrorException) {
isMaxTryReachedError = true
exception.findActualDownloadErrorCause()
}else exception
} else exception
if (ExceptionUtils.isNormalCancellation(actualCause)) {
return
}
val prefix = if (isMaxTryReachedError) {
"Too Many Error: "
}else{
} else {
"Error: "
}
val reason = actualCause.message?:"Unknown"
val reason = actualCause.message ?: "Unknown"
sendNotification(
"downloadId=${it.downloadItem.id}",
title = it.downloadItem.name,
@ -369,7 +394,7 @@ class AppComponent(
override suspend fun openDownloadItem(id: Long) {
val item = downloadSystem.getDownloadItemById(id)
if (item==null){
if (item == null) {
sendNotification(
"Open File",
"Can't open file",
@ -380,6 +405,7 @@ class AppComponent(
}
openDownloadItem(item)
}
override fun openDownloadItem(downloadItem: DownloadItem) {
runCatching {
FileUtils.openFile(downloadSystem.getDownloadFile(downloadItem))
@ -396,7 +422,7 @@ class AppComponent(
override suspend fun openDownloadItemFolder(id: Long) {
val item = downloadSystem.getDownloadItemById(id)
if (item==null){
if (item == null) {
sendNotification(
"Open Folder",
"Can't open folder",
@ -423,7 +449,7 @@ class AppComponent(
}
override fun openAddDownloadDialog(
links: List<DownloadCredentials>
links: List<DownloadCredentials>,
) {
scope.launch {
//remove duplicates
@ -548,22 +574,23 @@ class AppComponent(
}
fun closeAbout() {
showAboutPage .update { false }
showAboutPage.update { false }
}
fun openOpenSourceLibraries() {
showOpenSourceLibraries .update { true }
showOpenSourceLibraries.update { true }
}
fun closeOpenSourceLibraries() {
showOpenSourceLibraries .update { false }
showOpenSourceLibraries.update { false }
}
fun openQueues() {
scope.launch {
showQueuesSlot.value.child?.instance.let {
if (it!=null){
if (it != null) {
it.bringToFront()
}else{
} else {
showQueues.activate(QueuePageConfig())
}
}
@ -574,6 +601,23 @@ class AppComponent(
showQueues.dismiss()
}
fun openBatchDownload() {
scope.launch {
batchDownloadSlot.value.child?.instance.let {
if (it != null) {
it.bringToFront()
} else {
batchDownload.activate(BatchDownloadConfig())
}
}
}
}
fun closeBatchDownload() {
batchDownload.dismiss()
}
var showCreateQueueDialog = MutableStateFlow(false)
private set
@ -613,10 +657,11 @@ class AppComponent(
IntegrationPortBroadcaster.isInitialized(),
).all { it }
}
// TODO enable updater
// TODO enable updater
// val updater = UpdateComponent(childContext("updater"))
val showAboutPage=MutableStateFlow(false)
val showOpenSourceLibraries=MutableStateFlow(false)
val showAboutPage = MutableStateFlow(false)
val showOpenSourceLibraries = MutableStateFlow(false)
val theme = appRepository.theme
// val uiScale = appRepository.uiScale
}

View File

@ -50,7 +50,12 @@ val newDownloadFromClipboardAction = simpleAction(
}
appComponent.openAddDownloadDialog(items)
}
val batchDownloadAction = simpleAction(
title = "Batch Download",
icon = MyIcons.download
) {
appComponent.openBatchDownload()
}
val stopQueueGroupAction = MenuItem.SubMenu(
icon = MyIcons.stop,
title = "Stop Queue",

View File

@ -0,0 +1,192 @@
package com.abdownloadmanager.desktop.pages.batchdownload
import com.abdownloadmanager.desktop.utils.BaseComponent
import com.abdownloadmanager.desktop.utils.ClipboardUtil
import com.abdownloadmanager.desktop.utils.mvi.ContainsEffects
import com.abdownloadmanager.desktop.utils.mvi.supportEffects
import com.abdownloadmanager.utils.isValidUrl
import com.arkivanov.decompose.ComponentContext
import ir.amirab.util.flow.combineStateFlows
import ir.amirab.util.flow.mapStateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.max
sealed interface BatchDownloadEffects {
data object BringToFront : BatchDownloadEffects
}
class BatchDownloadComponent(
ctx: ComponentContext,
val onClose: () -> Unit,
val importLinks: (List<String>) -> Unit,
) : BaseComponent(ctx), ContainsEffects<BatchDownloadEffects> by supportEffects() {
private val _link = MutableStateFlow("")
val link = _link.asStateFlow()
fun setLink(link: String) {
_link.value = link
}
private val _start = MutableStateFlow("")
val start = _start.asStateFlow()
fun setStart(start: String) {
_start.value = start
}
private val _end = MutableStateFlow("")
val end = _end.asStateFlow()
fun setEnd(end: String) {
_end.value = end
}
private val _wildcardLength = MutableStateFlow<WildcardLength>(WildcardLength.Auto)
val wildcardLength = _wildcardLength
fun setWildCardLength(wildcardLength: WildcardLength) {
_wildcardLength.value = wildcardLength
}
init {
fillLinkIfUrlIsInClipboard()
}
private fun fillLinkIfUrlIsInClipboard() {
scope.launch {
withContext(Dispatchers.Default) {
val clipboard = ClipboardUtil.read() ?: return@withContext
if (isValidUrl(clipboard)) {
setLink(clipboard.trim())
}
}
}
}
@Suppress("NAME_SHADOWING")
private val batch = combineStateFlows(
link,
start,
end,
wildcardLength,
) { link, start, end, wildcardLength ->
val minimumSize = max(start.length, end.length)
val start = start.toIntOrNull() ?: return@combineStateFlows null
val end = end.toIntOrNull() ?: return@combineStateFlows null
if (start < 0) return@combineStateFlows null
if (end < 0 || end < start) return@combineStateFlows null
WildcardString(
string = link.trim(),
range = start..end,
wildcardLength = wildcardLength,
minimumAllowed = minimumSize,
)
}
fun bringToFront() {
sendEffect(BatchDownloadEffects.BringToFront)
}
val startLinkResult: StateFlow<String> = batch
.mapStateFlow { it?.first() ?: "" }
val endLinkResult: StateFlow<String> = batch
.mapStateFlow { it?.last() ?: "" }
val validationResult = batch.mapStateFlow {
when (it) {
null -> BatchDownloadValidationResult.Others
else -> {
val listSize = it.size()
when {
listSize < 1 -> BatchDownloadValidationResult.Others
listSize > MAX_ALLOWED_RANGE -> BatchDownloadValidationResult.MaxRangeExceed(MAX_ALLOWED_RANGE)
!isValidUrl(it.first()) -> BatchDownloadValidationResult.URLInvalid
else -> BatchDownloadValidationResult.Ok
}
}
}
}
val canConfirm = validationResult.mapStateFlow {
it is BatchDownloadValidationResult.Ok
}
fun confirm() {
if (!canConfirm.value) {
println(batch.value?.toList())
return
}
val items = batch.value?.toList()?.takeIf { it.isNotEmpty() }
if (items != null) {
importLinks(items)
}
onClose()
}
companion object {
const val MAX_ALLOWED_RANGE = 1000
}
}
sealed interface BatchDownloadValidationResult {
data object Ok : BatchDownloadValidationResult
data object Others : BatchDownloadValidationResult
data class MaxRangeExceed(val allowed: Int) : BatchDownloadValidationResult
data object URLInvalid : BatchDownloadValidationResult
}
sealed class WildcardLength {
data object Auto : WildcardLength()
data object Unspecified : WildcardLength()
data class Custom(val v: Int) : WildcardLength()
}
data class WildcardString(
val string: String,
val range: IntRange,
val wildcardLength: WildcardLength,
val minimumAllowed: Int = range.last.toString().length,
) : Iterable<String> {
private fun transformIndex(index: Int): String {
var str = index.toString()
if (wildcardLength is WildcardLength.Unspecified) {
return str
}
val length = when (wildcardLength) {
is WildcardLength.Custom -> wildcardLength.v.coerceAtLeast(minimumAllowed)
WildcardLength.Auto -> minimumAllowed
WildcardLength.Unspecified -> null
}
if (length != null) {
str = str.padStart(length, '0')
}
return str
}
fun get(index: Int): String {
return string.replace("*", transformIndex(index))
}
fun first(): String {
return get(range.first)
}
fun last(): String {
return get(range.last)
}
fun size() = range.last - range.first + 1
override fun iterator(): Iterator<String> {
return range
.asSequence()
.map(::get)
.iterator()
}
}

View File

@ -0,0 +1,28 @@
package com.abdownloadmanager.desktop.pages.batchdownload
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberWindowState
import com.abdownloadmanager.desktop.ui.customwindow.CustomWindow
import com.abdownloadmanager.desktop.utils.mvi.HandleEffects
@Composable
fun BatchDownloadWindow(batchDownloadComponent: BatchDownloadComponent) {
CustomWindow(
state = rememberWindowState(
size = DpSize(500.dp, 420.dp),
position = WindowPosition(Alignment.Center)
),
onCloseRequest = batchDownloadComponent.onClose
) {
HandleEffects(batchDownloadComponent) {
when (it) {
BatchDownloadEffects.BringToFront -> window.toFront()
}
}
BatchDownload(batchDownloadComponent)
}
}

View File

@ -0,0 +1,385 @@
package com.abdownloadmanager.desktop.pages.batchdownload
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.desktop.pages.addDownload.single.MyTextFieldIcon
import com.abdownloadmanager.desktop.pages.batchdownload.WildcardSelect.*
import com.abdownloadmanager.desktop.ui.customwindow.WindowTitle
import com.abdownloadmanager.desktop.ui.icon.MyIcons
import com.abdownloadmanager.desktop.ui.theme.myColors
import com.abdownloadmanager.desktop.ui.theme.myTextSizes
import com.abdownloadmanager.desktop.ui.util.ifThen
import com.abdownloadmanager.desktop.ui.widget.*
import com.abdownloadmanager.desktop.utils.ClipboardUtil
import com.abdownloadmanager.desktop.utils.div
import com.abdownloadmanager.utils.compose.LocalContentColor
import com.abdownloadmanager.utils.compose.WithContentAlpha
import com.abdownloadmanager.utils.compose.widget.MyIcon
import ir.amirab.util.compose.IconSource
@Composable
fun BatchDownload(
component: BatchDownloadComponent,
) {
WindowTitle("Batch Download")
val link by component.link.collectAsState()
val setLink = component::setLink
val start by component.start.collectAsState()
val setStart = component::setStart
val end by component.end.collectAsState()
val setEnd = component::setEnd
val scrollState = rememberScrollState()
val scrollAdapter = rememberScrollbarAdapter(scrollState)
val validationResult by component.validationResult.collectAsState()
val linkFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
linkFocusRequester.requestFocus()
}
Column(Modifier.padding(16.dp)) {
Row(Modifier.weight(1f)) {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(scrollState)
) {
LabeledContent(
label = {
Text("Enter a link that contains wildcards (use *)")
},
content = {
BatchDownloadPageTextField(
text = link,
onTextChange = setLink,
placeholder = "Link: https://example.com/photo-*.png",
modifier = Modifier
.focusRequester(linkFocusRequester)
.fillMaxWidth(),
start = {
MyTextFieldIcon(MyIcons.link)
},
end = {
MyTextFieldIcon(MyIcons.paste, {
val v = ClipboardUtil.read()
if (v != null) {
setLink(v)
}
})
},
errorText = when (val v = validationResult) {
BatchDownloadValidationResult.URLInvalid -> {
"Invalid URL"
}
is BatchDownloadValidationResult.MaxRangeExceed -> "List is too large! maximum ${v.allowed} items allowed"
BatchDownloadValidationResult.Others -> null
BatchDownloadValidationResult.Ok -> null
}
)
}
)
Spacer(Modifier.height(8.dp))
LabeledContent(
label = {
Text("Enter range")
},
content = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
BatchDownloadPageTextField(
text = start,
onTextChange = setStart,
placeholder = "Start",
modifier = Modifier.width(80.dp),
start = {
Text("A", Modifier.padding(horizontal = 8.dp))
}
)
Spacer(Modifier.width(8.dp))
Text("To")
Spacer(Modifier.width(8.dp))
BatchDownloadPageTextField(
text = end,
onTextChange = setEnd,
placeholder = "End",
modifier = Modifier.width(80.dp),
start = {
Text("B", Modifier.padding(horizontal = 8.dp))
}
)
}
}
)
Spacer(Modifier.height(8.dp))
LabeledContent(
label = {
Text("Wildcard length")
},
content = {
WildcardLengthUi(
component.wildcardLength.collectAsState().value,
component::setWildCardLength
)
}
)
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
val lineModifier = Modifier
.height(1.dp)
.padding(horizontal = 5.dp)
.background(LocalContentColor.current.copy(0.05f))
Spacer(Modifier.padding(vertical = 4.dp).fillMaxWidth().then(lineModifier))
}
Spacer(Modifier.height(8.dp))
LabeledContent(
label = {
Text("First Link")
},
content = {
Text(
component.startLinkResult.collectAsState().value,
Modifier
.fillMaxWidth()
.background(myColors.surface)
.padding(2.dp)
)
}
)
Spacer(Modifier.height(8.dp))
LabeledContent(
label = {
Text("Last Link")
},
content = {
Text(
component.endLinkResult.collectAsState().value,
Modifier
.fillMaxWidth()
.background(myColors.surface)
.padding(2.dp)
)
}
)
}
VerticalScrollbar(scrollAdapter, Modifier.fillMaxHeight())
}
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
) {
ActionButton(
text = "OK",
enabled = component.canConfirm.collectAsState().value,
onClick = component::confirm
)
Spacer(Modifier.width(8.dp))
ActionButton("Cancel", onClick = component.onClose)
}
}
}
enum class WildcardSelect {
Auto, Unspecified, Custom;
companion object {
fun fromWildcardLength(wildcardLength: WildcardLength): WildcardSelect {
return when (wildcardLength) {
WildcardLength.Auto -> Auto
is WildcardLength.Custom -> Custom
WildcardLength.Unspecified -> Unspecified
}
}
}
}
@Composable
private fun WildcardLengthUi(
wildcardLength: WildcardLength,
onChangeWildcardLength: (WildcardLength) -> Unit,
) {
var customLength by remember {
mutableStateOf(2)
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Multiselect(
selections = WildcardSelect.entries,
selectedItem = WildcardSelect.fromWildcardLength(wildcardLength),
onSelectionChange = {
onChangeWildcardLength(
when (it) {
Auto -> WildcardLength.Auto
Unspecified -> WildcardLength.Unspecified
Custom -> WildcardLength.Custom(customLength)
}
)
},
render = {
Text(it.toString())
}
)
AnimatedVisibility(wildcardLength is WildcardLength.Custom) {
Row {
Spacer(Modifier.width(8.dp))
IntTextField(
value = customLength,
onValueChange = {
customLength = it
onChangeWildcardLength(
WildcardLength.Custom(it)
)
},
range = 1..10,
keyboardOptions = KeyboardOptions.Default,
modifier = Modifier.width(72.dp)
)
}
}
}
}
@Composable
private fun <T> Multiselect(
selections: List<T>,
selectedItem: T,
onSelectionChange: (T) -> Unit,
render: @Composable (T) -> Unit,
) {
val shape = RoundedCornerShape(6.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(shape)
.background(myColors.surface)
) {
for (item in selections) {
val isSelected = item == selectedItem
Box(
Modifier
.padding(vertical = 4.dp, horizontal = 4.dp)
.clip(shape)
.ifThen(isSelected) {
background(LocalContentColor.current / 10)
}
.clickable {
onSelectionChange(item)
}
.padding(vertical = 2.dp, horizontal = 4.dp)
) {
WithContentAlpha(
if (isSelected) {
1f
} else {
0.5f
}
) {
render(item)
}
}
}
}
}
@Composable
private fun LabeledContent(
label: @Composable () -> Unit,
content: @Composable () -> Unit,
) {
Column {
label()
Spacer(Modifier.height(8.dp))
content()
}
}
@Composable
private fun BatchDownloadPageTextField(
text: String,
onTextChange: (String) -> Unit,
placeholder: String,
modifier: Modifier,
errorText: String? = null,
start: @Composable() (() -> Unit)? = null,
end: @Composable() (() -> Unit)? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val dividerModifier = Modifier
.fillMaxHeight()
.padding(vertical = 1.dp)
//to not conflict with text-field border
.width(1.dp)
.background(if (isFocused) myColors.onBackground / 10 else Color.Transparent)
Column(modifier) {
MyTextField(
text,
onTextChange,
placeholder,
modifier = Modifier.fillMaxWidth(),
background = myColors.surface / 50,
interactionSource = interactionSource,
shape = RoundedCornerShape(6.dp),
start = start?.let {
{
WithContentAlpha(0.5f) {
it()
}
Spacer(dividerModifier)
}
},
end = end?.let {
{
Spacer(dividerModifier)
it()
}
}
)
AnimatedVisibility(errorText != null) {
if (errorText != null) {
Text(
errorText,
Modifier.padding(bottom = 4.dp, start = 4.dp),
fontSize = myTextSizes.sm,
color = myColors.error,
)
}
}
}
}
@Composable
private fun MyTextFieldIcon(
icon: IconSource,
onClick: (() -> Unit)? = null,
) {
MyIcon(icon, null, Modifier
.fillMaxHeight()
.ifThen(onClick != null) {
pointerHoverIcon(PointerIcon.Default)
.clickable { onClick?.invoke() }
}
.wrapContentHeight()
.padding(horizontal = 8.dp)
.size(16.dp))
}

View File

@ -322,6 +322,7 @@ class HomeComponent(
subMenu("File") {
+newDownloadAction
+newDownloadFromClipboardAction
+batchDownloadAction
separator()
+exitAction

View File

@ -25,6 +25,7 @@ import com.abdownloadmanager.desktop.utils.isInDebugMode
import com.abdownloadmanager.desktop.utils.mvi.HandleEffects
import androidx.compose.runtime.*
import androidx.compose.ui.window.*
import com.abdownloadmanager.desktop.pages.batchdownload.BatchDownloadWindow
import com.abdownloadmanager.desktop.pages.home.HomeWindow
import com.abdownloadmanager.desktop.pages.settings.ThemeManager
import com.abdownloadmanager.utils.compose.ProvideDebugInfo
@ -71,6 +72,10 @@ object Ui : KoinComponent {
showQueuesSlot.child?.instance?.let {
QueuesWindow(it)
}
val batchDownloadSlot = appComponent.batchDownloadSlot.collectAsState().value
batchDownloadSlot.child?.instance?.let {
BatchDownloadWindow(it)
}
ShowAddDownloadDialogs(appComponent)
ShowDownloadDialogs(appComponent)
//TODO Enable Updater