Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,8 @@ class AddTorrentRepository(
suspend fun getDefaultSavePath(serverId: Int) = requestManager.request(serverId) { service ->
service.getDefaultSavePath()
}

suspend fun getDirectoryContent(serverId: Int, path: String) = requestManager.request(serverId) { service ->
service.getDirectoryContent(path)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ val appModule = module {
viewModel { (serverId: Int, hash: String) -> TorrentPeersViewModel(serverId, hash, get(), get(), get()) }
viewModel { (serverId: Int, hash: String) -> TorrentWebSeedsViewModel(serverId, hash, get(), get()) }

viewModel { (initialServerId: Int?) -> AddTorrentViewModel(initialServerId, get(), get(), get()) }
viewModel { (initialServerId: Int?) -> AddTorrentViewModel(initialServerId, get(), get(), get(), get()) }

viewModel { (serverId: Int) -> RssFeedsViewModel(serverId, get()) }
viewModel { (serverId: Int, feedPath: List<String>, uid: String?) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ class TorrentService(

suspend fun getDefaultSavePath(): Response<String> = get("app/defaultSavePath")

suspend fun getDirectoryContent(path: String): Response<List<String>> = get(
"app/getDirectoryContent",
mapOf("dirPath" to path),
)

suspend fun shutdown(): Response<String> = post("app/shutdown")

suspend fun getLog(): Response<List<Log>> = get("log/main")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
Expand All @@ -32,6 +34,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
Expand Down Expand Up @@ -68,11 +71,17 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.lifecycle.compose.collectAsStateWithLifecycle
Expand Down Expand Up @@ -881,24 +890,93 @@ fun AddTorrentScreen(
}
}

OutlinedTextField(
value = savePath,
onValueChange = { savePath = it },
label = {
Text(
text = stringResource(Res.string.torrent_add_save_path),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
modifier = Modifier.fillMaxWidth(),
enabled = autoTmmIndex != 2,
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
)
val directorySuggestions by viewModel.directorySuggestions.collectAsStateWithLifecycle()
var savePathExpanded by remember { mutableStateOf(false) }

// Hide suggestions when IME is folded
val density = LocalDensity.current
val isImeVisible = WindowInsets.ime.getBottom(density) > 0
LaunchedEffect(isImeVisible) {
if (!isImeVisible) {
savePathExpanded = false
}
}

ExposedDropdownMenuBox(
expanded = savePathExpanded,
onExpandedChange = { savePathExpanded = it },
) {
OutlinedTextField(
value = savePath,
onValueChange = {
savePath = it
savePathExpanded = true
viewModel.setSavePath(it.text)
},
label = {
Text(
text = stringResource(Res.string.torrent_add_save_path),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
enabled = autoTmmIndex != 2,
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = {
savePathExpanded = false
defaultKeyboardAction(ImeAction.Next)
},
),
)

if (directorySuggestions.isNotEmpty()) {
ExposedDropdownMenu(
expanded = savePathExpanded,
onDismissRequest = { savePathExpanded = false },
modifier = Modifier.heightIn(max = 240.dp),
) {
directorySuggestions.forEach { suggestion ->
DropdownMenuItem(
text = {
Text(
text = buildAnnotatedString {
val matchLength = savePath.text.length
if (suggestion.startsWith(savePath.text, ignoreCase = true)) {
withStyle(
style = SpanStyle(background = Color.LightGray.copy(alpha = 0.5f)),
) {
append(suggestion.take(matchLength))
}
append(suggestion.drop(matchLength))
} else {
append(suggestion)
}
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = {
savePath = TextFieldValue(
text = suggestion,
selection = TextRange(suggestion.length),
)
savePathExpanded = true
viewModel.setSavePath(suggestion)
},
)
}
}
}
}

OutlinedTextField(
value = torrentName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope
import dev.bartuzen.qbitcontroller.data.ServerManager
import dev.bartuzen.qbitcontroller.data.repositories.AddTorrentRepository
import dev.bartuzen.qbitcontroller.model.Category
import dev.bartuzen.qbitcontroller.model.QBittorrentVersion
import dev.bartuzen.qbitcontroller.network.RequestManager
import dev.bartuzen.qbitcontroller.network.RequestResult
import dev.bartuzen.qbitcontroller.utils.getSerializableStateFlow
import io.github.vinceglb.filekit.PlatformFile
Expand All @@ -15,21 +17,28 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.io.files.FileNotFoundException
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.math.max

class AddTorrentViewModel(
initialServerId: Int?,
private val savedStateHandle: SavedStateHandle,
private val repository: AddTorrentRepository,
private val serverManager: ServerManager,
private val requestManager: RequestManager,
) : ViewModel() {
private val eventChannel = Channel<Event>()
val eventFlow = eventChannel.receiveAsFlow()
Expand All @@ -48,7 +57,16 @@ class AddTorrentViewModel(
private val _isAdding = MutableStateFlow(false)
val isAdding = _isAdding.asStateFlow()

private val savePath = MutableStateFlow<String?>("")

private val directoryInfo = MutableStateFlow<DirectoryInfo?>(null)

val directorySuggestions = directoryInfo
.map { it?.content ?: emptyList() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

private var loadCategoryTagJob: Job? = null
private var searchDirectoriesJob: Job? = null

init {
viewModelScope.launch {
Expand All @@ -71,6 +89,16 @@ class AddTorrentViewModel(
}
}
}

viewModelScope.launch {
combine(savePath, serverId) { path, serverId ->
Pair(path, serverId)
}.collectLatest { (path, serverId) ->
if (serverId != null && path != null) {
searchDirectories(serverId, path)
}
}
}
}

fun addTorrent(
Expand Down Expand Up @@ -218,6 +246,74 @@ class AddTorrentViewModel(
}
}

private fun searchDirectories(serverId: Int, path: String) {
searchDirectoriesJob?.cancel()

val currentDirectoryInfo = directoryInfo.value
if (serverId != currentDirectoryInfo?.serverId) {
directoryInfo.value = null
}

if (path.isBlank()) {
directoryInfo.value = currentDirectoryInfo?.copy(content = emptyList())
return
}

val version = requestManager.getQBittorrentVersion(serverId)
if (version < QBittorrentVersion(5, 0, 0)) {
return
}

searchDirectoriesJob = viewModelScope.launch {
if (directoryInfo.value != null) {
delay(300)
}

val pathDeferred = async {
when (val result = repository.getDirectoryContent(serverId, path)) {
is RequestResult.Success -> {
result.data
}
is RequestResult.Error -> {
emptyList()
}
}
}

val parentDeferred = async {
if (path.endsWith("/") || path.endsWith("\\")) {
return@async emptyList()
}

val lastSeparatorIndex = max(
path.lastIndexOf('/'),
path.lastIndexOf('\\'),
)
if (lastSeparatorIndex == -1) {
return@async emptyList()
}

val parent = path.take(lastSeparatorIndex + 1)
when (val result = repository.getDirectoryContent(serverId, parent)) {
is RequestResult.Success -> {
result.data
}
is RequestResult.Error -> {
emptyList()
}
}
}

directoryInfo.value = DirectoryInfo(
serverId = serverId,
content = (pathDeferred.await() + parentDeferred.await())
.distinct()
.filter { it.startsWith(path, ignoreCase = true) }
.sortedWith(String.CASE_INSENSITIVE_ORDER),
)
}
}

fun setServerId(serverId: Int?) {
savedStateHandle["serverId"] = serverId
}
Expand All @@ -230,6 +326,10 @@ class AddTorrentViewModel(
savedStateHandle["isLoading"] = isLoading
}

fun setSavePath(path: String) {
savePath.value = path
}

sealed class Event {
data class Error(val error: RequestResult.Error) : Event()
data object NoServersFound : Event()
Expand All @@ -247,3 +347,8 @@ data class ServerData(
val tagList: List<String>,
val defaultSavePath: String,
)

private data class DirectoryInfo(
val serverId: Int,
val content: List<String>,
)