Implement sort modes and random walk for downloads (#767)

* Implement sort modes and random walk for downloads

* Use `PrefDelegate<T>.asMutableState` instead

BREAKING CHANGE: Upgrade database.
This commit is contained in:
FooIbar authored and GitHub committed 2024-02-20 11:50:37 +00:00
1 parent c57fd0a923
commit 953e216dde
25 files changed
+836 -335

No files matched your search

@@ -0,0 +1,465 @@
{
"formatVersion": 1,
"database": {
"version": 19,
"identityHash": "edd2f8e628072889f7702b50eb2c44a1",
"entities": [
{
"tableName": "GALLERIES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "TOKEN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbKey",
"columnName": "THUMB",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "posted",
"columnName": "POSTED",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "UPLOADER",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "RATING",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "simpleLanguage",
"columnName": "SIMPLE_LANGUAGE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favoriteSlot",
"columnName": "FAVORITE_SLOT",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DOWNLOAD_LABELS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_DOWNLOAD_LABELS_LABEL",
"unique": true,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": []
},
{
"tableName": "DOWNLOADS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "STATE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "legacy",
"columnName": "LEGACY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [
{
"name": "index_DOWNLOADS_LABEL",
"unique": false,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
},
{
"table": "DOWNLOAD_LABELS",
"onDelete": "SET NULL",
"onUpdate": "CASCADE",
"columns": [
"LABEL"
],
"referencedColumns": [
"LABEL"
]
}
]
},
{
"tableName": "DOWNLOAD_DIRNAME",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dirname",
"columnName": "DIRNAME",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "FILTER",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "TEXT",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "ENABLE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HISTORY",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "LOCAL_FAVORITES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "PROGRESS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "PAGE",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "QUICK_SEARCH",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "KEYWORD",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "advanceSearch",
"columnName": "ADVANCE_SEARCH",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minRating",
"columnName": "MIN_RATING",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageFrom",
"columnName": "PAGE_FROM",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageTo",
"columnName": "PAGE_TO",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'edd2f8e628072889f7702b50eb2c44a1')"
]
}
}
@@ -57,7 +57,7 @@ object EhDB {
}
}
suspend fun updateDownloadInfo(downloadInfo: List<DownloadInfo>) {
suspend fun updateDownloadInfo(downloadInfo: Collection<DownloadInfo>) {
val dao = db.downloadsDao()
dao.update(downloadInfo.map(DownloadInfo::downloadInfo))
}
@@ -70,7 +70,6 @@ object EhDB {
suspend fun removeDownloadInfo(downloadInfo: DownloadInfo) {
val dao = db.downloadsDao()
dao.delete(downloadInfo.downloadInfo)
dao.fill(downloadInfo.position)
deleteGalleryInfo(downloadInfo.galleryInfo)
}
@@ -82,7 +81,6 @@ object EhDB {
dao.delete(it.downloadInfo)
deleteGalleryInfo(it.galleryInfo)
}
dao.fill(downloadInfo.first().position, downloadInfo.size)
}
suspend fun getDownloadDirname(gid: Long): String? {
@@ -106,6 +104,9 @@ object EhDB {
dao.insertOrIgnore(downloadDirnameList)
}
val downloadsCount
get() = db.downloadsDao().count()
suspend fun getAllDownloadLabelList() = db.downloadLabelDao().list()
suspend fun addDownloadLabel(raw: DownloadLabel): DownloadLabel {
@@ -12,6 +12,7 @@ import androidx.compose.runtime.remember
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.download.SortMode
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import java.util.Locale
@@ -108,8 +109,11 @@ object Settings : DataStorePreferences(null) {
val thumbColumns = intPref("thumb_columns", 3)
val listThumbSize = intPref("list_tile_size", 40)
val languageFilter = intPref("language_filter", -1)
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
val blackDarkTheme = boolPref("black_dark_theme", false)
val gridView = boolPref("grid_view", false)
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
@@ -150,7 +154,6 @@ object Settings : DataStorePreferences(null) {
var downloadOriginImage by boolPref("download_origin_image", false)
var archiveMetadata by boolPref("archive_metadata", true)
var enableCronet by boolPref("enable_cronet", true)
var gridView by boolPref("grid_view", false)
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
var defaultFavSlot by intPref("default_favorite_slot", -2)
var securityDelay by intPref("require_unlock_delay", 0)
@@ -161,7 +164,6 @@ object Settings : DataStorePreferences(null) {
var lastDawnDays by intPref("last_dawn_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
var recentDownloadLabel by stringOrNullPref("recent_download_label", null)
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
var displayName by stringOrNullPref("display_name", null)
var avatar by stringOrNullPref("avatar", null)
@@ -5,7 +5,6 @@ interface AbstractDownloadInfo {
var legacy: Int
var time: Long
var label: String?
var position: Int
var speed: Long
var remaining: Long
var finished: Int
@@ -15,6 +15,7 @@
*/
package com.hippo.ehviewer.client.data
import com.hippo.ehviewer.util.containsIgnoreCase
import java.util.regex.Pattern
import moe.tarsin.kt.unreachable
@@ -146,3 +147,6 @@ fun GalleryInfo.findBaseInfo(): BaseGalleryInfo {
fun GalleryInfo.asGalleryDetail(): GalleryDetail? {
return this as? GalleryDetail
}
operator fun GalleryInfo.contains(keyword: String): Boolean =
arrayOf(title, titleJpn, uploader).any { it.containsIgnoreCase(keyword) }
@@ -107,12 +107,15 @@ class ThumbKeyMigration : AutoMigrationSpec {
}
}
@RenameColumn(tableName = "DOWNLOADS", fromColumnName = "POSITION", toColumnName = "TIME")
class DownloadsMigration : AutoMigrationSpec
@Database(
entities = [
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class,
Filter::class, HistoryInfo::class, LocalFavoriteInfo::class, ProgressInfo::class, QuickSearch::class,
],
version = 18,
version = 19,
exportSchema = true,
autoMigrations = [
AutoMigration(
@@ -178,6 +181,11 @@ class ThumbKeyMigration : AutoMigrationSpec {
from = 17,
to = 18,
),
AutoMigration(
from = 18,
to = 19,
spec = DownloadsMigration::class,
),
],
)
@TypeConverters(FilterModeConverter::class)
@@ -42,9 +42,6 @@ data class DownloadEntity(
@ColumnInfo(name = "LABEL", index = true)
override var label: String? = null,
@ColumnInfo(name = "POSITION")
override var position: Int = 0,
@Ignore
override var speed: Long = 0,
@@ -8,10 +8,10 @@ import androidx.room.Update
@Dao
interface DownloadLabelDao {
@Query("SELECT * FROM DOWNLOAD_LABELS ORDER BY POSITION ASC")
@Query("SELECT * FROM DOWNLOAD_LABELS ORDER BY POSITION")
suspend fun list(): List<DownloadLabel>
@Query("SELECT * FROM DOWNLOAD_LABELS ORDER BY POSITION ASC LIMIT :limit OFFSET :offset")
@Query("SELECT * FROM DOWNLOAD_LABELS ORDER BY POSITION LIMIT :limit OFFSET :offset")
suspend fun list(offset: Int, limit: Int): List<DownloadLabel>
@Query("UPDATE DOWNLOAD_LABELS SET POSITION = POSITION - 1 WHERE POSITION > :position")
@@ -3,23 +3,26 @@ package com.hippo.ehviewer.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface DownloadsDao {
@Query("SELECT * FROM DOWNLOADS ORDER BY POSITION DESC")
@Suppress("ktlint:standard:annotation")
@Query("SELECT LABEL, COUNT(*) AS COUNT FROM DOWNLOADS LEFT JOIN DOWNLOAD_LABELS USING(LABEL) GROUP BY LABEL")
fun count(): Flow<Map<@MapColumn("LABEL") String?, @MapColumn("COUNT") Int>>
@Query("SELECT * FROM DOWNLOADS ORDER BY TIME")
suspend fun list(): List<DownloadEntity>
@Transaction
@Query("SELECT * FROM DOWNLOADS LEFT JOIN DOWNLOAD_DIRNAME USING(GID) ORDER BY POSITION DESC")
@Query("SELECT * FROM DOWNLOADS LEFT JOIN DOWNLOAD_DIRNAME USING(GID) ORDER BY TIME DESC")
suspend fun joinList(): List<DownloadInfo>
@Query("UPDATE DOWNLOADS SET POSITION = POSITION - :offset WHERE POSITION > :position")
suspend fun fill(position: Int, offset: Int = 1)
@Update
suspend fun update(downloadInfo: List<DownloadEntity>)
@@ -11,7 +11,7 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
@Dao
interface HistoryDao {
@Query("SELECT * FROM HISTORY ORDER BY TIME DESC")
@Query("SELECT * FROM HISTORY ORDER BY TIME")
suspend fun list(): List<HistoryInfo>
@RewriteQueriesToDropUnusedColumns
@@ -15,7 +15,7 @@ interface LocalFavoritesDao {
@Query("SELECT COUNT(*) FROM LOCAL_FAVORITES")
fun count(): Flow<Int>
@Query("SELECT * FROM LOCAL_FAVORITES ORDER BY TIME DESC")
@Query("SELECT * FROM LOCAL_FAVORITES ORDER BY TIME")
suspend fun list(): List<LocalFavoriteInfo>
@RewriteQueriesToDropUnusedColumns
@@ -27,7 +27,7 @@ interface LocalFavoritesDao {
fun joinListLazy(title: String): PagingSource<Int, BaseGalleryInfo>
@RewriteQueriesToDropUnusedColumns
@Query("SELECT * FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY RANDOM() DESC LIMIT 1")
@Query("SELECT * FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY RANDOM() LIMIT 1")
suspend fun random(): BaseGalleryInfo
@Query("SELECT EXISTS(SELECT * FROM LOCAL_FAVORITES WHERE GID = :gid)")
@@ -8,10 +8,10 @@ import androidx.room.Update
@Dao
interface QuickSearchDao {
@Query("SELECT * FROM QUICK_SEARCH ORDER BY POSITION ASC")
@Query("SELECT * FROM QUICK_SEARCH ORDER BY POSITION")
suspend fun list(): List<QuickSearch>
@Query("SELECT * FROM QUICK_SEARCH ORDER BY POSITION ASC LIMIT :limit OFFSET :offset")
@Query("SELECT * FROM QUICK_SEARCH ORDER BY POSITION LIMIT :limit OFFSET :offset")
suspend fun list(offset: Int, limit: Int): List<QuickSearch>
@Query("UPDATE QUICK_SEARCH SET POSITION = POSITION - 1 WHERE POSITION > :position")
@@ -22,9 +22,7 @@ import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import com.google.android.material.math.MathUtils
import com.hippo.ehviewer.EhDB
@@ -39,7 +37,7 @@ import com.hippo.ehviewer.spider.putToDownloadDir
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ConcurrentPool
import com.hippo.ehviewer.util.SimpleHandler
import com.hippo.ehviewer.util.assertNotMainThread
import com.hippo.ehviewer.util.insertWith
import com.hippo.ehviewer.util.mapNotNull
import com.hippo.ehviewer.util.runAssertingNotMainThread
import com.hippo.unifile.UniFile
@@ -57,20 +55,19 @@ import splitties.preferences.edit
object DownloadManager : OnSpiderListener {
// All download info list
val allInfoList = runAssertingNotMainThread { EhDB.getAllDownloadInfo() }.toMutableStateList()
private val allInfoList = runAssertingNotMainThread {
(EhDB.getAllDownloadInfo() as MutableList).apply { sortWith(comparator()) }
}
val downloadInfoList: List<DownloadInfo>
get() = allInfoList
// All download info map
private val mAllInfoMap = allInfoList.associateBy { it.gid } as MutableMap<Long, DownloadInfo>
// label and info list map, without default label info list
private val map: MutableMap<String?, SnapshotStateList<DownloadInfo>>
// All labels without default label
val labelList = runAssertingNotMainThread { EhDB.getAllDownloadLabelList() }.toMutableStateList()
// Store download info with default label
private val defaultInfoList: SnapshotStateList<DownloadInfo>
// Store download info wait to start
private val mWaitList = ArrayDeque<DownloadInfo>()
private val mSpeedReminder = SpeedReminder()
@@ -84,31 +81,6 @@ object DownloadManager : OnSpiderListener {
private val mutex = Mutex()
init {
assertNotMainThread()
map = runAssertingNotMainThread {
allInfoList.groupBy { it.label }.entries.associate {
it.key?.let { label ->
if (!containLabel(label)) {
// Add non existing label to DB and list
labelList.add(EhDB.addDownloadLabel(DownloadLabel(label, labelList.size)))
}
}
it.key to it.value.toMutableStateList()
}
} as MutableMap<String?, SnapshotStateList<DownloadInfo>>
defaultInfoList = map.remove(null) ?: mutableStateListOf()
labelList.forEach { (label) ->
map[label] ?: mutableStateListOf<DownloadInfo>().also { map[label] = it }
}
}
fun getInfoListForLabel(label: String?) = if (label == null) {
defaultInfoList
} else {
map[label]
}
fun containLabel(label: String?): Boolean {
if (label == null) {
return false
@@ -195,18 +167,9 @@ object DownloadManager : OnSpiderListener {
info = DownloadInfo(galleryInfo, galleryInfo.putToDownloadDir())
info.label = label
info.state = DownloadInfo.STATE_WAIT
info.position = allInfoList.size
// Add to label download list
val list = getInfoListForLabel(info.label)
if (list == null) {
logcat(TAG, LogPriority.ERROR) { "Can't find download info list with label: $label" }
return
}
list.add(0, info)
// Add to all download list and map
allInfoList.add(0, info)
allInfoList.insertWith(info, comparator())
mAllInfoMap[galleryInfo.gid] = info
// Add to wait list
@@ -281,51 +244,40 @@ object DownloadManager : OnSpiderListener {
}
suspend fun addDownload(downloadInfoList: List<DownloadInfo>) {
downloadInfoList.filterNot { containDownloadInfo(it.gid) }.forEach { info ->
val comparator = comparator()
downloadInfoList.forEach { info ->
if (containDownloadInfo(info.gid)) return@forEach
// Ensure download state
if (DownloadInfo.STATE_WAIT == info.state || DownloadInfo.STATE_DOWNLOAD == info.state) {
info.state = DownloadInfo.STATE_NONE
}
info.position = allInfoList.size
// Add to label download list
val list = getInfoListForLabel(info.label) ?: addLabel(info.label).let { getInfoListForLabel(info.label)!! }
list.add(info)
// Sort
list.sortByPositionDescending()
// Add to all download list and map
allInfoList.add(info)
allInfoList.insertWith(info, comparator)
mAllInfoMap[info.gid] = info
// Save to
EhDB.putDownloadInfo(info)
}
// Sort all download list
allInfoList.sortByPositionDescending()
}
suspend fun addDownloadLabel(downloadLabelList: List<DownloadLabel>) {
val offset = downloadLabelList.size
downloadLabelList.filterNot { containLabel(it.label) }.forEachIndexed { index, label ->
map[label.label] = mutableStateListOf()
label.position = index + offset
labelList.add(EhDB.addDownloadLabel(label))
downloadLabelList.forEachIndexed { index, label ->
if (!containLabel(label.label)) {
label.position = index + offset
labelList.add(EhDB.addDownloadLabel(label))
}
}
}
suspend fun restoreDownload(galleryInfo: BaseGalleryInfo, dirname: String) {
val info = DownloadInfo(galleryInfo, dirname)
info.state = DownloadInfo.STATE_NONE
info.position = allInfoList.size
// Add to default label download list
val list = defaultInfoList
list.add(0, info)
// Add to all download list and map
allInfoList.add(0, info)
allInfoList.insertWith(info, comparator())
mAllInfoMap[galleryInfo.gid] = info
// Save to
@@ -397,16 +349,8 @@ object DownloadManager : OnSpiderListener {
allInfoList.remove(info)
mAllInfoMap.remove(info.gid)
// Remove label list
val list = getInfoListForLabel(info.label)
if (list != null) {
val index = list.indexOf(info)
if (index >= 0) {
list.remove(info)
// Update listener
mutableNotifyFlow.emit(info)
}
}
// Update listener
mutableNotifyFlow.emit(info)
// Ensure download
ensureDownload()
@@ -421,7 +365,7 @@ object DownloadManager : OnSpiderListener {
suspend fun deleteRangeDownload(gidList: LongArray) {
stopRangeDownloadInternal(gidList)
val list = gidList.mapNotNull { gid ->
mAllInfoMap.remove(gid)?.also { getInfoListForLabel(it.label)?.remove(it) }
mAllInfoMap.remove(gid)
}
EhDB.removeDownloadInfo(list)
allInfoList.removeAll(list.toSet())
@@ -433,25 +377,8 @@ object DownloadManager : OnSpiderListener {
ensureDownload()
}
private fun moveDownload(fromPosition: Int, toPosition: Int): List<DownloadInfo> {
val info = allInfoList.removeAt(fromPosition)
allInfoList.add(toPosition, info)
val range = if (fromPosition < toPosition) fromPosition..toPosition else toPosition..fromPosition
val list = allInfoList.slice(range)
val limit = allInfoList.size - 1
list.zip(range).forEach { it.first.position = limit - it.second }
getInfoListForLabel(info.label)!!.sortByPositionDescending()
return list
}
fun moveDownload(fromItem: DownloadInfo, toItem: DownloadInfo): List<DownloadInfo> {
val fromPosition = allInfoList.indexOf(fromItem)
val toPosition = allInfoList.indexOf(toItem)
return if (fromPosition != -1 && toPosition != -1) {
moveDownload(fromPosition, toPosition)
} else {
emptyList()
}
fun sortDownloads(mode: SortMode) {
allInfoList.sortWith(mode.comparator())
}
suspend fun resetAllReadingProgress() = runCatching {
@@ -551,28 +478,10 @@ object DownloadManager : OnSpiderListener {
logcat(TAG, LogPriority.ERROR) { "Not exits label: $label" }
return
}
val dstList = getInfoListForLabel(label)
if (dstList == null) {
logcat(TAG, LogPriority.ERROR) { "Can't find label with label: $label" }
return
}
for (info in list) {
if (info.label == label) {
continue
}
val srcList = getInfoListForLabel(info.label)
if (srcList == null) {
logcat(TAG, LogPriority.ERROR) { "Can't find label with label: " + info.label }
continue
}
srcList.remove(info)
dstList.add(info)
info.label = label
dstList.sortByPositionDescending()
// Save to DB
EhDB.putDownloadInfo(info)
list.forEach {
it.label = label
}
EhDB.updateDownloadInfo(list)
}
suspend fun addLabel(label: String?) {
@@ -580,7 +489,6 @@ object DownloadManager : OnSpiderListener {
return
}
labelList.add(EhDB.addDownloadLabel(DownloadLabel(label, labelList.size)))
map[label] = mutableStateListOf()
}
suspend fun renameLabel(from: String, to: String) {
@@ -590,37 +498,27 @@ object DownloadManager : OnSpiderListener {
val new = exist.copy(label = to)
labelList.add(index, new)
EhDB.updateDownloadLabel(new)
val list = map.remove(from)?.onEach { it.label = to }
if (list != null) map[to] = list
allInfoList.forEach {
if (it.label == from) {
it.label = to
}
}
}
}
suspend fun deleteLabel(label: String) {
// Find in label list and remove
var found = false
val iterator = labelList.iterator()
while (iterator.hasNext()) {
val raw = iterator.next()
if (label == raw.label) {
found = true
iterator.remove()
EhDB.removeDownloadLabel(raw)
break
labelList.run {
val index = indexOfFirst { it.label == label }
subList(index + 1, size).forEach {
it.position--
}
EhDB.removeDownloadLabel(removeAt(index))
}
allInfoList.forEach {
if (it.label == label) {
it.label = null
}
}
if (!found) {
return
}
val list = map.remove(label) ?: return
// Update info label
for (info in list) {
info.label = null
defaultInfoList.add(info)
}
// Sort
defaultInfoList.sortByPositionDescending()
}
val isIdle: Boolean
@@ -995,9 +893,7 @@ object DownloadManager : OnSpiderListener {
private const val TYPE_ON_PAGE_FAILURE = 4
private const val TYPE_ON_FINISH = 5
private fun MutableList<DownloadInfo>.sortByPositionDescending() {
sortByDescending { it.position }
}
private fun comparator() = SortMode.from(Settings.downloadSortMode.value).comparator()
}
var downloadLocation: UniFile
@@ -0,0 +1,51 @@
package com.hippo.ehviewer.download
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.dao.DownloadInfo
data class SortMode(val field: Field, val order: Order) {
val flag
get() = field.value shl 1 or order.value
enum class Field(val value: Int) {
GID(0),
TITLE(1),
TIME(2),
LABEL(3),
}
enum class Order(val value: Int) {
ASC(0),
DESC(1),
}
fun comparator(): Comparator<DownloadInfo> {
val comparator = when (field) {
Field.GID -> compareBy { it.gid }
Field.TITLE -> compareBy { EhUtils.getSuitableTitle(it) }
Field.TIME -> compareBy { it.time }
Field.LABEL -> compareBy<DownloadInfo> { it.label }.thenBy { EhUtils.getSuitableTitle(it) }
}
return if (order == Order.ASC) comparator else comparator.reversed()
}
companion object {
fun from(flag: Int): SortMode {
val field = flag shr 1
val order = flag and 1
require(field in Field.entries.indices && order in Order.entries.indices)
return SortMode(Field.entries[field], Order.entries[order])
}
val Default = SortMode(Field.TIME, Order.DESC)
val All = listOf(
SortMode(Field.TIME, Order.DESC),
SortMode(Field.TIME, Order.ASC),
SortMode(Field.GID, Order.DESC),
SortMode(Field.GID, Order.ASC),
SortMode(Field.TITLE, Order.ASC),
SortMode(Field.TITLE, Order.DESC),
SortMode(Field.LABEL, Order.ASC),
SortMode(Field.LABEL, Order.DESC),
)
}
}
@@ -37,23 +37,19 @@ import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.DragHandle
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.util.FileUtils
import sh.calvin.reorderable.ReorderableItemScope
@Composable
fun ReorderableItemScope.DownloadCard(
fun DownloadCard(
onClick: () -> Unit,
onThumbClick: () -> Unit,
onLongClick: () -> Unit,
onStart: () -> Unit,
onStop: () -> Unit,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit,
info: DownloadInfo,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
CrystalCard(
modifier = modifier,
@@ -169,7 +165,6 @@ fun ReorderableItemScope.DownloadCard(
bottom.linkTo(parent.bottom)
},
) {
DragHandle(onDragStarted = onDragStarted, onDragStopped = onDragStopped)
if (downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD) {
IconButton(onClick = onStop) {
Icon(imageVector = Icons.Default.Pause, contentDescription = null)
@@ -106,7 +106,6 @@ fun FabLayout(
expanded: Boolean,
onExpandChanged: (Boolean) -> Unit,
autoCancel: Boolean,
rotateWhenExpand: Boolean = true,
fabBuilder: FabBuilder.() -> Unit,
) {
val updatedHidden by rememberUpdatedState(hidden)
@@ -174,12 +173,8 @@ fun FabLayout(
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = if (rotateWhenExpand) {
Modifier.graphicsLayer {
rotationZ = lerp(-135f, 0f, animatedProgress)
}
} else {
Modifier
modifier = Modifier.graphicsLayer {
rotationZ = lerp(-135f, 0f, animatedProgress)
},
)
}
@@ -1,6 +1,7 @@
package com.hippo.ehviewer.ui.screen
import android.content.Intent
import android.view.ViewConfiguration
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.clickable
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
@@ -26,16 +28,19 @@ import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DriveFileMove
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.automirrored.filled.ViewList
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.NewLabel
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
@@ -47,6 +52,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
@@ -76,11 +82,14 @@ import arrow.core.partially1
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.client.data.contains
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.DownloadManager.labelList
import com.hippo.ehviewer.download.DownloadService
import com.hippo.ehviewer.download.SortMode
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.Download
import com.hippo.ehviewer.ui.LocalSideSheetState
@@ -88,6 +97,7 @@ import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.confirmRemoveDownloadRange
import com.hippo.ehviewer.ui.main.DownloadCard
import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME
import com.hippo.ehviewer.ui.main.FabLayout
import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
import com.hippo.ehviewer.ui.navToReader
@@ -98,9 +108,9 @@ import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.draggingHapticFeedback
import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.util.containsIgnoreCase
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.mapToLongArray
import com.ramcosta.composedestinations.annotation.Destination
@@ -108,22 +118,26 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import kotlin.math.roundToInt
import kotlin.math.sign
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.onEachLatest
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyColumnState
@Destination
@Composable
fun DownloadsScreen(navigator: DestinationsNavigator) {
var label by Settings::recentDownloadLabel.observed
var gridView by Settings::gridView.observed
var label by Settings.recentDownloadLabel.asMutableState()
var gridView by Settings.gridView.asMutableState()
var sortMode by Settings.downloadSortMode.asMutableState()
var keyword by rememberSaveable { mutableStateOf<String?>(null) }
var filterType by rememberSaveable { mutableIntStateOf(-1) }
var searchBarOffsetY by remember(label) { mutableIntStateOf(0) }
val searchFieldState = rememberTextFieldState()
var fabExpanded by remember { mutableStateOf(false) }
var fabHidden by remember { mutableStateOf(false) }
val checkedInfoMap = remember { mutableStateMapOf<Long, DownloadInfo>() }
val selectMode by rememberUpdatedState(checkedInfoMap.isNotEmpty())
LockDrawer(selectMode)
@@ -138,12 +152,12 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
val defaultName = stringResource(R.string.default_download_label_name)
val title = stringResource(R.string.scene_download_title, if (label == "") allName else label ?: defaultName)
val hint = stringResource(R.string.search_bar_hint, title)
val list = remember(label, filterType, keyword) {
val raw = DownloadManager.getInfoListForLabel(label) ?: DownloadManager.allInfoList
raw.filterTo(mutableStateListOf()) { info ->
(filterType == -1 || info.state == filterType) && keyword?.let {
info.title.containsIgnoreCase(it) || info.titleJpn.containsIgnoreCase(it) || info.uploader.containsIgnoreCase(it)
} ?: true
val sortModes = stringArrayResource(id = R.array.download_sort_modes)
val downloadStates = stringArrayResource(id = R.array.download_state)
val list = remember(label, filterType, keyword, sortMode) {
DownloadManager.downloadInfoList.filterTo(mutableStateListOf()) { info ->
(label == "" || info.label == label) && (filterType == -1 || info.state == filterType) &&
keyword.orEmpty() in info
}
}
@@ -153,6 +167,8 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
val labelEmpty = stringResource(R.string.label_text_is_empty)
val defaultInvalid = stringResource(R.string.label_text_is_invalid)
val labelExists = stringResource(R.string.label_text_exist)
val downloadsCount by rememberInVM { EhDB.downloadsCount }.collectAsState(emptyMap())
val totalCount = remember(downloadsCount) { downloadsCount.values.sum() }
with(activity) {
ProvideSideSheetContent { drawerState ->
fun closeSheet() = coroutineScope.launch { drawerState.close() }
@@ -218,7 +234,6 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
) {
stickyHeader {
val all = DownloadManager.allInfoList
ListItem(
modifier = Modifier.clickable {
label = ""
@@ -227,12 +242,11 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
tonalElevation = 1.dp,
shadowElevation = 1.dp,
headlineContent = {
Text("$allName [${all.size}]")
Text("$allName [$totalCount]")
},
)
}
stickyHeader {
val default = DownloadManager.getInfoListForLabel(null)!!
ListItem(
modifier = Modifier.clickable {
label = null
@@ -241,7 +255,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
tonalElevation = 1.dp,
shadowElevation = 1.dp,
headlineContent = {
Text("$defaultName [${default.size}]")
Text("$defaultName [${downloadsCount.getOrDefault(null, 0)}]")
},
)
}
@@ -251,7 +265,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
if (it == SwipeToDismissBoxValue.EndToStart) {
coroutineScope.launch {
DownloadManager.deleteLabel(item)
label = ""
if (label == item) {
label = ""
}
}
}
true
@@ -270,8 +286,6 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
},
label = "elevation",
)
val thatList = DownloadManager.getInfoListForLabel(item)
val text = if (thatList != null) "$item [${thatList.size}]" else item
ListItem(
modifier = Modifier.clickable {
label = item
@@ -280,7 +294,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
tonalElevation = 1.dp,
shadowElevation = elevation,
headlineContent = {
Text(text)
Text("$item [${downloadsCount.getOrDefault(item, 0)}]")
},
trailingContent = {
Row {
@@ -334,10 +348,12 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
title = title,
searchFieldState = searchFieldState,
searchFieldHint = hint,
showSearchFab = selectMode,
onApplySearch = { keyword = it.takeUnless { it.isBlank() } },
onSearchExpanded = { checkedInfoMap.clear() },
onSearchHidden = {},
onSearchExpanded = {
checkedInfoMap.clear()
fabHidden = true
},
onSearchHidden = { fabHidden = false },
searchBarOffsetY = { searchBarOffsetY },
trailingIcon = {
var expanded by remember { mutableStateOf(false) }
@@ -349,7 +365,6 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
IconButton(onClick = { expanded = !expanded }) {
Icon(imageVector = Icons.Default.MoreVert, contentDescription = null)
}
val states = stringArrayResource(id = R.array.download_state)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_labels)) },
@@ -360,15 +375,6 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_filter)) },
onClick = {
expanded = false
coroutineScope.launch {
filterType = dialogState.showSingleChoice(states.toList(), filterType + 1) - 1
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_start_all)) },
onClick = {
@@ -429,10 +435,17 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
end = contentPadding.calculateEndPadding(layoutDirection) + marginH,
)
val searchBarConnection = remember {
val slop = ViewConfiguration.get(context).scaledTouchSlop
val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() }
object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
searchBarOffsetY = (searchBarOffsetY + consumed.y).roundToInt().coerceIn(-topPaddingPx, 0)
val dy = -consumed.y
if (dy >= slop) {
fabHidden = true
} else if (dy <= -slop / 2) {
fabHidden = false
}
searchBarOffsetY = (searchBarOffsetY - dy).roundToInt().coerceIn(-topPaddingPx, 0)
return Offset.Zero // We never consume it
}
}
@@ -459,80 +472,52 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
onClick = ::onItemClick.partially1(it),
onLongClick = { navigator.navigate(it.galleryInfo.asDst()) },
info = it,
modifier = Modifier.animateItemPlacement(),
)
}
}
} else {
val listState = rememberLazyListState()
val reorderableState = rememberReorderableLazyColumnState(listState) { from, to ->
val fromIndex = from.index - 1
val toIndex = to.index - 1
list.apply { add(toIndex, removeAt(fromIndex)) }
view.performHapticFeedback(draggingHapticFeedback)
}
var fromIndex by remember { mutableIntStateOf(-1) }
FastScrollLazyColumn(
modifier = Modifier.nestedScroll(searchBarConnection).fillMaxSize(),
state = listState,
contentPadding = realPadding,
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.gallery_list_interval)),
) {
// Fix the first item's reorder animation
item {}
itemsIndexed(list, key = { _, item -> item.gid }) { index, info ->
ReorderableItem(reorderableState, key = info.gid) {
val checked = info.gid in checkedInfoMap
CheckableItem(checked = checked) { interactionSource ->
DownloadCard(
onClick = {
if (selectMode) {
if (checked) {
checkedInfoMap.remove(info.gid)
} else {
checkedInfoMap[info.gid] = info
}
items(list, key = { it.gid }) { info ->
val checked = info.gid in checkedInfoMap
CheckableItem(checked = checked, modifier = Modifier.animateItemPlacement()) { interactionSource ->
DownloadCard(
onClick = {
if (selectMode) {
if (checked) {
checkedInfoMap.remove(info.gid)
} else {
onItemClick(info)
checkedInfoMap[info.gid] = info
}
},
onThumbClick = {
navigator.navigate(info.galleryInfo.asDst())
},
onLongClick = {
checkedInfoMap[info.gid] = info
},
onStart = {
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START
intent.putExtra(DownloadService.KEY_GALLERY_INFO, info.galleryInfo)
ContextCompat.startForegroundService(activity, intent)
},
onStop = {
coroutineScope.launchIO {
DownloadManager.stopDownload(info.gid)
}
},
onDragStarted = {
fromIndex = index
},
onDragStopped = {
if (fromIndex != -1) {
val direction = (index - fromIndex).sign
if (direction != 0) {
val toItem = list[index - direction]
coroutineScope.launchIO {
val toUpdate = DownloadManager.moveDownload(info, toItem)
EhDB.updateDownloadInfo(toUpdate)
}
}
fromIndex = -1
}
},
info = info,
interactionSource = interactionSource,
modifier = Modifier.height(height),
)
}
} else {
onItemClick(info)
}
},
onThumbClick = {
navigator.navigate(info.galleryInfo.asDst())
},
onLongClick = {
checkedInfoMap[info.gid] = info
},
onStart = {
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START
intent.putExtra(DownloadService.KEY_GALLERY_INFO, info.galleryInfo)
ContextCompat.startForegroundService(activity, intent)
},
onStop = {
coroutineScope.launchIO {
DownloadManager.stopDownload(info.gid)
}
},
info = info,
modifier = Modifier.height(height),
interactionSource = interactionSource,
)
}
}
}
@@ -561,40 +546,77 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
}
}
val hideFab by delegateSnapshotUpdate {
record { fabHidden }
transform {
// Bug: IDE failed to inference 'hide's type
onEachLatest { hide: Boolean ->
if (!hide) delay(FAB_ANIMATE_TIME.toLong())
}
}
}
FabLayout(
hidden = !selectMode,
expanded = selectMode,
onExpandChanged = { checkedInfoMap.clear() },
autoCancel = false,
rotateWhenExpand = false,
hidden = hideFab && !selectMode,
expanded = fabExpanded || selectMode,
onExpandChanged = {
fabExpanded = it
checkedInfoMap.clear()
},
autoCancel = !selectMode,
) {
onClick(Icons.Default.DoneAll) {
val info = list.associateBy { it.gid }
checkedInfoMap.putAll(info)
throw CancellationException()
}
onClick(Icons.Default.PlayArrow) {
val gidList = checkedInfoMap.run { toMap().values.also { clear() } }.mapToLongArray(DownloadInfo::gid)
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START_RANGE
intent.putExtra(DownloadService.KEY_GID_LIST, gidList)
ContextCompat.startForegroundService(context, intent)
}
onClick(Icons.Default.Pause) {
val gidList = checkedInfoMap.run { toMap().values.also { clear() } }.mapToLongArray(DownloadInfo::gid)
DownloadManager.stopRangeDownload(gidList)
}
onClick(Icons.Default.Delete) {
val infoList = checkedInfoMap.run { toMap().values.also { clear() } }
dialogState.confirmRemoveDownloadRange(infoList)
list.removeAll(infoList)
}
onClick(Icons.AutoMirrored.Default.DriveFileMove) {
val infoList = checkedInfoMap.run { toMap().values.also { clear() } }
val toLabel = dialogState.showMoveDownloadLabelList(infoList)
if (label != "" && label != toLabel) {
if (!selectMode) {
onClick(Icons.Default.Shuffle) {
context.navToReader(list.random().galleryInfo)
}
onClick(Icons.AutoMirrored.Default.Sort) {
val selected = dialogState.showSingleChoice(
sortModes.toList(),
SortMode.All.indexOfFirst { it.flag == sortMode },
R.string.sort_by,
)
val mode = SortMode.All[selected]
DownloadManager.sortDownloads(mode)
sortMode = mode.flag
}
onClick(Icons.Default.FilterList) {
filterType = dialogState.showSingleChoice(
downloadStates.toList(),
filterType + 1,
R.string.download_filter,
) - 1
}
} else {
onClick(Icons.Default.DoneAll) {
val info = list.associateBy { it.gid }
checkedInfoMap.putAll(info)
throw CancellationException()
}
onClick(Icons.Default.PlayArrow) {
val gidList = checkedInfoMap.run { toMap().values.also { clear() } }
.mapToLongArray(DownloadInfo::gid)
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START_RANGE
intent.putExtra(DownloadService.KEY_GID_LIST, gidList)
ContextCompat.startForegroundService(context, intent)
}
onClick(Icons.Default.Pause) {
val gidList = checkedInfoMap.run { toMap().values.also { clear() } }
.mapToLongArray(DownloadInfo::gid)
DownloadManager.stopRangeDownload(gidList)
}
onClick(Icons.Default.Delete) {
val infoList = checkedInfoMap.run { toMap().values.also { clear() } }
dialogState.confirmRemoveDownloadRange(infoList)
list.removeAll(infoList)
}
onClick(Icons.AutoMirrored.Default.DriveFileMove) {
val infoList = checkedInfoMap.run { toMap().values.also { clear() } }
val toLabel = dialogState.showMoveDownloadLabelList(infoList)
if (label != "" && label != toLabel) {
list.removeAll(infoList)
}
}
}
}
}
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.screen
import android.view.ViewConfiguration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
@@ -226,8 +227,8 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
data.loadState.refresh is LoadState.NotLoading
}
var expanded by remember { mutableStateOf(false) }
var hidden by remember { mutableStateOf(false) }
var fabExpanded by remember { mutableStateOf(false) }
var fabHidden by remember { mutableStateOf(false) }
val checkedInfoMap = remember { mutableStateMapOf<Long, BaseGalleryInfo>() }
val selectMode = checkedInfoMap.isNotEmpty()
LockDrawer(selectMode)
@@ -239,9 +240,9 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
onApplySearch = { refresh(FavListUrlBuilder(urlBuilder.favCat, it)) },
onSearchExpanded = {
checkedInfoMap.clear()
hidden = true
fabHidden = true
},
onSearchHidden = { hidden = false },
onSearchHidden = { fabHidden = false },
refreshState = refreshState,
searchBarOffsetY = { searchBarOffsetY },
trailingIcon = {
@@ -255,10 +256,17 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
val height by collectListThumbSizeAsState()
val showPages = Settings.showGalleryPages
val searchBarConnection = remember {
val slop = ViewConfiguration.get(context).scaledTouchSlop
val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() }
object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
searchBarOffsetY = (searchBarOffsetY + consumed.y).roundToInt().coerceIn(-topPaddingPx, 0)
val dy = -consumed.y
if (dy >= slop) {
fabHidden = true
} else if (dy <= -slop / 2) {
fabHidden = false
}
searchBarOffsetY = (searchBarOffsetY - dy).roundToInt().coerceIn(-topPaddingPx, 0)
return Offset.Zero // We never consume it
}
}
@@ -270,7 +278,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
listMode = listMode,
detailItemContent = { info ->
val checked = info.gid in checkedInfoMap
CheckableItem(checked = checked) { interactionSource ->
CheckableItem(checked = checked, modifier = Modifier.animateItemPlacement()) { interactionSource ->
GalleryInfoListItem(
onClick = {
if (selectMode) {
@@ -296,7 +304,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
},
thumbItemContent = { info ->
val checked = info.gid in checkedInfoMap
CheckableItem(checked = checked) { interactionSource ->
CheckableItem(checked = checked, modifier = Modifier.animateItemPlacement()) { interactionSource ->
GalleryInfoGridItem(
onClick = {
if (selectMode) {
@@ -325,7 +333,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
}
val hideFab by delegateSnapshotUpdate {
record { hidden }
record { fabHidden }
transform {
// Bug: IDE failed to inference 'hide's type
onEachLatest { hide: Boolean ->
@@ -335,10 +343,10 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
}
FabLayout(
hidden = hideFab,
expanded = expanded || selectMode,
hidden = hideFab && !selectMode,
expanded = fabExpanded || selectMode,
onExpandChanged = {
expanded = it
fabExpanded = it
checkedInfoMap.clear()
},
autoCancel = !selectMode,
@@ -25,9 +25,13 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
@Composable
fun CheckableItem(checked: Boolean, content: @Composable (MutableInteractionSource) -> Unit) {
fun CheckableItem(
checked: Boolean,
modifier: Modifier = Modifier,
content: @Composable (MutableInteractionSource) -> Unit,
) {
val src = controlledInteractionSource(enabled = checked)
Box {
Box(modifier) {
content(src)
if (checked) {
Icon(
@@ -204,6 +204,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
}
}
// Fix crash when TextField with focus is scaled to 0
val focusManager = LocalFocusManager.current
LaunchedEffect(showSearchLayout) {
if (!showSearchLayout) {
@@ -421,9 +422,15 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { index, item ->
ReorderableItem(reorderableLazyListState, key = item.id!!) { isDragging ->
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissBoxValue.EndToStart) {
quickSearchList.remove(item)
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
quickSearchList.run {
val removeIndex = indexOf(item)
subList(removeIndex + 1, size).forEach {
it.position--
}
removeAt(removeIndex)
}
coroutineScope.launchIO {
EhDB.deleteQuickSearch(item)
}
@@ -502,7 +509,8 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
data.loadState.refresh is LoadState.NotLoading
}
var hidden by remember { mutableStateOf(false) }
var fabExpanded by remember { mutableStateOf(false) }
var fabHidden by remember { mutableStateOf(false) }
val openGalleryKeyword = stringResource(R.string.gallery_list_search_bar_open_gallery)
abstract class UrlSuggestion : Suggestion() {
@@ -523,8 +531,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
override val destination = ProgressScreenDestination(gid, pToken, page)
}
var expanded by remember { mutableStateOf(false) }
val selectImageFirst = stringResource(R.string.select_image_first)
SearchBarScreen(
title = suitableTitle,
@@ -571,8 +577,8 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
}
showSearchLayout = false
},
onSearchExpanded = { hidden = true },
onSearchHidden = { hidden = false },
onSearchExpanded = { fabHidden = true },
onSearchHidden = { fabHidden = false },
refreshState = refreshState,
suggestionProvider = {
GalleryDetailUrlParser.parse(it, false)?.run {
@@ -667,9 +673,9 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
val dy = -consumed.y
if (dy >= slop) {
hidden = true
fabHidden = true
} else if (dy <= -slop / 2) {
hidden = false
fabHidden = false
}
searchBarOffsetY = (searchBarOffsetY - dy).roundToInt().coerceIn(-topPaddingPx, 0)
return Offset.Zero // We never consume it
@@ -731,7 +737,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
val outOfRange = stringResource(R.string.error_out_of_range)
val hideFab by delegateSnapshotUpdate {
record { hidden || showSearchLayout }
record { fabHidden || showSearchLayout }
transform {
// Bug: IDE failed to inference 'hide's type
onEachLatest { hide: Boolean ->
@@ -742,8 +748,8 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
FabLayout(
hidden = hideFab,
expanded = expanded,
onExpandChanged = { expanded = it },
expanded = fabExpanded,
onExpandChanged = { fabExpanded = it },
autoCancel = true,
) {
if (urlBuilder.mode in arrayOf(MODE_NORMAL, MODE_UPLOADER, MODE_TAG)) {
@@ -408,8 +408,16 @@ class DialogState {
suspend fun showSingleChoice(
items: List<String>,
selected: Int,
@StringRes title: Int? = null,
): Int = showNoButton {
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) {
title?.let {
Text(
text = stringResource(id = it),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.headlineSmall,
)
}
items.forEachIndexed { index, text ->
Row(
modifier = Modifier.clickable { dismissWith(index) }.fillMaxWidth()
@@ -0,0 +1,8 @@
package com.hippo.ehviewer.util
fun <T> MutableList<T>.insertWith(element: T, comparator: Comparator<T>) {
val index = binarySearch(element, comparator).let {
if (it < 0) -it - 1 else it
}
add(index, element)
}
@@ -456,6 +456,15 @@
<string name="black_dark_theme">纯黑深色主题</string>
<string name="harmonize_category_color">类别颜色适配动态配色</string>
<string name="use_ci_update_channel">使用持续集成(CI)构建频道更新</string>
<string name="sort_by">排序依据</string>
<string name="added_time_desc">添加时间(降序)</string>
<string name="added_time_asc">添加时间(升序)</string>
<string name="uploaded_time_desc">上传时间(降序)</string>
<string name="uploaded_time_asc">上传时间(升序)</string>
<string name="title_asc">标题(升序)</string>
<string name="title_desc">标题(降序)</string>
<string name="download_label_asc">下载标签(升序)</string>
<string name="download_label_desc">下载标签(降序)</string>
<string name="download_filter">过滤</string>
<string name="download_all">全部</string>
<string name="download_start_all_reversed">全部开始(倒序)</string>
+11
View File
@@ -228,6 +228,17 @@
<item>2</item>
</integer-array>
<string-array name="download_sort_modes">
<item>@string/added_time_desc</item>
<item>@string/added_time_asc</item>
<item>@string/uploaded_time_desc</item>
<item>@string/uploaded_time_asc</item>
<item>@string/title_asc</item>
<item>@string/title_desc</item>
<item>@string/download_label_asc</item>
<item>@string/download_label_desc</item>
</string-array>
<string-array name="download_state">
<item>@string/download_all</item>
<item>@string/download_state_none</item>
+9
View File
@@ -521,6 +521,15 @@
<string name="dark_theme">Dark theme</string>
<string name="black_dark_theme">Black dark theme</string>
<string name="harmonize_category_color">Harmonize category color for Dynamic Color</string>
<string name="sort_by">Sort by</string>
<string name="added_time_desc">Added time (descending)</string>
<string name="added_time_asc">Added time (ascending)</string>
<string name="uploaded_time_desc">Uploaded time (descending)</string>
<string name="uploaded_time_asc">Uploaded time (ascending)</string>
<string name="title_asc">Title (ascending)</string>
<string name="title_desc">Title (descending)</string>
<string name="download_label_asc">Download label (ascending)</string>
<string name="download_label_desc">Download label (descending)</string>
<string name="download_filter">Filter</string>
<string name="download_all">All</string>
<string name="download_start_all_reversed">Start all (reversed)</string>