DownloadController: Partial Compose conversion (#7969)

Item list is not changed as currently there is no fitting Compose component to
replace the drag-drop behavior.
This commit is contained in:
Ivan Iskandar 2022-09-10 09:29:40 +07:00 committed by GitHub
parent 07d1b9f3ba
commit fb9791f597
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 335 additions and 221 deletions

View File

@ -73,6 +73,7 @@ android {
signingConfig = debugType.signingConfig
versionNameSuffix = debugType.versionNameSuffix
applicationIdSuffix = debugType.applicationIdSuffix
matchingFallbacks.add("release")
}
}
@ -252,6 +253,7 @@ dependencies {
implementation(libs.insetter)
implementation(libs.markwon)
implementation(libs.aboutLibraries.compose)
implementation(libs.cascade)
// Conductor
implementation(libs.bundles.conductor)

View File

@ -25,6 +25,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -47,6 +49,9 @@ class DownloadService : Service() {
*/
val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
private val _isRunning = MutableStateFlow(false)
val isRunning = _isRunning.asStateFlow()
/**
* Starts this service.
*
@ -98,6 +103,7 @@ class DownloadService : Service() {
startForeground(Notifications.ID_DOWNLOAD_CHAPTER_PROGRESS, getPlaceholderNotification())
wakeLock = acquireWakeLock(javaClass.name)
runningRelay.call(true)
_isRunning.value = true
subscriptions = CompositeSubscription()
listenDownloaderState()
listenNetworkChanges()
@ -109,6 +115,7 @@ class DownloadService : Service() {
override fun onDestroy() {
ioScope?.cancel()
runningRelay.call(false)
_isRunning.value = false
subscriptions.unsubscribe()
downloadManager.stopDownloads()
wakeLock.releaseIfNeeded()

View File

@ -83,6 +83,8 @@ class DownloadQueue(
.startWith(Unit)
.map { this }
fun getUpdatedAsFlow(): Flow<List<Download>> = getUpdatedObservable().asFlow()
private fun setPagesFor(download: Download) {
if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) {
setPagesSubject(download.pages, null)

View File

@ -1,132 +1,316 @@
package eu.kanade.tachiyomi.ui.download
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.view.isVisible
import android.view.ViewGroup.MarginLayoutParams
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.ViewCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.components.Pill
import eu.kanade.presentation.components.Scaffold
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.databinding.DownloadControllerBinding
import eu.kanade.tachiyomi.databinding.DownloadListBinding
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.util.lang.launchUI
import me.saket.cascade.CascadeDropdownMenu
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
/**
* Controller that shows the currently active downloads.
* Uses R.layout.fragment_download_queue.
*/
class DownloadController :
NucleusController<DownloadControllerBinding, DownloadPresenter>(),
FabController,
FullComposeController<DownloadPresenter>(),
DownloadAdapter.DownloadItemListener {
private lateinit var controllerBinding: DownloadListBinding
/**
* Adapter containing the active downloads.
*/
private var adapter: DownloadAdapter? = null
private var actionFab: ExtendedFloatingActionButton? = null
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
/**
* Map of subscriptions for active downloads.
*/
private val progressSubscriptions by lazy { mutableMapOf<Download, Subscription>() }
/**
* Whether the download queue is running or not.
*/
private var isRunning: Boolean = false
init {
setHasOptionsMenu(true)
}
override fun createBinding(inflater: LayoutInflater) = DownloadControllerBinding.inflate(inflater)
override fun createPresenter(): DownloadPresenter {
return DownloadPresenter()
}
override fun getTitle(): String? {
return resources?.getString(R.string.label_download_queue)
}
override fun createPresenter() = DownloadPresenter()
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
viewScope.launchUI {
presenter.getDownloadStatusFlow()
.collect(this@DownloadController::onStatusChange)
}
// Check if download queue is empty and update information accordingly.
setInformationView()
// Initialize adapter.
adapter = DownloadAdapter(this@DownloadController)
binding.recycler.adapter = adapter
adapter?.isHandleDragEnabled = true
adapter?.fastScroller = binding.fastScroller
// Set the layout manager for the recycler and fixed size.
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.setHasFixedSize(true)
actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler)
// Subscribe to changes
DownloadService.runningRelay
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy { onQueueStatusChange(it) }
presenter.getDownloadStatusObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy { onStatusChange(it) }
presenter.getDownloadProgressObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy { onUpdateDownloadedPages(it) }
presenter.downloadQueue.getUpdatedObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeUntilDestroy {
updateTitle(it.size)
}
}
override fun configureFab(fab: ExtendedFloatingActionButton) {
actionFab = fab
fab.setOnClickListener {
val context = applicationContext ?: return@setOnClickListener
if (isRunning) {
DownloadService.stop(context)
presenter.pauseDownloads()
} else {
DownloadService.start(context)
}
setInformationView()
viewScope.launchUI {
presenter.getDownloadProgressFlow()
.collect(this@DownloadController::onUpdateDownloadedPages)
}
}
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
fab.setOnClickListener(null)
actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
actionFab = null
@Composable
override fun ComposeContent() {
val context = LocalContext.current
val downloadList by presenter.state.collectAsState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
var fabExpanded by remember { mutableStateOf(true) }
val nestedScrollConnection = remember {
// All this lines just for fab state :/
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
fabExpanded = available.y >= 0
return scrollBehavior.nestedScrollConnection.onPreScroll(available, source)
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
return scrollBehavior.nestedScrollConnection.onPostScroll(consumed, available, source)
}
override suspend fun onPreFling(available: Velocity): Velocity {
return scrollBehavior.nestedScrollConnection.onPreFling(available)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return scrollBehavior.nestedScrollConnection.onPostFling(consumed, available)
}
}
}
Scaffold(
topBar = {
AppBar(
titleContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.label_download_queue),
maxLines = 1,
modifier = Modifier.weight(1f, false),
overflow = TextOverflow.Ellipsis,
)
if (downloadList.isNotEmpty()) {
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
Pill(
text = "${downloadList.size}",
modifier = Modifier.padding(start = 4.dp),
color = MaterialTheme.colorScheme.onBackground
.copy(alpha = pillAlpha),
fontSize = 14.sp,
)
}
}
},
navigateUp = router::popCurrentController,
actions = {
if (downloadList.isNotEmpty()) {
val (expanded, onExpanded) = remember { mutableStateOf(false) }
Box {
IconButton(onClick = { onExpanded(!expanded) }) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.label_more),
)
}
CascadeDropdownMenu(
expanded = expanded,
onDismissRequest = { onExpanded(false) },
) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_reorganize_by)) },
children = {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_order_by_upload_date)) },
children = {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_newest)) },
onClick = {
reorderQueue({ it.download.chapter.date_upload }, true)
onExpanded(false)
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_oldest)) },
onClick = {
reorderQueue({ it.download.chapter.date_upload }, false)
onExpanded(false)
},
)
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_order_by_chapter_number)) },
children = {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_asc)) },
onClick = {
reorderQueue({ it.download.chapter.chapter_number }, false)
onExpanded(false)
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_desc)) },
onClick = {
reorderQueue({ it.download.chapter.chapter_number }, true)
onExpanded(false)
},
)
},
)
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_cancel_all)) },
onClick = {
presenter.clearQueue(context)
onExpanded(false)
},
)
}
}
}
},
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
AnimatedVisibility(
visible = downloadList.isNotEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
val isRunning by DownloadService.isRunning.collectAsState()
ExtendedFloatingActionButton(
text = {
val id = if (isRunning) {
R.string.action_pause
} else {
R.string.action_resume
}
Text(text = stringResource(id))
},
icon = {
val icon = if (isRunning) {
Icons.Default.Pause
} else {
Icons.Default.PlayArrow
}
Icon(imageVector = icon, contentDescription = null)
},
onClick = {
if (isRunning) {
DownloadService.stop(context)
presenter.pauseDownloads()
} else {
DownloadService.start(context)
}
},
expanded = fabExpanded,
modifier = Modifier.navigationBarsPadding(),
)
}
},
) { contentPadding ->
if (downloadList.isEmpty()) {
EmptyScreen(textResource = R.string.information_no_downloads)
return@Scaffold
}
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val left = with(density) { contentPadding.calculateLeftPadding(layoutDirection).toPx().roundToInt() }
val top = with(density) { contentPadding.calculateTopPadding().toPx().roundToInt() }
val right = with(density) { contentPadding.calculateRightPadding(layoutDirection).toPx().roundToInt() }
val bottom = with(density) { contentPadding.calculateBottomPadding().toPx().roundToInt() }
Box(modifier = Modifier.nestedScroll(nestedScrollConnection)) {
AndroidView(
factory = { context ->
controllerBinding = DownloadListBinding.inflate(LayoutInflater.from(context))
adapter = DownloadAdapter(this@DownloadController)
controllerBinding.recycler.adapter = adapter
adapter?.isHandleDragEnabled = true
adapter?.fastScroller = controllerBinding.fastScroller
controllerBinding.recycler.layoutManager = LinearLayoutManager(context)
ViewCompat.setNestedScrollingEnabled(controllerBinding.root, true)
controllerBinding.root
},
update = {
controllerBinding.recycler
.updatePadding(
left = left,
top = top,
right = right,
bottom = bottom,
)
controllerBinding.fastScroller
.updateLayoutParams<MarginLayoutParams> {
leftMargin = left
topMargin = top
rightMargin = right
bottomMargin = bottom
}
adapter?.updateDataSet(downloadList)
},
)
}
}
}
override fun onDestroyView(view: View) {
@ -138,32 +322,6 @@ class DownloadController :
super.onDestroyView(view)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.download_queue, menu)
}
override fun onPrepareOptionsMenu(menu: Menu) {
menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty()
menu.findItem(R.id.reorder).isVisible = !presenter.downloadQueue.isEmpty()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val context = applicationContext ?: return false
when (item.itemId) {
R.id.clear_queue -> {
DownloadService.stop(context)
presenter.clearQueue()
}
R.id.newest, R.id.oldest -> {
reorderQueue({ it.download.chapter.date_upload }, item.itemId == R.id.newest)
}
R.id.asc, R.id.desc -> {
reorderQueue({ it.download.chapter.chapter_number }, item.itemId == R.id.desc)
}
}
return super.onOptionsItemSelected(item)
}
private fun <R : Comparable<R>> reorderQueue(selector: (DownloadItem) -> R, reverse: Boolean = false) {
val adapter = adapter ?: return
val newDownloads = mutableListOf<Download>()
@ -242,30 +400,6 @@ class DownloadController :
progressSubscriptions.remove(download)?.unsubscribe()
}
/**
* Called when the queue's status has changed. Updates the visibility of the buttons.
*
* @param running whether the queue is now running or not.
*/
private fun onQueueStatusChange(running: Boolean) {
isRunning = running
activity?.invalidateOptionsMenu()
// Check if download queue is empty and update information accordingly.
setInformationView()
}
/**
* Called from the presenter to assign the downloads for the adapter.
*
* @param downloads the downloads from the queue.
*/
fun onNextDownloads(downloads: List<DownloadHeaderItem>) {
activity?.invalidateOptionsMenu()
setInformationView()
adapter?.updateDataSet(downloads)
}
/**
* Called when the progress of a download changes.
*
@ -291,39 +425,7 @@ class DownloadController :
* @return the holder of the download or null if it's not bound.
*/
private fun getHolder(download: Download): DownloadHolder? {
return binding.recycler.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder
}
/**
* Set information view when queue is empty
*/
private fun setInformationView() {
if (presenter.downloadQueue.isEmpty()) {
binding.emptyView.show(R.string.information_no_downloads)
actionFab?.isVisible = false
updateTitle()
} else {
binding.emptyView.hide()
actionFab?.apply {
isVisible = true
setText(
if (isRunning) {
R.string.action_pause
} else {
R.string.action_resume
},
)
setIconResource(
if (isRunning) {
R.drawable.ic_pause_24dp
} else {
R.drawable.ic_play_arrow_24dp
},
)
}
}
return controllerBinding.recycler.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder
}
/**
@ -373,7 +475,7 @@ class DownloadController :
?.filterIsInstance<DownloadItem>()
?.map(DownloadItem::download)
?.partition { item.download.manga.id == it.manga.id }
?: Pair(listOf<Download>(), listOf<Download>())
?: Pair(listOf(), listOf())
presenter.reorder(selectedSeries + otherSeries)
}
R.id.cancel_download -> {
@ -391,14 +493,4 @@ class DownloadController :
}
}
}
private fun updateTitle(queueSize: Int = 0) {
val defaultTitle = getTitle()
if (queueSize == 0) {
setTitle(defaultTitle)
} else {
setTitle("$defaultTitle ($queueSize)")
}
}
}

View File

@ -35,14 +35,25 @@ data class DownloadHeaderItem(
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is DownloadHeaderItem) {
return id == other.id && name == other.name
}
return false
if (javaClass != other?.javaClass) return false
other as DownloadHeaderItem
if (id != other.id) return false
if (name != other.name) return false
if (size != other.size) return false
if (subItemsCount != other.subItemsCount) return false
if (subItems !== other.subItems) return false
return true
}
override fun hashCode(): Int {
return id.hashCode()
var result = id.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + size
result = 31 * result + subItems.hashCode()
return result
}
init {

View File

@ -1,14 +1,21 @@
package eu.kanade.tachiyomi.ui.download
import android.content.Context
import android.os.Bundle
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import logcat.LogPriority
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.injectLazy
/**
@ -21,37 +28,34 @@ class DownloadPresenter : BasePresenter<DownloadController>() {
/**
* Property to get the queue from the download manager.
*/
val downloadQueue: DownloadQueue
private val downloadQueue: DownloadQueue
get() = downloadManager.queue
private val _state = MutableStateFlow(emptyList<DownloadHeaderItem>())
val state = _state.asStateFlow()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
downloadQueue.getUpdatedObservable()
.observeOn(AndroidSchedulers.mainThread())
.map { downloads ->
downloads
.groupBy { it.source }
.map { entry ->
DownloadHeaderItem(entry.key.id, entry.key.name, entry.value.size).apply {
addSubItems(0, entry.value.map { DownloadItem(it, this) })
presenterScope.launch {
downloadQueue.getUpdatedAsFlow()
.catch { error -> logcat(LogPriority.ERROR, error) }
.map { downloads ->
downloads
.groupBy { it.source }
.map { entry ->
DownloadHeaderItem(entry.key.id, entry.key.name, entry.value.size).apply {
addSubItems(0, entry.value.map { DownloadItem(it, this) })
}
}
}
}
.subscribeLatestCache(DownloadController::onNextDownloads) { _, error ->
logcat(LogPriority.ERROR, error)
}
}
.collect { newList -> _state.update { newList } }
}
}
fun getDownloadStatusObservable(): Observable<Download> {
return downloadQueue.getStatusObservable()
.startWith(downloadQueue.getActiveDownloads())
}
fun getDownloadStatusFlow() = downloadQueue.getStatusAsFlow()
fun getDownloadProgressObservable(): Observable<Download> {
return downloadQueue.getProgressObservable()
.onBackpressureBuffer()
}
fun getDownloadProgressFlow() = downloadQueue.getProgressAsFlow()
/**
* Pauses the download queue.
@ -63,7 +67,8 @@ class DownloadPresenter : BasePresenter<DownloadController>() {
/**
* Clears the download queue.
*/
fun clearQueue() {
fun clearQueue(context: Context) {
DownloadService.stop(context)
downloadManager.clearQueue()
}

View File

@ -30,6 +30,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginEnd="4dp"
android:paddingHorizontal="10dp"
android:paddingVertical="8dp"
android:scaleType="center"

View File

@ -87,6 +87,7 @@
android:id="@+id/menu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_toEndOf="@id/download_progress_text"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_menu"

View File

@ -11,7 +11,6 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="@dimen/fab_list_padding"
tools:listitem="@layout/download_item" />
<eu.kanade.tachiyomi.widget.MaterialFastScroll
@ -22,11 +21,4 @@
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
<eu.kanade.tachiyomi.widget.EmptyView
android:id="@+id/empty_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>

View File

@ -62,6 +62,7 @@ flexible-adapter-ui = "com.github.arkon.FlexibleAdapter:flexible-adapter-ui:c801
photoview = "com.github.chrisbanes:PhotoView:2.3.0"
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
cascade = "me.saket.cascade:cascade-compose:2.0.0-beta1"
conductor-core = { module = "com.bluelinelabs:conductor", version.ref = "conductor_version" }
conductor-support-preference = { module = "com.github.tachiyomiorg:conductor-support-preference", version.ref = "conductor_version" }