Fix formatting in a bunch of files

This commit is contained in:
Syer10
2025-10-03 21:20:55 -04:00
parent a7cc5e664b
commit bdd4ef20cb
92 changed files with 4647 additions and 4709 deletions

View File

@@ -23,103 +23,102 @@ import kotlinx.coroutines.launch
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class ServerListeners
@Inject
constructor() {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Inject
class ServerListeners {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private fun <T> Flow<T>.startWith(value: T) = onStart { emit(value) }
private fun <T> Flow<T>.startWith(value: T) = onStart { emit(value) }
private val _mangaListener = MutableSharedFlow<List<Long>>(
extraBufferCapacity = Channel.UNLIMITED,
)
val mangaListener = _mangaListener.asSharedFlow()
private val _mangaListener = MutableSharedFlow<List<Long>>(
extraBufferCapacity = Channel.UNLIMITED,
)
val mangaListener = _mangaListener.asSharedFlow()
private val _chapterIdsListener = MutableSharedFlow<List<Long>>(
extraBufferCapacity = Channel.UNLIMITED,
)
val chapterIdsListener = _chapterIdsListener.asSharedFlow()
private val _chapterIdsListener = MutableSharedFlow<List<Long>>(
extraBufferCapacity = Channel.UNLIMITED,
)
val chapterIdsListener = _chapterIdsListener.asSharedFlow()
private val _mangaChapterIdsListener = MutableSharedFlow<List<Long>>(
extraBufferCapacity = Channel.UNLIMITED,
)
val mangaChapterIdsListener = _mangaChapterIdsListener.asSharedFlow()
private val _mangaChapterIdsListener = MutableSharedFlow<List<Long>>(
extraBufferCapacity = Channel.UNLIMITED,
)
val mangaChapterIdsListener = _mangaChapterIdsListener.asSharedFlow()
private val categoryMangaListener = MutableSharedFlow<Long>(
extraBufferCapacity = Channel.UNLIMITED,
)
private val categoryMangaListener = MutableSharedFlow<Long>(
extraBufferCapacity = Channel.UNLIMITED,
)
private val extensionListener = MutableSharedFlow<List<String>>(
extraBufferCapacity = Channel.UNLIMITED,
)
private val extensionListener = MutableSharedFlow<List<String>>(
extraBufferCapacity = Channel.UNLIMITED,
)
fun <T> combineMangaUpdates(
flow: Flow<T>,
predate: (suspend (List<Long>) -> Boolean)? = null,
) = if (predate != null) {
_mangaListener
.filter(predate)
.startWith(Unit)
} else {
_mangaListener.startWith(Unit)
}
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.flatMapLatest { flow }
fun <T> combineMangaUpdates(
flow: Flow<T>,
predate: (suspend (List<Long>) -> Boolean)? = null,
) = if (predate != null) {
_mangaListener
.filter(predate)
.startWith(Unit)
} else {
_mangaListener.startWith(Unit)
}
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.flatMapLatest { flow }
fun updateManga(vararg ids: Long) {
scope.launch {
_mangaListener.emit(ids.toList())
}
}
fun <T> combineCategoryManga(
flow: Flow<T>,
predate: (suspend (Long) -> Boolean)? = null,
) = if (predate != null) {
categoryMangaListener.filter(predate).startWith(-1)
} else {
categoryMangaListener.startWith(-1)
}
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.flatMapLatest { flow }
fun updateCategoryManga(id: Long) {
scope.launch {
categoryMangaListener.emit(id)
}
}
fun <T> combineChapters(
flow: Flow<T>,
chapterIdPredate: (suspend (List<Long>) -> Boolean)? = null,
mangaIdPredate: (suspend (List<Long>) -> Boolean)? = null,
): Flow<T> {
val idsListener = _chapterIdsListener
.filter { chapterIdPredate?.invoke(it) ?: false }
.startWith(Unit)
.combine(
_mangaChapterIdsListener.filter { mangaIdPredate?.invoke(it) ?: false }
.startWith(Unit),
) { _, _ -> }
return idsListener
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.flatMapLatest { flow }
}
fun updateChapters(chapterIds: List<Long>) {
scope.launch {
_chapterIdsListener.emit(chapterIds)
}
}
fun updateChapters(vararg chapterIds: Long) {
scope.launch {
_chapterIdsListener.emit(chapterIds.toList())
}
}
companion object {
private val log = logging()
fun updateManga(vararg ids: Long) {
scope.launch {
_mangaListener.emit(ids.toList())
}
}
fun <T> combineCategoryManga(
flow: Flow<T>,
predate: (suspend (Long) -> Boolean)? = null,
) = if (predate != null) {
categoryMangaListener.filter(predate).startWith(-1)
} else {
categoryMangaListener.startWith(-1)
}
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.flatMapLatest { flow }
fun updateCategoryManga(id: Long) {
scope.launch {
categoryMangaListener.emit(id)
}
}
fun <T> combineChapters(
flow: Flow<T>,
chapterIdPredate: (suspend (List<Long>) -> Boolean)? = null,
mangaIdPredate: (suspend (List<Long>) -> Boolean)? = null,
): Flow<T> {
val idsListener = _chapterIdsListener
.filter { chapterIdPredate?.invoke(it) ?: false }
.startWith(Unit)
.combine(
_mangaChapterIdsListener.filter { mangaIdPredate?.invoke(it) ?: false }
.startWith(Unit),
) { _, _ -> }
return idsListener
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.flatMapLatest { flow }
}
fun updateChapters(chapterIds: List<Long>) {
scope.launch {
_chapterIdsListener.emit(chapterIds)
}
}
fun updateChapters(vararg chapterIds: Long) {
scope.launch {
_chapterIdsListener.emit(chapterIds.toList())
}
}
companion object {
private val log = logging()
}
}

View File

@@ -13,30 +13,29 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class ExportBackupFile
@Inject
constructor(
private val backupRepository: BackupRepository,
) {
suspend fun await(
includeCategories: Boolean,
includeChapters: Boolean,
block: HttpRequestBuilder.() -> Unit = {},
onError: suspend (Throwable) -> Unit = {},
) = asFlow(includeCategories, includeChapters, block)
.catch {
onError(it)
log.warn(it) { "Failed to export backup" }
}
.singleOrNull()
fun asFlow(
includeCategories: Boolean,
includeChapters: Boolean,
block: HttpRequestBuilder.() -> Unit = {},
) = backupRepository.createBackup(includeCategories, includeChapters, block)
companion object {
private val log = logging()
@Inject
class ExportBackupFile(
private val backupRepository: BackupRepository,
) {
suspend fun await(
includeCategories: Boolean,
includeChapters: Boolean,
block: HttpRequestBuilder.() -> Unit = {},
onError: suspend (Throwable) -> Unit = {},
) = asFlow(includeCategories, includeChapters, block)
.catch {
onError(it)
log.warn(it) { "Failed to export backup" }
}
.singleOrNull()
fun asFlow(
includeCategories: Boolean,
includeChapters: Boolean,
block: HttpRequestBuilder.() -> Unit = {},
) = backupRepository.createBackup(includeCategories, includeChapters, block)
companion object {
private val log = logging()
}
}

View File

@@ -15,24 +15,23 @@ import okio.Path
import okio.SYSTEM
import org.lighthousegames.logging.logging
class ImportBackupFile
@Inject
constructor(
private val backupRepository: BackupRepository,
) {
suspend fun await(
file: Path,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(file)
.catch {
onError(it)
log.warn(it) { "Failed to import backup ${file.name}" }
}
.singleOrNull()
fun asFlow(file: Path) = backupRepository.restoreBackup(FileSystem.SYSTEM.source(file))
companion object {
private val log = logging()
@Inject
class ImportBackupFile(
private val backupRepository: BackupRepository,
) {
suspend fun await(
file: Path,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(file)
.catch {
onError(it)
log.warn(it) { "Failed to import backup ${file.name}" }
}
.singleOrNull()
fun asFlow(file: Path) = backupRepository.restoreBackup(FileSystem.SYSTEM.source(file))
companion object {
private val log = logging()
}
}

View File

@@ -15,24 +15,23 @@ import okio.Path
import okio.SYSTEM
import org.lighthousegames.logging.logging
class ValidateBackupFile
@Inject
constructor(
private val backupRepository: BackupRepository,
) {
suspend fun await(
file: Path,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(file)
.catch {
onError(it)
log.warn(it) { "Failed to validate backup ${file.name}" }
}
.singleOrNull()
fun asFlow(file: Path) = backupRepository.validateBackup(FileSystem.SYSTEM.source(file))
companion object {
private val log = logging()
@Inject
class ValidateBackupFile(
private val backupRepository: BackupRepository,
) {
suspend fun await(
file: Path,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(file)
.catch {
onError(it)
log.warn(it) { "Failed to validate backup ${file.name}" }
}
.singleOrNull()
fun asFlow(file: Path) = backupRepository.validateBackup(FileSystem.SYSTEM.source(file))
companion object {
private val log = logging()
}
}

View File

@@ -17,61 +17,60 @@ import kotlinx.coroutines.flow.map
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class AddMangaToCategory
@Inject
constructor(
private val categoryRepository: CategoryRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
categoryId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId, categoryId)
.catch {
onError(it)
log.warn(it) { "Failed to add $mangaId to category $categoryId" }
}
.collect()
suspend fun await(
manga: Manga,
category: Category,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga, category)
.catch {
onError(it)
log.warn(it) { "Failed to add ${manga.title}(${manga.id}) to category ${category.name}" }
}
.collect()
fun asFlow(
mangaId: Long,
categoryId: Long,
) = if (categoryId != 0L) {
categoryRepository.addMangaToCategory(mangaId, categoryId)
.map { serverListeners.updateCategoryManga(categoryId) }
} else {
flow {
serverListeners.updateCategoryManga(categoryId)
emit(Unit)
}
@Inject
class AddMangaToCategory(
private val categoryRepository: CategoryRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
categoryId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId, categoryId)
.catch {
onError(it)
log.warn(it) { "Failed to add $mangaId to category $categoryId" }
}
.collect()
fun asFlow(
manga: Manga,
category: Category,
) = if (category.id != 0L) {
categoryRepository.addMangaToCategory(manga.id, category.id)
.map { serverListeners.updateCategoryManga(category.id) }
} else {
flow {
serverListeners.updateCategoryManga(category.id)
emit(Unit)
}
suspend fun await(
manga: Manga,
category: Category,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga, category)
.catch {
onError(it)
log.warn(it) { "Failed to add ${manga.title}(${manga.id}) to category ${category.name}" }
}
.collect()
companion object {
private val log = logging()
fun asFlow(
mangaId: Long,
categoryId: Long,
) = if (categoryId != 0L) {
categoryRepository.addMangaToCategory(mangaId, categoryId)
.map { serverListeners.updateCategoryManga(categoryId) }
} else {
flow {
serverListeners.updateCategoryManga(categoryId)
emit(Unit)
}
}
fun asFlow(
manga: Manga,
category: Category,
) = if (category.id != 0L) {
categoryRepository.addMangaToCategory(manga.id, category.id)
.map { serverListeners.updateCategoryManga(category.id) }
} else {
flow {
serverListeners.updateCategoryManga(category.id)
emit(Unit)
}
}
companion object {
private val log = logging()
}
}

View File

@@ -12,24 +12,23 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class CreateCategory
@Inject
constructor(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
name: String,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(name)
.catch {
onError(it)
log.warn(it) { "Failed to create category $name" }
}
.collect()
fun asFlow(name: String) = categoryRepository.createCategory(name)
companion object {
private val log = logging()
@Inject
class CreateCategory(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
name: String,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(name)
.catch {
onError(it)
log.warn(it) { "Failed to create category $name" }
}
.collect()
fun asFlow(name: String) = categoryRepository.createCategory(name)
companion object {
private val log = logging()
}
}

View File

@@ -13,36 +13,35 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class DeleteCategory
@Inject
constructor(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
categoryId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(categoryId)
.catch {
onError(it)
log.warn(it) { "Failed to delete category $categoryId" }
}
.collect()
suspend fun await(
category: Category,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(category)
.catch {
onError(it)
log.warn(it) { "Failed to delete category ${category.name}" }
}
.collect()
fun asFlow(categoryId: Long) = categoryRepository.deleteCategory(categoryId)
fun asFlow(category: Category) = categoryRepository.deleteCategory(category.id)
companion object {
private val log = logging()
@Inject
class DeleteCategory(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
categoryId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(categoryId)
.catch {
onError(it)
log.warn(it) { "Failed to delete category $categoryId" }
}
.collect()
suspend fun await(
category: Category,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(category)
.catch {
onError(it)
log.warn(it) { "Failed to delete category ${category.name}" }
}
.collect()
fun asFlow(categoryId: Long) = categoryRepository.deleteCategory(categoryId)
fun asFlow(category: Category) = categoryRepository.deleteCategory(category.id)
companion object {
private val log = logging()
}
}

View File

@@ -13,32 +13,31 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetCategories
@Inject
constructor(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
dropDefault: Boolean = false,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(dropDefault)
.catch {
onError(it)
log.warn(it) { "Failed to get categories" }
}
.singleOrNull()
fun asFlow(dropDefault: Boolean = false) =
categoryRepository.getCategories()
.map { categories ->
if (dropDefault) {
categories.filterNot { it.name.equals("default", true) }
} else {
categories
}
}
companion object {
private val log = logging()
@Inject
class GetCategories(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
dropDefault: Boolean = false,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(dropDefault)
.catch {
onError(it)
log.warn(it) { "Failed to get categories" }
}
.singleOrNull()
fun asFlow(dropDefault: Boolean = false) =
categoryRepository.getCategories()
.map { categories ->
if (dropDefault) {
categories.filterNot { it.name.equals("default", true) }
} else {
categories
}
}
companion object {
private val log = logging()
}
}

View File

@@ -13,36 +13,35 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetMangaCategories
@Inject
constructor(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to get categories for $mangaId" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to get categories for ${manga.title}(${manga.id})" }
}
.singleOrNull()
fun asFlow(mangaId: Long) = categoryRepository.getMangaCategories(mangaId)
fun asFlow(manga: Manga) = categoryRepository.getMangaCategories(manga.id)
companion object {
private val log = logging()
@Inject
class GetMangaCategories(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to get categories for $mangaId" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to get categories for ${manga.title}(${manga.id})" }
}
.singleOrNull()
fun asFlow(mangaId: Long) = categoryRepository.getMangaCategories(mangaId)
fun asFlow(manga: Manga) = categoryRepository.getMangaCategories(manga.id)
companion object {
private val log = logging()
}
}

View File

@@ -15,45 +15,44 @@ import kotlinx.coroutines.flow.take
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetMangaListFromCategory
@Inject
constructor(
private val categoryRepository: CategoryRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
categoryId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(categoryId)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get manga list from category $categoryId" }
}
.singleOrNull()
suspend fun await(
category: Category,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(category)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get manga list from category ${category.name}" }
}
.singleOrNull()
fun asFlow(categoryId: Long) =
serverListeners.combineCategoryManga(
categoryRepository.getMangaFromCategory(categoryId),
) { categoryId == it }
fun asFlow(category: Category) =
serverListeners.combineCategoryManga(
categoryRepository.getMangaFromCategory(category.id),
) { category.id == it }
companion object {
private val log = logging()
@Inject
class GetMangaListFromCategory(
private val categoryRepository: CategoryRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
categoryId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(categoryId)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get manga list from category $categoryId" }
}
.singleOrNull()
suspend fun await(
category: Category,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(category)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get manga list from category ${category.name}" }
}
.singleOrNull()
fun asFlow(categoryId: Long) =
serverListeners.combineCategoryManga(
categoryRepository.getMangaFromCategory(categoryId),
) { categoryId == it }
fun asFlow(category: Category) =
serverListeners.combineCategoryManga(
categoryRepository.getMangaFromCategory(category.id),
) { category.id == it }
companion object {
private val log = logging()
}
}

View File

@@ -13,52 +13,51 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class ModifyCategory
@Inject
constructor(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
categoryId: Long,
name: String,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(
categoryId = categoryId,
name = name,
).catch {
onError(it)
log.warn(it) { "Failed to modify category $categoryId with options: name=$name" }
}.collect()
@Inject
class ModifyCategory(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
categoryId: Long,
name: String,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(
categoryId = categoryId,
name = name,
).catch {
onError(it)
log.warn(it) { "Failed to modify category $categoryId with options: name=$name" }
}.collect()
suspend fun await(
category: Category,
name: String? = null,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(
category = category,
name = name,
).catch {
onError(it)
log.warn(it) { "Failed to modify category ${category.name} with options: name=$name" }
}.collect()
suspend fun await(
category: Category,
name: String? = null,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(
category = category,
name = name,
).catch {
onError(it)
log.warn(it) { "Failed to modify category ${category.name} with options: name=$name" }
}.collect()
fun asFlow(
categoryId: Long,
name: String,
) = categoryRepository.modifyCategory(
categoryId = categoryId,
name = name,
)
fun asFlow(
categoryId: Long,
name: String,
) = categoryRepository.modifyCategory(
categoryId = categoryId,
name = name,
)
fun asFlow(
category: Category,
name: String? = null,
) = categoryRepository.modifyCategory(
categoryId = category.id,
name = name ?: category.name,
)
fun asFlow(
category: Category,
name: String? = null,
) = categoryRepository.modifyCategory(
categoryId = category.id,
name = name ?: category.name,
)
companion object {
private val log = logging()
}
companion object {
private val log = logging()
}
}

View File

@@ -17,61 +17,60 @@ import kotlinx.coroutines.flow.map
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class RemoveMangaFromCategory
@Inject
constructor(
private val categoryRepository: CategoryRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
categoryId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId, categoryId)
.catch {
onError(it)
log.warn(it) { "Failed to remove $mangaId from category $categoryId" }
}
.collect()
suspend fun await(
manga: Manga,
category: Category,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga, category)
.catch {
onError(it)
log.warn(it) { "Failed to remove ${manga.title}(${manga.id}) from category ${category.name}" }
}
.collect()
fun asFlow(
mangaId: Long,
categoryId: Long,
) = if (categoryId != 0L) {
categoryRepository.removeMangaFromCategory(mangaId, categoryId)
.map { serverListeners.updateCategoryManga(categoryId) }
} else {
flow {
serverListeners.updateCategoryManga(categoryId)
emit(Unit)
}
@Inject
class RemoveMangaFromCategory(
private val categoryRepository: CategoryRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
categoryId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId, categoryId)
.catch {
onError(it)
log.warn(it) { "Failed to remove $mangaId from category $categoryId" }
}
.collect()
fun asFlow(
manga: Manga,
category: Category,
) = if (category.id != 0L) {
categoryRepository.removeMangaFromCategory(manga.id, category.id)
.map { serverListeners.updateCategoryManga(category.id) }
} else {
flow {
serverListeners.updateCategoryManga(category.id)
emit(Unit)
}
suspend fun await(
manga: Manga,
category: Category,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga, category)
.catch {
onError(it)
log.warn(it) { "Failed to remove ${manga.title}(${manga.id}) from category ${category.name}" }
}
.collect()
companion object {
private val log = logging()
fun asFlow(
mangaId: Long,
categoryId: Long,
) = if (categoryId != 0L) {
categoryRepository.removeMangaFromCategory(mangaId, categoryId)
.map { serverListeners.updateCategoryManga(categoryId) }
} else {
flow {
serverListeners.updateCategoryManga(categoryId)
emit(Unit)
}
}
fun asFlow(
manga: Manga,
category: Category,
) = if (category.id != 0L) {
categoryRepository.removeMangaFromCategory(manga.id, category.id)
.map { serverListeners.updateCategoryManga(category.id) }
} else {
flow {
serverListeners.updateCategoryManga(category.id)
emit(Unit)
}
}
companion object {
private val log = logging()
}
}

View File

@@ -12,28 +12,27 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class ReorderCategory
@Inject
constructor(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
categoryId: Long,
position: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(categoryId, position)
.catch {
onError(it)
log.warn(it) { "Failed to move category $categoryId to $position" }
}
.collect()
fun asFlow(
categoryId: Long,
position: Int,
) = categoryRepository.reorderCategory(categoryId, position)
companion object {
private val log = logging()
@Inject
class ReorderCategory(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
categoryId: Long,
position: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(categoryId, position)
.catch {
onError(it)
log.warn(it) { "Failed to move category $categoryId to $position" }
}
.collect()
fun asFlow(
categoryId: Long,
position: Int,
) = categoryRepository.reorderCategory(categoryId, position)
companion object {
private val log = logging()
}
}

View File

@@ -14,37 +14,36 @@ import kotlinx.coroutines.flow.flow
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdateCategoryMeta
@Inject
constructor(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
category: Category,
example: Int = category.meta.example,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(category, example)
.catch {
onError(it)
log.warn(it) { "Failed to update ${category.name}(${category.id}) meta" }
}
.collect()
fun asFlow(
category: Category,
example: Int = category.meta.example,
) = flow {
if (example != category.meta.example) {
categoryRepository.updateCategoryMeta(
category.id,
"example",
example.toString(),
).collect()
}
emit(Unit)
@Inject
class UpdateCategoryMeta(
private val categoryRepository: CategoryRepository,
) {
suspend fun await(
category: Category,
example: Int = category.meta.example,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(category, example)
.catch {
onError(it)
log.warn(it) { "Failed to update ${category.name}(${category.id}) meta" }
}
.collect()
companion object {
private val log = logging()
fun asFlow(
category: Category,
example: Int = category.meta.example,
) = flow {
if (example != category.meta.example) {
categoryRepository.updateCategoryMeta(
category.id,
"example",
example.toString(),
).collect()
}
emit(Unit)
}
companion object {
private val log = logging()
}
}

View File

@@ -16,73 +16,72 @@ import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
import kotlin.jvm.JvmName
class DeleteChapterDownload
@Inject
constructor(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapterId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId)
.catch {
onError(it)
log.warn(it) { "Failed to delete chapter download for $chapterId" }
}
.collect()
@JvmName("awaitChapter")
suspend fun await(
chapter: Chapter,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter)
.catch {
onError(it)
log.warn(it) { "Failed to delete chapter download for ${chapter.index} of ${chapter.mangaId}" }
}
.collect()
suspend fun await(
chapterIds: List<Long>,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterIds)
.catch {
onError(it)
log.warn(it) { "Failed to delete chapter download for $chapterIds" }
}
.collect()
@JvmName("awaitChapters")
suspend fun await(
chapters: List<Chapter>,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapters)
.catch {
onError(it)
log.warn(it) { "Failed to delete chapter download for ${chapters.joinToString { it.id.toString() }}" }
}
.collect()
fun asFlow(chapterId: Long) =
chapterRepository.deleteDownloadedChapter(chapterId)
.onEach { serverListeners.updateChapters(chapterId) }
@JvmName("asFlowChapter")
fun asFlow(chapter: Chapter) =
chapterRepository.deleteDownloadedChapter(chapter.id)
.onEach { serverListeners.updateChapters(chapter.id) }
fun asFlow(chapterIds: List<Long>) =
chapterRepository.deleteDownloadedChapters(chapterIds)
.onEach { serverListeners.updateChapters(chapterIds) }
@JvmName("asFlowChapters")
fun asFlow(chapter: List<Chapter>) =
chapterRepository.deleteDownloadedChapters(chapter.map { it.id })
.onEach { serverListeners.updateChapters(chapter.map { it.id }) }
companion object {
private val log = logging()
@Inject
class DeleteChapterDownload(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapterId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId)
.catch {
onError(it)
log.warn(it) { "Failed to delete chapter download for $chapterId" }
}
.collect()
@JvmName("awaitChapter")
suspend fun await(
chapter: Chapter,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter)
.catch {
onError(it)
log.warn(it) { "Failed to delete chapter download for ${chapter.index} of ${chapter.mangaId}" }
}
.collect()
suspend fun await(
chapterIds: List<Long>,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterIds)
.catch {
onError(it)
log.warn(it) { "Failed to delete chapter download for $chapterIds" }
}
.collect()
@JvmName("awaitChapters")
suspend fun await(
chapters: List<Chapter>,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapters)
.catch {
onError(it)
log.warn(it) { "Failed to delete chapter download for ${chapters.joinToString { it.id.toString() }}" }
}
.collect()
fun asFlow(chapterId: Long) =
chapterRepository.deleteDownloadedChapter(chapterId)
.onEach { serverListeners.updateChapters(chapterId) }
@JvmName("asFlowChapter")
fun asFlow(chapter: Chapter) =
chapterRepository.deleteDownloadedChapter(chapter.id)
.onEach { serverListeners.updateChapters(chapter.id) }
fun asFlow(chapterIds: List<Long>) =
chapterRepository.deleteDownloadedChapters(chapterIds)
.onEach { serverListeners.updateChapters(chapterIds) }
@JvmName("asFlowChapters")
fun asFlow(chapter: List<Chapter>) =
chapterRepository.deleteDownloadedChapters(chapter.map { it.id })
.onEach { serverListeners.updateChapters(chapter.map { it.id }) }
companion object {
private val log = logging()
}
}

View File

@@ -15,47 +15,46 @@ import kotlinx.coroutines.flow.take
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetChapter
@Inject
constructor(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapterId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get chapter $chapterId" }
}
.singleOrNull()
suspend fun await(
chapter: Chapter,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get chapter ${chapter.index} for ${chapter.mangaId}" }
}
.singleOrNull()
fun asFlow(chapterId: Long) =
serverListeners.combineChapters(
chapterRepository.getChapter(chapterId),
chapterIdPredate = { ids -> chapterId in ids },
)
fun asFlow(chapter: Chapter) =
serverListeners.combineChapters(
chapterRepository.getChapter(chapter.id),
chapterIdPredate = { ids -> chapter.id in ids },
)
companion object {
private val log = logging()
@Inject
class GetChapter(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapterId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get chapter $chapterId" }
}
.singleOrNull()
suspend fun await(
chapter: Chapter,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get chapter ${chapter.index} for ${chapter.mangaId}" }
}
.singleOrNull()
fun asFlow(chapterId: Long) =
serverListeners.combineChapters(
chapterRepository.getChapter(chapterId),
chapterIdPredate = { ids -> chapterId in ids },
)
fun asFlow(chapter: Chapter) =
serverListeners.combineChapters(
chapterRepository.getChapter(chapter.id),
chapterIdPredate = { ids -> chapter.id in ids },
)
companion object {
private val log = logging()
}
}

View File

@@ -13,40 +13,39 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetChapterPages
@Inject
constructor(
private val chapterRepository: ChapterRepository,
) {
suspend fun await(
chapterId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId)
.catch {
onError(it)
log.warn(it) { "Failed to get pages for $chapterId" }
}
.singleOrNull()
suspend fun await(
url: String,
onError: suspend (Throwable) -> Unit = {},
block: HttpRequestBuilder.() -> Unit,
) = asFlow(url, block)
.catch {
onError(it)
log.warn(it) { "Failed to get page $url" }
}
.singleOrNull()
fun asFlow(chapterId: Long) = chapterRepository.getPages(chapterId)
fun asFlow(
url: String,
block: HttpRequestBuilder.() -> Unit,
) = chapterRepository.getPage(url, block)
companion object {
private val log = logging()
@Inject
class GetChapterPages(
private val chapterRepository: ChapterRepository,
) {
suspend fun await(
chapterId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId)
.catch {
onError(it)
log.warn(it) { "Failed to get pages for $chapterId" }
}
.singleOrNull()
suspend fun await(
url: String,
onError: suspend (Throwable) -> Unit = {},
block: HttpRequestBuilder.() -> Unit,
) = asFlow(url, block)
.catch {
onError(it)
log.warn(it) { "Failed to get page $url" }
}
.singleOrNull()
fun asFlow(chapterId: Long) = chapterRepository.getPages(chapterId)
fun asFlow(
url: String,
block: HttpRequestBuilder.() -> Unit,
) = chapterRepository.getPage(url, block)
companion object {
private val log = logging()
}
}

View File

@@ -15,47 +15,46 @@ import kotlinx.coroutines.flow.take
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetChapters
@Inject
constructor(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get chapters for $mangaId" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get chapters for ${manga.title}(${manga.id})" }
}
.singleOrNull()
fun asFlow(mangaId: Long) =
serverListeners.combineChapters(
chapterRepository.getChapters(mangaId),
chapterIdPredate = { ids -> false }, // todo
)
fun asFlow(manga: Manga) =
serverListeners.combineChapters(
chapterRepository.getChapters(manga.id),
chapterIdPredate = { ids -> false }, // todo
)
companion object {
private val log = logging()
@Inject
class GetChapters(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get chapters for $mangaId" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get chapters for ${manga.title}(${manga.id})" }
}
.singleOrNull()
fun asFlow(mangaId: Long) =
serverListeners.combineChapters(
chapterRepository.getChapters(mangaId),
chapterIdPredate = { ids -> false }, // todo
)
fun asFlow(manga: Manga) =
serverListeners.combineChapters(
chapterRepository.getChapters(manga.id),
chapterIdPredate = { ids -> false }, // todo
)
companion object {
private val log = logging()
}
}

View File

@@ -15,41 +15,40 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class RefreshChapters
@Inject
constructor(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to refresh chapters for $mangaId" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to refresh chapters for ${manga.title}(${manga.id})" }
}
.singleOrNull()
fun asFlow(mangaId: Long) =
chapterRepository.fetchChapters(mangaId)
.onEach { serverListeners.updateChapters(mangaId) }
fun asFlow(manga: Manga) =
chapterRepository.fetchChapters(manga.id)
.onEach { serverListeners.updateChapters(manga.id) }
companion object {
private val log = logging()
@Inject
class RefreshChapters(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to refresh chapters for $mangaId" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to refresh chapters for ${manga.title}(${manga.id})" }
}
.singleOrNull()
fun asFlow(mangaId: Long) =
chapterRepository.fetchChapters(mangaId)
.onEach { serverListeners.updateChapters(mangaId) }
fun asFlow(manga: Manga) =
chapterRepository.fetchChapters(manga.id)
.onEach { serverListeners.updateChapters(manga.id) }
companion object {
private val log = logging()
}
}

View File

@@ -16,115 +16,114 @@ import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
import kotlin.jvm.JvmName
class UpdateChapter
@Inject
constructor(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapterId: Long,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId, bookmarked, read, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter bookmark for chapter $chapterId" }
}
.collect()
suspend fun await(
chapter: Chapter,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter, bookmarked, read, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter bookmark for chapter ${chapter.index} of ${chapter.mangaId}" }
}
.collect()
suspend fun await(
chapterIds: List<Long>,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterIds, bookmarked, read, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter bookmark for chapters $chapterIds" }
}
.collect()
@JvmName("awaitChapters")
suspend fun await(
chapters: List<Chapter>,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapters, bookmarked, read, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter bookmark for chapters ${chapters.joinToString { it.id.toString() }}" }
}
.collect()
fun asFlow(
chapterId: Long,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
) = chapterRepository.updateChapter(
chapterId = chapterId,
bookmarked = bookmarked,
read = read,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapterId) }
fun asFlow(
chapter: Chapter,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
) = chapterRepository.updateChapter(
chapterId = chapter.id,
bookmarked = bookmarked,
read = read,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapter.id) }
fun asFlow(
chapterIds: List<Long>,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
) = chapterRepository.updateChapters(
chapterIds = chapterIds,
bookmarked = bookmarked,
read = read,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapterIds) }
@JvmName("asFlowChapters")
fun asFlow(
chapters: List<Chapter>,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
) = chapterRepository.updateChapters(
chapterIds = chapters.map { it.id },
bookmarked = bookmarked,
read = read,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapters.map { it.id }) }
companion object {
private val log = logging()
@Inject
class UpdateChapter(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapterId: Long,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId, bookmarked, read, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter bookmark for chapter $chapterId" }
}
.collect()
suspend fun await(
chapter: Chapter,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter, bookmarked, read, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter bookmark for chapter ${chapter.index} of ${chapter.mangaId}" }
}
.collect()
suspend fun await(
chapterIds: List<Long>,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterIds, bookmarked, read, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter bookmark for chapters $chapterIds" }
}
.collect()
@JvmName("awaitChapters")
suspend fun await(
chapters: List<Chapter>,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapters, bookmarked, read, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter bookmark for chapters ${chapters.joinToString { it.id.toString() }}" }
}
.collect()
fun asFlow(
chapterId: Long,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
) = chapterRepository.updateChapter(
chapterId = chapterId,
bookmarked = bookmarked,
read = read,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapterId) }
fun asFlow(
chapter: Chapter,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
) = chapterRepository.updateChapter(
chapterId = chapter.id,
bookmarked = bookmarked,
read = read,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapter.id) }
fun asFlow(
chapterIds: List<Long>,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
) = chapterRepository.updateChapters(
chapterIds = chapterIds,
bookmarked = bookmarked,
read = read,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapterIds) }
@JvmName("asFlowChapters")
fun asFlow(
chapters: List<Chapter>,
bookmarked: Boolean? = null,
read: Boolean? = null,
lastPageRead: Int? = null,
) = chapterRepository.updateChapters(
chapterIds = chapters.map { it.id },
bookmarked = bookmarked,
read = read,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapters.map { it.id }) }
companion object {
private val log = logging()
}
}

View File

@@ -15,51 +15,50 @@ import kotlinx.coroutines.flow.onEach
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdateChapterLastPageRead
@Inject
constructor(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapterId: Long,
lastPageRead: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter last page read for chapter $chapterId" }
}
.collect()
suspend fun await(
chapter: Chapter,
lastPageRead: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter last page read for chapter ${chapter.index} of ${chapter.mangaId}" }
}
.collect()
fun asFlow(
chapterId: Long,
lastPageRead: Int,
) = chapterRepository.updateChapter(
chapterId = chapterId,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapterId) }
fun asFlow(
chapter: Chapter,
lastPageRead: Int,
) = chapterRepository.updateChapter(
chapterId = chapter.id,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapter.id) }
companion object {
private val log = logging()
@Inject
class UpdateChapterLastPageRead(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapterId: Long,
lastPageRead: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter last page read for chapter $chapterId" }
}
.collect()
suspend fun await(
chapter: Chapter,
lastPageRead: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter, lastPageRead)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter last page read for chapter ${chapter.index} of ${chapter.mangaId}" }
}
.collect()
fun asFlow(
chapterId: Long,
lastPageRead: Int,
) = chapterRepository.updateChapter(
chapterId = chapterId,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapterId) }
fun asFlow(
chapter: Chapter,
lastPageRead: Int,
) = chapterRepository.updateChapter(
chapterId = chapter.id,
lastPageRead = lastPageRead,
).onEach { serverListeners.updateChapters(chapter.id) }
companion object {
private val log = logging()
}
}

View File

@@ -15,39 +15,38 @@ import kotlinx.coroutines.flow.flow
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdateChapterMeta
@Inject
constructor(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapter: Chapter,
pageOffset: Int = chapter.meta.juiPageOffset,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter, pageOffset)
.catch {
onError(it)
log.warn(it) { "Failed to update ${chapter.name}(${chapter.index}) meta" }
}
.collect()
fun asFlow(
chapter: Chapter,
pageOffset: Int = chapter.meta.juiPageOffset,
) = flow {
if (pageOffset != chapter.meta.juiPageOffset) {
chapterRepository.updateChapterMeta(
chapter.id,
"juiPageOffset",
pageOffset.toString(),
).collect()
serverListeners.updateChapters(chapter.id)
}
emit(Unit)
@Inject
class UpdateChapterMeta(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapter: Chapter,
pageOffset: Int = chapter.meta.juiPageOffset,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter, pageOffset)
.catch {
onError(it)
log.warn(it) { "Failed to update ${chapter.name}(${chapter.index}) meta" }
}
.collect()
companion object {
private val log = logging()
fun asFlow(
chapter: Chapter,
pageOffset: Int = chapter.meta.juiPageOffset,
) = flow {
if (pageOffset != chapter.meta.juiPageOffset) {
chapterRepository.updateChapterMeta(
chapter.id,
"juiPageOffset",
pageOffset.toString(),
).collect()
serverListeners.updateChapters(chapter.id)
}
emit(Unit)
}
companion object {
private val log = logging()
}
}

View File

@@ -15,59 +15,58 @@ import kotlinx.coroutines.flow.onEach
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdateChapterRead
@Inject
constructor(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapterId: Long,
read: Boolean,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId, read)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter read status for chapter $chapterId" }
}
.collect()
suspend fun await(
chapter: Chapter,
read: Boolean,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter, read)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter read status for chapter ${chapter.index} of ${chapter.mangaId}" }
}
.collect()
fun asFlow(
chapterId: Long,
read: Boolean,
) = chapterRepository.updateChapter(
chapterId = chapterId,
read = read,
).onEach { serverListeners.updateChapters(chapterId) }
fun asFlow(
chapterIds: List<Long>,
read: Boolean,
) = chapterRepository.updateChapters(
chapterIds = chapterIds,
read = read,
).onEach { serverListeners.updateChapters(chapterIds) }
fun asFlow(
chapter: Chapter,
read: Boolean,
) = chapterRepository.updateChapter(
chapterId = chapter.id,
read = read,
).onEach { serverListeners.updateChapters(chapter.id) }
companion object {
private val log = logging()
@Inject
class UpdateChapterRead(
private val chapterRepository: ChapterRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
chapterId: Long,
read: Boolean,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId, read)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter read status for chapter $chapterId" }
}
.collect()
suspend fun await(
chapter: Chapter,
read: Boolean,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapter, read)
.catch {
onError(it)
log.warn(it) { "Failed to update chapter read status for chapter ${chapter.index} of ${chapter.mangaId}" }
}
.collect()
fun asFlow(
chapterId: Long,
read: Boolean,
) = chapterRepository.updateChapter(
chapterId = chapterId,
read = read,
).onEach { serverListeners.updateChapters(chapterId) }
fun asFlow(
chapterIds: List<Long>,
read: Boolean,
) = chapterRepository.updateChapters(
chapterIds = chapterIds,
read = read,
).onEach { serverListeners.updateChapters(chapterIds) }
fun asFlow(
chapter: Chapter,
read: Boolean,
) = chapterRepository.updateChapter(
chapterId = chapter.id,
read = read,
).onEach { serverListeners.updateChapters(chapter.id) }
companion object {
private val log = logging()
}
}

View File

@@ -12,36 +12,35 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class BatchChapterDownload
@Inject
constructor(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(
chapterIds: List<Long>,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterIds)
.catch {
onError(it)
log.warn(it) { "Failed to queue chapters $chapterIds for a download" }
}
.collect()
suspend fun await(
vararg chapterIds: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(*chapterIds)
.catch {
onError(it)
log.warn(it) { "Failed to queue chapters ${chapterIds.asList()} for a download" }
}
.collect()
fun asFlow(chapterIds: List<Long>) = downloadRepository.batchDownload(chapterIds)
fun asFlow(vararg chapterIds: Long) = downloadRepository.batchDownload(chapterIds.asList())
companion object {
private val log = logging()
@Inject
class BatchChapterDownload(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(
chapterIds: List<Long>,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterIds)
.catch {
onError(it)
log.warn(it) { "Failed to queue chapters $chapterIds for a download" }
}
.collect()
suspend fun await(
vararg chapterIds: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(*chapterIds)
.catch {
onError(it)
log.warn(it) { "Failed to queue chapters ${chapterIds.asList()} for a download" }
}
.collect()
fun asFlow(chapterIds: List<Long>) = downloadRepository.batchDownload(chapterIds)
fun asFlow(vararg chapterIds: Long) = downloadRepository.batchDownload(chapterIds.asList())
companion object {
private val log = logging()
}
}

View File

@@ -12,22 +12,21 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class ClearDownloadQueue
@Inject
constructor(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to clear download queue" }
}
.collect()
@Inject
class ClearDownloadQueue(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to clear download queue" }
}
.collect()
fun asFlow() = downloadRepository.clearDownloadQueue()
fun asFlow() = downloadRepository.clearDownloadQueue()
companion object {
private val log = logging()
}
companion object {
private val log = logging()
}
}

View File

@@ -12,24 +12,23 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class QueueChapterDownload
@Inject
constructor(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(
chapterId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId)
.catch {
onError(it)
log.warn(it) { "Failed to queue chapter $chapterId for a download" }
}
.collect()
fun asFlow(chapterId: Long) = downloadRepository.queueChapterDownload(chapterId)
companion object {
private val log = logging()
@Inject
class QueueChapterDownload(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(
chapterId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId)
.catch {
onError(it)
log.warn(it) { "Failed to queue chapter $chapterId for a download" }
}
.collect()
fun asFlow(chapterId: Long) = downloadRepository.queueChapterDownload(chapterId)
companion object {
private val log = logging()
}
}

View File

@@ -12,28 +12,27 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class ReorderChapterDownload
@Inject
constructor(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(
chapterId: Long,
to: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId, to)
.catch {
onError(it)
log.warn(it) { "Failed to reorder chapter download for $chapterId to $to" }
}
.collect()
fun asFlow(
chapterId: Long,
to: Int,
) = downloadRepository.reorderChapterDownload(chapterId, to)
companion object {
private val log = logging()
@Inject
class ReorderChapterDownload(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(
chapterId: Long,
to: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId, to)
.catch {
onError(it)
log.warn(it) { "Failed to reorder chapter download for $chapterId to $to" }
}
.collect()
fun asFlow(
chapterId: Long,
to: Int,
) = downloadRepository.reorderChapterDownload(chapterId, to)
companion object {
private val log = logging()
}
}

View File

@@ -12,22 +12,21 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class StartDownloading
@Inject
constructor(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to start downloader" }
}
.collect()
@Inject
class StartDownloading(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to start downloader" }
}
.collect()
fun asFlow() = downloadRepository.startDownloading()
fun asFlow() = downloadRepository.startDownloading()
companion object {
private val log = logging()
}
companion object {
private val log = logging()
}
}

View File

@@ -12,24 +12,23 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class StopChapterDownload
@Inject
constructor(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(
chapterId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId)
.catch {
onError(it)
log.warn(it) { "Failed to stop chapter download for $chapterId" }
}
.collect()
fun asFlow(chapterId: Long) = downloadRepository.stopChapterDownload(chapterId)
companion object {
private val log = logging()
@Inject
class StopChapterDownload(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(
chapterId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(chapterId)
.catch {
onError(it)
log.warn(it) { "Failed to stop chapter download for $chapterId" }
}
.collect()
fun asFlow(chapterId: Long) = downloadRepository.stopChapterDownload(chapterId)
companion object {
private val log = logging()
}
}

View File

@@ -12,22 +12,21 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class StopDownloading
@Inject
constructor(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to stop downloader" }
}
.collect()
@Inject
class StopDownloading(
private val downloadRepository: DownloadRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to stop downloader" }
}
.collect()
fun asFlow() = downloadRepository.stopDownloading()
fun asFlow() = downloadRepository.stopDownloading()
companion object {
private val log = logging()
}
companion object {
private val log = logging()
}
}

View File

@@ -16,42 +16,40 @@ import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString
import me.tatarka.inject.annotations.Inject
class DownloadService
@Inject
constructor(
serverPreferences: ServerPreferences,
client: Http,
) : WebsocketService(serverPreferences, client) {
override val _status: MutableStateFlow<Status>
get() = status
@Inject
class DownloadService(
serverPreferences: ServerPreferences,
client: Http,
) : WebsocketService(serverPreferences, client) {
override val _status: MutableStateFlow<Status>
get() = status
override val query: String
get() = "/api/v1/downloads"
override val query: String
get() = "/api/v1/downloads"
override suspend fun onReceived(frame: Frame.Text) {
val status = json.decodeFromString<DownloadStatus>(frame.readText())
downloaderStatus.value = status.status
downloadQueue.value = status.queue
}
companion object {
val status = MutableStateFlow(Status.STARTING)
val downloadQueue = MutableStateFlow(emptyList<DownloadChapter>())
val downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped)
fun registerWatch(mangaId: Long) =
downloadQueue
.map {
it.filter { it.mangaId == mangaId }
}
fun registerWatches(mangaIds: Set<Long>) =
downloadQueue
.map {
it.filter { it.mangaId in mangaIds }
}
}
override suspend fun onReceived(frame: Frame.Text) {
val status = json.decodeFromString<DownloadStatus>(frame.readText())
downloaderStatus.value = status.status
downloadQueue.value = status.queue
}
companion object {
val status = MutableStateFlow(Status.STARTING)
val downloadQueue = MutableStateFlow(emptyList<DownloadChapter>())
val downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped)
fun registerWatch(mangaId: Long) =
downloadQueue
.map {
it.filter { it.mangaId == mangaId }
}
fun registerWatches(mangaIds: Set<Long>) =
downloadQueue
.map {
it.filter { it.mangaId in mangaIds }
}
}
}

View File

@@ -12,22 +12,21 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetExtensionList
@Inject
constructor(
private val extensionRepository: ExtensionRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to get extension list" }
}
.singleOrNull()
@Inject
class GetExtensionList(
private val extensionRepository: ExtensionRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to get extension list" }
}
.singleOrNull()
fun asFlow() = extensionRepository.getExtensionList()
fun asFlow() = extensionRepository.getExtensionList()
companion object {
private val log = logging()
}
companion object {
private val log = logging()
}
}

View File

@@ -13,24 +13,23 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class InstallExtension
@Inject
constructor(
private val extensionRepository: ExtensionRepository,
) {
suspend fun await(
extension: Extension,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(extension)
.catch {
onError(it)
log.warn(it) { "Failed to install extension ${extension.apkName}" }
}
.collect()
fun asFlow(extension: Extension) = extensionRepository.installExtension(extension.pkgName)
companion object {
private val log = logging()
@Inject
class InstallExtension(
private val extensionRepository: ExtensionRepository,
) {
suspend fun await(
extension: Extension,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(extension)
.catch {
onError(it)
log.warn(it) { "Failed to install extension ${extension.apkName}" }
}
.collect()
fun asFlow(extension: Extension) = extensionRepository.installExtension(extension.pkgName)
companion object {
private val log = logging()
}
}

View File

@@ -15,24 +15,23 @@ import okio.Path
import okio.SYSTEM
import org.lighthousegames.logging.logging
class InstallExtensionFile
@Inject
constructor(
private val extensionRepository: ExtensionRepository,
) {
suspend fun await(
path: Path,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(path)
.catch {
onError(it)
log.warn(it) { "Failed to install extension from $path" }
}
.collect()
fun asFlow(path: Path) = extensionRepository.installExtension(FileSystem.SYSTEM.source(path))
companion object {
private val log = logging()
@Inject
class InstallExtensionFile(
private val extensionRepository: ExtensionRepository,
) {
suspend fun await(
path: Path,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(path)
.catch {
onError(it)
log.warn(it) { "Failed to install extension from $path" }
}
.collect()
fun asFlow(path: Path) = extensionRepository.installExtension(FileSystem.SYSTEM.source(path))
companion object {
private val log = logging()
}
}

View File

@@ -13,24 +13,23 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UninstallExtension
@Inject
constructor(
private val extensionRepository: ExtensionRepository,
) {
suspend fun await(
extension: Extension,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(extension)
.catch {
onError(it)
log.warn(it) { "Failed to uninstall extension ${extension.apkName}" }
}
.collect()
fun asFlow(extension: Extension) = extensionRepository.uninstallExtension(extension.pkgName)
companion object {
private val log = logging()
@Inject
class UninstallExtension(
private val extensionRepository: ExtensionRepository,
) {
suspend fun await(
extension: Extension,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(extension)
.catch {
onError(it)
log.warn(it) { "Failed to uninstall extension ${extension.apkName}" }
}
.collect()
fun asFlow(extension: Extension) = extensionRepository.uninstallExtension(extension.pkgName)
companion object {
private val log = logging()
}
}

View File

@@ -13,24 +13,23 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdateExtension
@Inject
constructor(
private val extensionRepository: ExtensionRepository,
) {
suspend fun await(
extension: Extension,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(extension)
.catch {
onError(it)
log.warn(it) { "Failed to update extension ${extension.apkName}" }
}
.collect()
fun asFlow(extension: Extension) = extensionRepository.updateExtension(extension.pkgName)
companion object {
private val log = logging()
@Inject
class UpdateExtension(
private val extensionRepository: ExtensionRepository,
) {
suspend fun await(
extension: Extension,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(extension)
.catch {
onError(it)
log.warn(it) { "Failed to update extension ${extension.apkName}" }
}
.collect()
fun asFlow(extension: Extension) = extensionRepository.updateExtension(extension.pkgName)
companion object {
private val log = logging()
}
}

View File

@@ -12,22 +12,21 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetGlobalMeta
@Inject
constructor(
private val globalRepository: GlobalRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to get global meta" }
}
.singleOrNull()
@Inject
class GetGlobalMeta(
private val globalRepository: GlobalRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to get global meta" }
}
.singleOrNull()
fun asFlow() = globalRepository.getGlobalMeta()
fun asFlow() = globalRepository.getGlobalMeta()
companion object {
private val log = logging()
}
companion object {
private val log = logging()
}
}

View File

@@ -14,36 +14,35 @@ import kotlinx.coroutines.flow.flow
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdateGlobalMeta
@Inject
constructor(
private val globalRepository: GlobalRepository,
) {
suspend fun await(
globalMeta: GlobalMeta,
example: Int = globalMeta.example,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(globalMeta, example)
.catch {
onError(it)
log.warn(it) { "Failed to update global meta" }
}
.collect()
fun asFlow(
globalMeta: GlobalMeta,
example: Int = globalMeta.example,
) = flow {
if (example != globalMeta.example) {
globalRepository.updateGlobalMeta(
"example",
example.toString(),
).collect()
}
emit(Unit)
@Inject
class UpdateGlobalMeta(
private val globalRepository: GlobalRepository,
) {
suspend fun await(
globalMeta: GlobalMeta,
example: Int = globalMeta.example,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(globalMeta, example)
.catch {
onError(it)
log.warn(it) { "Failed to update global meta" }
}
.collect()
companion object {
private val log = logging()
fun asFlow(
globalMeta: GlobalMeta,
example: Int = globalMeta.example,
) = flow {
if (example != globalMeta.example) {
globalRepository.updateGlobalMeta(
"example",
example.toString(),
).collect()
}
emit(Unit)
}
companion object {
private val log = logging()
}
}

View File

@@ -15,41 +15,40 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class AddMangaToLibrary
@Inject
constructor(
private val libraryRepository: LibraryRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to add $mangaId to library" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to add ${manga.title}(${manga.id}) to library" }
}
.singleOrNull()
fun asFlow(mangaId: Long) =
libraryRepository.addMangaToLibrary(mangaId)
.onEach { serverListeners.updateManga(mangaId) }
fun asFlow(manga: Manga) =
libraryRepository.addMangaToLibrary(manga.id)
.onEach { serverListeners.updateManga(manga.id) }
companion object {
private val log = logging()
@Inject
class AddMangaToLibrary(
private val libraryRepository: LibraryRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to add $mangaId to library" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to add ${manga.title}(${manga.id}) to library" }
}
.singleOrNull()
fun asFlow(mangaId: Long) =
libraryRepository.addMangaToLibrary(mangaId)
.onEach { serverListeners.updateManga(mangaId) }
fun asFlow(manga: Manga) =
libraryRepository.addMangaToLibrary(manga.id)
.onEach { serverListeners.updateManga(manga.id) }
companion object {
private val log = logging()
}
}

View File

@@ -15,41 +15,40 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class RemoveMangaFromLibrary
@Inject
constructor(
private val libraryRepository: LibraryRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to remove $mangaId from library" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to remove ${manga.title}(${manga.id}) from library" }
}
.singleOrNull()
fun asFlow(mangaId: Long) =
libraryRepository.removeMangaFromLibrary(mangaId)
.onEach { serverListeners.updateManga(mangaId) }
fun asFlow(manga: Manga) =
libraryRepository.removeMangaFromLibrary(manga.id)
.onEach { serverListeners.updateManga(manga.id) }
companion object {
private val log = logging()
@Inject
class RemoveMangaFromLibrary(
private val libraryRepository: LibraryRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.catch {
onError(it)
log.warn(it) { "Failed to remove $mangaId from library" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.catch {
onError(it)
log.warn(it) { "Failed to remove ${manga.title}(${manga.id}) from library" }
}
.singleOrNull()
fun asFlow(mangaId: Long) =
libraryRepository.removeMangaFromLibrary(mangaId)
.onEach { serverListeners.updateManga(mangaId) }
fun asFlow(manga: Manga) =
libraryRepository.removeMangaFromLibrary(manga.id)
.onEach { serverListeners.updateManga(manga.id) }
companion object {
private val log = logging()
}
}

View File

@@ -16,26 +16,25 @@ import kotlinx.coroutines.flow.MutableStateFlow
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class LibraryUpdateService
@Inject
constructor(
serverPreferences: ServerPreferences,
client: Http,
) : WebsocketService(serverPreferences, client) {
override val _status: MutableStateFlow<Status>
get() = status
@Inject
class LibraryUpdateService(
serverPreferences: ServerPreferences,
client: Http,
) : WebsocketService(serverPreferences, client) {
override val _status: MutableStateFlow<Status>
get() = status
override val query: String
get() = "/api/v1/update"
override val query: String
get() = "/api/v1/update"
override suspend fun onReceived(frame: Frame.Text) {
updateStatus.value = json.decodeFromString<UpdateStatus>(frame.readText())
}
companion object {
private val log = logging()
val status = MutableStateFlow(Status.STARTING)
val updateStatus = MutableStateFlow(UpdateStatus(emptyMap(), emptyMap(), false))
}
override suspend fun onReceived(frame: Frame.Text) {
updateStatus.value = json.decodeFromString<UpdateStatus>(frame.readText())
}
companion object {
private val log = logging()
val status = MutableStateFlow(Status.STARTING)
val updateStatus = MutableStateFlow(UpdateStatus(emptyMap(), emptyMap(), false))
}
}

View File

@@ -15,45 +15,44 @@ import kotlinx.coroutines.flow.take
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetManga
@Inject
constructor(
private val mangaRepository: MangaRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get manga $mangaId" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get manga ${manga.title}(${manga.id})" }
}
.singleOrNull()
fun asFlow(mangaId: Long) =
serverListeners.combineMangaUpdates(
mangaRepository.getManga(mangaId),
) { mangaId in it }
fun asFlow(manga: Manga) =
serverListeners.combineMangaUpdates(
mangaRepository.getManga(manga.id),
) { manga.id in it }
companion object {
private val log = logging()
@Inject
class GetManga(
private val mangaRepository: MangaRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get manga $mangaId" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to get manga ${manga.title}(${manga.id})" }
}
.singleOrNull()
fun asFlow(mangaId: Long) =
serverListeners.combineMangaUpdates(
mangaRepository.getManga(mangaId),
) { mangaId in it }
fun asFlow(manga: Manga) =
serverListeners.combineMangaUpdates(
mangaRepository.getManga(manga.id),
) { manga.id in it }
companion object {
private val log = logging()
}
}

View File

@@ -16,39 +16,38 @@ import kotlinx.coroutines.flow.take
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class RefreshManga
@Inject
constructor(
private val mangaRepository: MangaRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to refresh manga $mangaId" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to refresh manga ${manga.title}(${manga.id})" }
}
.singleOrNull()
fun asFlow(mangaId: Long) = mangaRepository.refreshManga(mangaId).onEach { serverListeners.updateManga(mangaId) }
fun asFlow(manga: Manga) = mangaRepository.refreshManga(manga.id).onEach { serverListeners.updateManga(manga.id) }
companion object {
private val log = logging()
@Inject
class RefreshManga(
private val mangaRepository: MangaRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
mangaId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(mangaId)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to refresh manga $mangaId" }
}
.singleOrNull()
suspend fun await(
manga: Manga,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga)
.take(1)
.catch {
onError(it)
log.warn(it) { "Failed to refresh manga ${manga.title}(${manga.id})" }
}
.singleOrNull()
fun asFlow(mangaId: Long) = mangaRepository.refreshManga(mangaId).onEach { serverListeners.updateManga(mangaId) }
fun asFlow(manga: Manga) = mangaRepository.refreshManga(manga.id).onEach { serverListeners.updateManga(manga.id) }
companion object {
private val log = logging()
}
}

View File

@@ -17,39 +17,38 @@ import kotlinx.coroutines.flow.flow
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdateMangaMeta
@Inject
constructor(
private val mangaRepository: MangaRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
manga: Manga,
readerMode: String = manga.meta.juiReaderMode,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga, readerMode)
.catch {
onError(it)
log.warn(it) { "Failed to update ${manga.title}(${manga.id}) meta" }
}
.collect()
fun asFlow(
manga: Manga,
readerMode: String = manga.meta.juiReaderMode.decodeURLQueryComponent(),
) = flow {
if (readerMode.encodeURLQueryComponent() != manga.meta.juiReaderMode) {
mangaRepository.updateMangaMeta(
manga.id,
"juiReaderMode",
readerMode,
).collect()
serverListeners.updateManga(manga.id)
}
emit(Unit)
@Inject
class UpdateMangaMeta(
private val mangaRepository: MangaRepository,
private val serverListeners: ServerListeners,
) {
suspend fun await(
manga: Manga,
readerMode: String = manga.meta.juiReaderMode,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manga, readerMode)
.catch {
onError(it)
log.warn(it) { "Failed to update ${manga.title}(${manga.id}) meta" }
}
.collect()
companion object {
private val log = logging()
fun asFlow(
manga: Manga,
readerMode: String = manga.meta.juiReaderMode.decodeURLQueryComponent(),
) = flow {
if (readerMode.encodeURLQueryComponent() != manga.meta.juiReaderMode) {
mangaRepository.updateMangaMeta(
manga.id,
"juiReaderMode",
readerMode,
).collect()
serverListeners.updateManga(manga.id)
}
emit(Unit)
}
companion object {
private val log = logging()
}
}

View File

@@ -11,20 +11,19 @@ import ca.gosyer.jui.domain.migration.service.MigrationPreferences
import ca.gosyer.jui.domain.reader.service.ReaderPreferences
import me.tatarka.inject.annotations.Inject
class RunMigrations
@Inject
constructor(
private val migrationPreferences: MigrationPreferences,
private val readerPreferences: ReaderPreferences,
) {
fun runMigrations() {
val code = migrationPreferences.version().get()
if (code <= 0) {
readerPreferences.modes().get().forEach {
readerPreferences.getMode(it).direction().delete()
}
migrationPreferences.version().set(BuildKonfig.MIGRATION_CODE)
return
@Inject
class RunMigrations(
private val migrationPreferences: MigrationPreferences,
private val readerPreferences: ReaderPreferences,
) {
fun runMigrations() {
val code = migrationPreferences.version().get()
if (code <= 0) {
readerPreferences.modes().get().forEach {
readerPreferences.getMode(it).direction().delete()
}
migrationPreferences.version().set(BuildKonfig.MIGRATION_CODE)
return
}
}
}

View File

@@ -12,22 +12,21 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class AboutServer
@Inject
constructor(
private val settingsRepository: SettingsRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to get server information" }
}
.singleOrNull()
@Inject
class AboutServer(
private val settingsRepository: SettingsRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to get server information" }
}
.singleOrNull()
fun asFlow() = settingsRepository.aboutServer()
fun asFlow() = settingsRepository.aboutServer()
companion object {
private val log = logging()
}
companion object {
private val log = logging()
}
}

View File

@@ -12,22 +12,21 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetSettings
@Inject
constructor(
private val settingsRepository: SettingsRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to check for server updates" }
}
.singleOrNull()
@Inject
class GetSettings(
private val settingsRepository: SettingsRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to check for server updates" }
}
.singleOrNull()
fun asFlow() = settingsRepository.getSettings()
fun asFlow() = settingsRepository.getSettings()
companion object {
private val log = logging()
}
companion object {
private val log = logging()
}
}

View File

@@ -13,24 +13,23 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class SetSettings
@Inject
constructor(
private val settingsRepository: SettingsRepository,
) {
suspend fun await(
input: SetSettingsInput,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(input)
.catch {
onError(it)
log.warn(it) { "Failed to check for server updates" }
}
.singleOrNull()
fun asFlow(input: SetSettingsInput) = settingsRepository.setSettings(input)
companion object {
private val log = logging()
@Inject
class SetSettings(
private val settingsRepository: SettingsRepository,
) {
suspend fun await(
input: SetSettingsInput,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(input)
.catch {
onError(it)
log.warn(it) { "Failed to check for server updates" }
}
.singleOrNull()
fun asFlow(input: SetSettingsInput) = settingsRepository.setSettings(input)
companion object {
private val log = logging()
}
}

View File

@@ -13,36 +13,35 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetFilterList
@Inject
constructor(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
source: Source,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(source.id)
.catch {
onError(it)
log.warn(it) { "Failed to get filter list for ${source.displayName}" }
}
.singleOrNull()
suspend fun await(
sourceId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(sourceId)
.catch {
onError(it)
log.warn(it) { "Failed to get filter list for $sourceId" }
}
.singleOrNull()
fun asFlow(source: Source) = sourceRepository.getFilterList(source.id)
fun asFlow(sourceId: Long) = sourceRepository.getFilterList(sourceId)
companion object {
private val log = logging()
@Inject
class GetFilterList(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
source: Source,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(source.id)
.catch {
onError(it)
log.warn(it) { "Failed to get filter list for ${source.displayName}" }
}
.singleOrNull()
suspend fun await(
sourceId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(sourceId)
.catch {
onError(it)
log.warn(it) { "Failed to get filter list for $sourceId" }
}
.singleOrNull()
fun asFlow(source: Source) = sourceRepository.getFilterList(source.id)
fun asFlow(sourceId: Long) = sourceRepository.getFilterList(sourceId)
companion object {
private val log = logging()
}
}

View File

@@ -13,44 +13,43 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetLatestManga
@Inject
constructor(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
source: Source,
page: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(source.id, page)
.catch {
onError(it)
log.warn(it) { "Failed to get latest manga from ${source.displayName} on page $page" }
}
.singleOrNull()
suspend fun await(
sourceId: Long,
page: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(sourceId, page)
.catch {
onError(it)
log.warn(it) { "Failed to get latest manga from $sourceId on page $page" }
}
.singleOrNull()
fun asFlow(
source: Source,
page: Int,
) = sourceRepository.getLatestManga(source.id, page)
fun asFlow(
sourceId: Long,
page: Int,
) = sourceRepository.getLatestManga(sourceId, page)
companion object {
private val log = logging()
@Inject
class GetLatestManga(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
source: Source,
page: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(source.id, page)
.catch {
onError(it)
log.warn(it) { "Failed to get latest manga from ${source.displayName} on page $page" }
}
.singleOrNull()
suspend fun await(
sourceId: Long,
page: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(sourceId, page)
.catch {
onError(it)
log.warn(it) { "Failed to get latest manga from $sourceId on page $page" }
}
.singleOrNull()
fun asFlow(
source: Source,
page: Int,
) = sourceRepository.getLatestManga(source.id, page)
fun asFlow(
sourceId: Long,
page: Int,
) = sourceRepository.getLatestManga(sourceId, page)
companion object {
private val log = logging()
}
}

View File

@@ -13,44 +13,43 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetPopularManga
@Inject
constructor(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
source: Source,
page: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(source.id, page)
.catch {
onError(it)
log.warn(it) { "Failed to get popular manga from ${source.displayName} on page $page" }
}
.singleOrNull()
suspend fun await(
sourceId: Long,
page: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(sourceId, page)
.catch {
onError(it)
log.warn(it) { "Failed to get popular manga from $sourceId on page $page" }
}
.singleOrNull()
fun asFlow(
source: Source,
page: Int,
) = sourceRepository.getPopularManga(source.id, page)
fun asFlow(
sourceId: Long,
page: Int,
) = sourceRepository.getPopularManga(sourceId, page)
companion object {
private val log = logging()
@Inject
class GetPopularManga(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
source: Source,
page: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(source.id, page)
.catch {
onError(it)
log.warn(it) { "Failed to get popular manga from ${source.displayName} on page $page" }
}
.singleOrNull()
suspend fun await(
sourceId: Long,
page: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(sourceId, page)
.catch {
onError(it)
log.warn(it) { "Failed to get popular manga from $sourceId on page $page" }
}
.singleOrNull()
fun asFlow(
source: Source,
page: Int,
) = sourceRepository.getPopularManga(source.id, page)
fun asFlow(
sourceId: Long,
page: Int,
) = sourceRepository.getPopularManga(sourceId, page)
companion object {
private val log = logging()
}
}

View File

@@ -14,64 +14,63 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetSearchManga
@Inject
constructor(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
source: Source,
page: Int,
searchTerm: String?,
filters: List<SourceFilter>?,
onError: suspend (Throwable) -> Unit = {
},
) = asFlow(source.id, page, searchTerm, filters)
.catch {
onError(it)
log.warn(it) { "Failed to get search results from ${source.displayName} on page $page with query '$searchTerm'" }
}
.singleOrNull()
suspend fun await(
sourceId: Long,
searchTerm: String?,
page: Int,
filters: List<SourceFilter>?,
onError: suspend (Throwable) -> Unit = {
},
) = asFlow(sourceId, page, searchTerm, filters)
.catch {
onError(it)
log.warn(it) { "Failed to get search results from $sourceId on page $page with query '$searchTerm'" }
}
.singleOrNull()
fun asFlow(
source: Source,
page: Int,
searchTerm: String?,
filters: List<SourceFilter>?,
) = sourceRepository.getSearchResults(
source.id,
page,
searchTerm?.ifBlank { null },
filters,
)
fun asFlow(
sourceId: Long,
page: Int,
searchTerm: String?,
filters: List<SourceFilter>?,
) = sourceRepository.getSearchResults(
sourceId,
page,
searchTerm?.ifBlank { null },
filters,
)
companion object {
private val log = logging()
@Inject
class GetSearchManga(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
source: Source,
page: Int,
searchTerm: String?,
filters: List<SourceFilter>?,
onError: suspend (Throwable) -> Unit = {
},
) = asFlow(source.id, page, searchTerm, filters)
.catch {
onError(it)
log.warn(it) { "Failed to get search results from ${source.displayName} on page $page with query '$searchTerm'" }
}
.singleOrNull()
suspend fun await(
sourceId: Long,
searchTerm: String?,
page: Int,
filters: List<SourceFilter>?,
onError: suspend (Throwable) -> Unit = {
},
) = asFlow(sourceId, page, searchTerm, filters)
.catch {
onError(it)
log.warn(it) { "Failed to get search results from $sourceId on page $page with query '$searchTerm'" }
}
.singleOrNull()
fun asFlow(
source: Source,
page: Int,
searchTerm: String?,
filters: List<SourceFilter>?,
) = sourceRepository.getSearchResults(
source.id,
page,
searchTerm?.ifBlank { null },
filters,
)
fun asFlow(
sourceId: Long,
page: Int,
searchTerm: String?,
filters: List<SourceFilter>?,
) = sourceRepository.getSearchResults(
sourceId,
page,
searchTerm?.ifBlank { null },
filters,
)
companion object {
private val log = logging()
}
}

View File

@@ -12,22 +12,21 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetSourceList
@Inject
constructor(
private val sourceRepository: SourceRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to get source list" }
}
.singleOrNull()
@Inject
class GetSourceList(
private val sourceRepository: SourceRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to get source list" }
}
.singleOrNull()
fun asFlow() = sourceRepository.getSourceList()
fun asFlow() = sourceRepository.getSourceList()
companion object {
private val log = logging()
}
companion object {
private val log = logging()
}
}

View File

@@ -13,36 +13,35 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetSourceSettings
@Inject
constructor(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
source: Source,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(source.id)
.catch {
onError(it)
log.warn(it) { "Failed to get source settings for ${source.displayName}" }
}
.singleOrNull()
suspend fun await(
sourceId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(sourceId)
.catch {
onError(it)
log.warn(it) { "Failed to get source settings for $sourceId" }
}
.singleOrNull()
fun asFlow(source: Source) = sourceRepository.getSourceSettings(source.id)
fun asFlow(sourceId: Long) = sourceRepository.getSourceSettings(sourceId)
companion object {
private val log = logging()
@Inject
class GetSourceSettings(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
source: Source,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(source.id)
.catch {
onError(it)
log.warn(it) { "Failed to get source settings for ${source.displayName}" }
}
.singleOrNull()
suspend fun await(
sourceId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(sourceId)
.catch {
onError(it)
log.warn(it) { "Failed to get source settings for $sourceId" }
}
.singleOrNull()
fun asFlow(source: Source) = sourceRepository.getSourceSettings(source.id)
fun asFlow(sourceId: Long) = sourceRepository.getSourceSettings(sourceId)
companion object {
private val log = logging()
}
}

View File

@@ -13,32 +13,31 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class SetSourceSetting
@Inject
constructor(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
sourceId: Long,
sourcePreference: SourcePreference,
onError: suspend (Throwable) -> Unit = {
},
) = asFlow(sourceId, sourcePreference)
.catch {
onError(it)
log.warn(it) { "Failed to set setting for $sourceId with index = ${sourcePreference.position}" }
}
.collect()
fun asFlow(
sourceId: Long,
sourcePreference: SourcePreference,
) = sourceRepository.setSourceSetting(
sourceId,
sourcePreference,
)
companion object {
private val log = logging()
@Inject
class SetSourceSetting(
private val sourceRepository: SourceRepository,
) {
suspend fun await(
sourceId: Long,
sourcePreference: SourcePreference,
onError: suspend (Throwable) -> Unit = {
},
) = asFlow(sourceId, sourcePreference)
.catch {
onError(it)
log.warn(it) { "Failed to set setting for $sourceId with index = ${sourcePreference.position}" }
}
.collect()
fun asFlow(
sourceId: Long,
sourcePreference: SourcePreference,
) = sourceRepository.setSourceSetting(
sourceId,
sourcePreference,
)
companion object {
private val log = logging()
}
}

View File

@@ -33,21 +33,21 @@ fun interface GetMangaPage {
suspend fun get(page: Int): MangaPage?
}
class SourcePager
@Inject
constructor(
private val getManga: GetManga,
private val serverListeners: ServerListeners,
@Assisted private val fetcher: GetMangaPage,
) : CoroutineScope by CoroutineScope(Dispatchers.Default + SupervisorJob()) {
private val sourceMutex = Mutex()
@Inject
class SourcePager(
private val getManga: GetManga,
private val serverListeners: ServerListeners,
@Assisted private val fetcher: GetMangaPage,
) : CoroutineScope by CoroutineScope(Dispatchers.Default + SupervisorJob()) {
private val sourceMutex = Mutex()
private val _sourceManga = MutableStateFlow<List<Manga>>(emptyList())
private val _sourceManga = MutableStateFlow<List<Manga>>(emptyList())
private val mangaIds = _sourceManga.map { mangas -> mangas.map { it.id } }
.stateIn(this, SharingStarted.Eagerly, emptyList())
private val mangaIds = _sourceManga.map { mangas -> mangas.map { it.id } }
.stateIn(this, SharingStarted.Eagerly, emptyList())
private val changedManga = serverListeners.mangaListener.runningFold(emptyMap<Long, Manga>()) { manga, updatedMangaIds ->
private val changedManga =
serverListeners.mangaListener.runningFold(emptyMap<Long, Manga>()) { manga, updatedMangaIds ->
coroutineScope {
manga + updatedMangaIds.filter { it in mangaIds.value }.map {
async {
@@ -57,37 +57,37 @@ class SourcePager
}
}.stateIn(this, SharingStarted.Eagerly, emptyMap())
val mangas = combine(_sourceManga, changedManga) { sourceManga, changedManga ->
sourceManga.map { changedManga[it.id] ?: it }
}.stateIn(this, SharingStarted.Eagerly, emptyList())
val mangas = combine(_sourceManga, changedManga) { sourceManga, changedManga ->
sourceManga.map { changedManga[it.id] ?: it }
}.stateIn(this, SharingStarted.Eagerly, emptyList())
private val _pageNum = MutableStateFlow(0)
val pageNum = _pageNum.asStateFlow()
private val _pageNum = MutableStateFlow(0)
val pageNum = _pageNum.asStateFlow()
private val _hasNextPage = MutableStateFlow(true)
val hasNextPage = _hasNextPage.asStateFlow()
private val _hasNextPage = MutableStateFlow(true)
val hasNextPage = _hasNextPage.asStateFlow()
private val _loading = MutableStateFlow(true)
val loading = _loading.asStateFlow()
private val _loading = MutableStateFlow(true)
val loading = _loading.asStateFlow()
fun loadNextPage() {
launch {
if (hasNextPage.value && sourceMutex.tryLock()) {
_pageNum.value++
val page = fetcher.get(_pageNum.value)
if (page != null) {
_sourceManga.value = _sourceManga.value + page.mangaList
_hasNextPage.value = page.hasNextPage
} else {
_pageNum.value--
}
sourceMutex.unlock()
fun loadNextPage() {
launch {
if (hasNextPage.value && sourceMutex.tryLock()) {
_pageNum.value++
val page = fetcher.get(_pageNum.value)
if (page != null) {
_sourceManga.value = _sourceManga.value + page.mangaList
_hasNextPage.value = page.hasNextPage
} else {
_pageNum.value--
}
_loading.value = false
sourceMutex.unlock()
}
}
companion object {
private val log = logging()
_loading.value = false
}
}
companion object {
private val log = logging()
}
}

View File

@@ -15,7 +15,7 @@ enum class StartScreen {
Library,
Updates,
// History,
// History,
Sources,
Extensions,
}

View File

@@ -12,24 +12,23 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GetRecentUpdates
@Inject
constructor(
private val updatesRepository: UpdatesRepository,
) {
suspend fun await(
pageNum: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(pageNum)
.catch {
onError(it)
log.warn(it) { "Failed to get updates for page $pageNum" }
}
.singleOrNull()
fun asFlow(pageNum: Int) = updatesRepository.getRecentUpdates(pageNum)
companion object {
private val log = logging()
@Inject
class GetRecentUpdates(
private val updatesRepository: UpdatesRepository,
) {
suspend fun await(
pageNum: Int,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(pageNum)
.catch {
onError(it)
log.warn(it) { "Failed to get updates for page $pageNum" }
}
.singleOrNull()
fun asFlow(pageNum: Int) = updatesRepository.getRecentUpdates(pageNum)
companion object {
private val log = logging()
}
}

View File

@@ -13,36 +13,35 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdateCategory
@Inject
constructor(
private val updatesRepository: UpdatesRepository,
) {
suspend fun await(
categoryId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(categoryId)
.catch {
onError(it)
log.warn(it) { "Failed to update category $categoryId" }
}
.collect()
suspend fun await(
category: Category,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(category)
.catch {
onError(it)
log.warn(it) { "Failed to update category ${category.name}(${category.id})" }
}
.collect()
fun asFlow(categoryId: Long) = updatesRepository.updateCategory(categoryId)
fun asFlow(category: Category) = updatesRepository.updateCategory(category.id)
companion object {
private val log = logging()
@Inject
class UpdateCategory(
private val updatesRepository: UpdatesRepository,
) {
suspend fun await(
categoryId: Long,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(categoryId)
.catch {
onError(it)
log.warn(it) { "Failed to update category $categoryId" }
}
.collect()
suspend fun await(
category: Category,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(category)
.catch {
onError(it)
log.warn(it) { "Failed to update category ${category.name}(${category.id})" }
}
.collect()
fun asFlow(categoryId: Long) = updatesRepository.updateCategory(categoryId)
fun asFlow(category: Category) = updatesRepository.updateCategory(category.id)
companion object {
private val log = logging()
}
}

View File

@@ -21,77 +21,76 @@ import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdateChecker
@Inject
constructor(
private val updatePreferences: UpdatePreferences,
private val client: Http,
) {
suspend fun await(
manualFetch: Boolean,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manualFetch)
.catch {
onError(it)
log.warn(it) { "Failed to check for updates" }
}
.singleOrNull()
fun asFlow(manualFetch: Boolean) =
flow {
if (!manualFetch && !updatePreferences.enabled().get()) return@flow
val latestRelease = client.get(
"https://api.github.com/repos/$GITHUB_REPO/releases/latest",
).body<GithubRelease>()
if (isNewVersion(latestRelease.version)) {
emit(Update.UpdateFound(latestRelease))
} else {
emit(Update.NoUpdatesFound)
}
}.flowOn(Dispatchers.IO)
sealed class Update {
data class UpdateFound(
val release: GithubRelease,
) : Update()
data object NoUpdatesFound : Update()
@Inject
class UpdateChecker(
private val updatePreferences: UpdatePreferences,
private val client: Http,
) {
suspend fun await(
manualFetch: Boolean,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(manualFetch)
.catch {
onError(it)
log.warn(it) { "Failed to check for updates" }
}
.singleOrNull()
// Thanks to Tachiyomi for inspiration
private fun isNewVersion(versionTag: String): Boolean {
// Removes prefixes like "r" or "v"
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
fun asFlow(manualFetch: Boolean) =
flow {
if (!manualFetch && !updatePreferences.enabled().get()) return@flow
val latestRelease = client.get(
"https://api.github.com/repos/$GITHUB_REPO/releases/latest",
).body<GithubRelease>()
return if (BuildKonfig.IS_PREVIEW) {
// Preview builds: based on releases in "Suwayomi/Suwayomi-JUI-preview" repo
// tagged as something like "r123"
newVersion.toInt() > BuildKonfig.PREVIEW_BUILD
if (isNewVersion(latestRelease.version)) {
emit(Update.UpdateFound(latestRelease))
} else {
// Release builds: based on releases in "Suwayomi/Suwayomi-JUI" repo
// tagged as something like "v1.1.2"
newVersion != BuildKonfig.VERSION
emit(Update.NoUpdatesFound)
}
}
}.flowOn(Dispatchers.IO)
companion object {
private val GITHUB_REPO = if (BuildKonfig.IS_PREVIEW) {
"Suwayomi/Suwayomi-JUI-preview"
} else {
"Suwayomi/Suwayomi-JUI"
}
sealed class Update {
data class UpdateFound(
val release: GithubRelease,
) : Update()
private val RELEASE_TAG: String by lazy {
if (BuildKonfig.IS_PREVIEW) {
"r${BuildKonfig.PREVIEW_BUILD}"
} else {
"v${BuildKonfig.VERSION}"
}
}
data object NoUpdatesFound : Update()
}
val RELEASE_URL = "https://github.com/$GITHUB_REPO/releases/tag/$RELEASE_TAG"
// Thanks to Tachiyomi for inspiration
private fun isNewVersion(versionTag: String): Boolean {
// Removes prefixes like "r" or "v"
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
private val log = logging()
return if (BuildKonfig.IS_PREVIEW) {
// Preview builds: based on releases in "Suwayomi/Suwayomi-JUI-preview" repo
// tagged as something like "r123"
newVersion.toInt() > BuildKonfig.PREVIEW_BUILD
} else {
// Release builds: based on releases in "Suwayomi/Suwayomi-JUI" repo
// tagged as something like "v1.1.2"
newVersion != BuildKonfig.VERSION
}
}
companion object {
private val GITHUB_REPO = if (BuildKonfig.IS_PREVIEW) {
"Suwayomi/Suwayomi-JUI-preview"
} else {
"Suwayomi/Suwayomi-JUI"
}
private val RELEASE_TAG: String by lazy {
if (BuildKonfig.IS_PREVIEW) {
"r${BuildKonfig.PREVIEW_BUILD}"
} else {
"v${BuildKonfig.VERSION}"
}
}
val RELEASE_URL = "https://github.com/$GITHUB_REPO/releases/tag/$RELEASE_TAG"
private val log = logging()
}
}

View File

@@ -12,22 +12,21 @@ import kotlinx.coroutines.flow.collect
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdateLibrary
@Inject
constructor(
private val updatesRepository: UpdatesRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to update library" }
}
.collect()
@Inject
class UpdateLibrary(
private val updatesRepository: UpdatesRepository,
) {
suspend fun await(onError: suspend (Throwable) -> Unit = {}) =
asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to update library" }
}
.collect()
fun asFlow() = updatesRepository.updateLibrary()
fun asFlow() = updatesRepository.updateLibrary()
companion object {
private val log = logging()
}
companion object {
private val log = logging()
}
}

View File

@@ -37,54 +37,54 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import me.tatarka.inject.annotations.Inject
class UpdatesPager
@Inject
constructor(
private val getRecentUpdates: GetRecentUpdates,
private val getManga: GetManga,
private val getChapter: GetChapter,
private val serverListeners: ServerListeners,
) : CoroutineScope by CoroutineScope(Dispatchers.Default + SupervisorJob()) {
private val updatesMutex = Mutex()
@Inject
class UpdatesPager(
private val getRecentUpdates: GetRecentUpdates,
private val getManga: GetManga,
private val getChapter: GetChapter,
private val serverListeners: ServerListeners,
) : CoroutineScope by CoroutineScope(Dispatchers.Default + SupervisorJob()) {
private val updatesMutex = Mutex()
private val fetchedUpdates = MutableSharedFlow<List<MangaAndChapter>>()
private val foldedUpdates = fetchedUpdates.runningFold(emptyList<Updates>()) { updates, newUpdates ->
updates.ifEmpty {
val first = newUpdates.firstOrNull()?.chapter ?: return@runningFold updates
listOf(
Updates.Date(
Instant.fromEpochSeconds(first.fetchedAt)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date,
),
)
} + newUpdates.fold(emptyList()) { list, (manga, chapter) ->
val date = (list.lastOrNull() as? Updates.Update)?.let {
val lastUpdateDate = Instant.fromEpochSeconds(it.chapter.fetchedAt)
private val fetchedUpdates = MutableSharedFlow<List<MangaAndChapter>>()
private val foldedUpdates = fetchedUpdates.runningFold(emptyList<Updates>()) { updates, newUpdates ->
updates.ifEmpty {
val first = newUpdates.firstOrNull()?.chapter ?: return@runningFold updates
listOf(
Updates.Date(
Instant.fromEpochSeconds(first.fetchedAt)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
val chapterDate = Instant.fromEpochSeconds(chapter.fetchedAt)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
chapterDate.takeUnless { it == lastUpdateDate }
}
if (date == null) {
list + Updates.Update(manga, chapter)
} else {
list + Updates.Date(date) + Updates.Update(manga, chapter)
}
.date,
),
)
} + newUpdates.fold(emptyList()) { list, (manga, chapter) ->
val date = (list.lastOrNull() as? Updates.Update)?.let {
val lastUpdateDate = Instant.fromEpochSeconds(it.chapter.fetchedAt)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
val chapterDate = Instant.fromEpochSeconds(chapter.fetchedAt)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
chapterDate.takeUnless { it == lastUpdateDate }
}
}.stateIn(this, SharingStarted.Eagerly, emptyList())
private val mangaIds = foldedUpdates.map { updates ->
updates.filterIsInstance<Updates.Update>().map { it.manga.id }
}.stateIn(this, SharingStarted.Eagerly, emptyList())
private val chapterIds = foldedUpdates.map { updates ->
updates.filterIsInstance<Updates.Update>().map { it.chapter.id }
}.stateIn(this, SharingStarted.Eagerly, emptyList())
if (date == null) {
list + Updates.Update(manga, chapter)
} else {
list + Updates.Date(date) + Updates.Update(manga, chapter)
}
}
}.stateIn(this, SharingStarted.Eagerly, emptyList())
private val changedManga = serverListeners.mangaListener.runningFold(emptyMap<Long, Manga>()) { manga, updatedMangaIds ->
private val mangaIds = foldedUpdates.map { updates ->
updates.filterIsInstance<Updates.Update>().map { it.manga.id }
}.stateIn(this, SharingStarted.Eagerly, emptyList())
private val chapterIds = foldedUpdates.map { updates ->
updates.filterIsInstance<Updates.Update>().map { it.chapter.id }
}.stateIn(this, SharingStarted.Eagerly, emptyList())
private val changedManga =
serverListeners.mangaListener.runningFold(emptyMap<Long, Manga>()) { manga, updatedMangaIds ->
coroutineScope {
manga + updatedMangaIds.filter { it in mangaIds.value }.map {
async {
@@ -94,82 +94,82 @@ class UpdatesPager
}
}.stateIn(this, SharingStarted.Eagerly, emptyMap())
private val changedChapters = MutableStateFlow(emptyMap<Long, Chapter>())
private val changedChapters = MutableStateFlow(emptyMap<Long, Chapter>())
init {
serverListeners.chapterIdsListener
.onEach { updatedChapterIds ->
val chapters = coroutineScope {
updatedChapterIds.mapNotNull { id -> chapterIds.value.find { it == id } }.map {
async {
getChapter.await(it)
}
}.awaitAll().filterNotNull().associateBy { it.id }
}
changedChapters.update { it + chapters }
}
.launchIn(this)
}
val updates = combine(
foldedUpdates,
changedManga,
changedChapters,
) { updates, changedManga, changedChapters ->
updates.map {
when (it) {
is Updates.Date -> it
is Updates.Update -> it.copy(
manga = changedManga[it.manga.id] ?: it.manga,
chapter = changedChapters[it.chapter.id] ?: it.chapter,
)
init {
serverListeners.chapterIdsListener
.onEach { updatedChapterIds ->
val chapters = coroutineScope {
updatedChapterIds.mapNotNull { id -> chapterIds.value.find { it == id } }.map {
async {
getChapter.await(it)
}
}.awaitAll().filterNotNull().associateBy { it.id }
}
changedChapters.update { it + chapters }
}
}.stateIn(this, SharingStarted.Eagerly, emptyList())
.launchIn(this)
}
private val currentPage = MutableStateFlow(0)
private val hasNextPage = MutableStateFlow(true)
val updates = combine(
foldedUpdates,
changedManga,
changedChapters,
) { updates, changedManga, changedChapters ->
updates.map {
when (it) {
is Updates.Date -> it
is Updates.Update -> it.copy(
manga = changedManga[it.manga.id] ?: it.manga,
chapter = changedChapters[it.chapter.id] ?: it.chapter,
)
}
}
}.stateIn(this, SharingStarted.Eagerly, emptyList())
private val currentPage = MutableStateFlow(0)
private val hasNextPage = MutableStateFlow(true)
@Immutable
sealed class Updates {
@Immutable
data class Update(
val manga: Manga,
val chapter: Chapter,
) : Updates()
@Immutable
sealed class Updates {
@Immutable
data class Update(
val manga: Manga,
val chapter: Chapter,
) : Updates()
@Immutable
data class Date(
val date: String,
) : Updates() {
constructor(date: LocalDate) : this(date.toString())
}
}
fun loadNextPage(
onComplete: (() -> Unit)? = null,
onError: suspend (Throwable) -> Unit,
) {
launch {
if (hasNextPage.value && updatesMutex.tryLock()) {
currentPage.value++
if (!getUpdates(currentPage.value, onError)) {
currentPage.value--
}
updatesMutex.unlock()
}
onComplete?.invoke()
}
}
private suspend fun getUpdates(
page: Int,
onError: suspend (Throwable) -> Unit,
): Boolean {
val updates = getRecentUpdates.await(page, onError) ?: return false
hasNextPage.value = updates.hasNextPage
fetchedUpdates.emit(updates.page)
return true
data class Date(
val date: String,
) : Updates() {
constructor(date: LocalDate) : this(date.toString())
}
}
fun loadNextPage(
onComplete: (() -> Unit)? = null,
onError: suspend (Throwable) -> Unit,
) {
launch {
if (hasNextPage.value && updatesMutex.tryLock()) {
currentPage.value++
if (!getUpdates(currentPage.value, onError)) {
currentPage.value--
}
updatesMutex.unlock()
}
onComplete?.invoke()
}
}
private suspend fun getUpdates(
page: Int,
onError: suspend (Throwable) -> Unit,
): Boolean {
val updates = getRecentUpdates.await(page, onError) ?: return false
hasNextPage.value = updates.hasNextPage
fetchedUpdates.emit(updates.page)
return true
}
}

View File

@@ -38,169 +38,168 @@ import kotlin.io.path.exists
import kotlin.io.path.isExecutable
@OptIn(DelicateCoroutinesApi::class)
class ServerService
@Inject
constructor(
private val serverHostPreferences: ServerHostPreferences,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Inject
class ServerService(
private val serverHostPreferences: ServerHostPreferences,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val host = serverHostPreferences.host().stateIn(GlobalScope)
private val _initialized = MutableStateFlow(
if (host.value) {
ServerResult.STARTING
} else {
ServerResult.UNUSED
},
)
val initialized = _initialized.asStateFlow()
private var process: Process? = null
private val host = serverHostPreferences.host().stateIn(GlobalScope)
private val _initialized = MutableStateFlow(
if (host.value) {
ServerResult.STARTING
} else {
ServerResult.UNUSED
},
)
val initialized = _initialized.asStateFlow()
private var process: Process? = null
fun startAnyway() {
_initialized.value = ServerResult.UNUSED
}
fun startAnyway() {
_initialized.value = ServerResult.UNUSED
}
@Throws(IOException::class)
private suspend fun copyJar(jarFile: Path) {
javaClass.getResourceAsStream("/Tachidesk.jar")?.source()
?.copyTo(FileSystem.SYSTEM.sink(jarFile).buffer())
}
@Throws(IOException::class)
private suspend fun copyJar(jarFile: Path) {
javaClass.getResourceAsStream("/Tachidesk.jar")?.source()
?.copyTo(FileSystem.SYSTEM.sink(jarFile).buffer())
}
private fun getJavaFromPath(javaPath: Path): String? {
val javaExeFile = javaPath.resolve("java.exe").toNioPath()
val javaUnixFile = javaPath.resolve("java").toNioPath()
return when {
javaExeFile.exists() && javaExeFile.isExecutable() -> javaExeFile.absolutePathString()
javaUnixFile.exists() && javaUnixFile.isExecutable() -> javaUnixFile.absolutePathString()
else -> null
}
}
private fun getRuntimeJava(): String? = System.getProperty("java.home")?.let { getJavaFromPath(it.toPath().resolve("bin")) }
private fun getPossibleJava(): String? =
System.getProperty("java.library.path")?.split(pathSeparatorChar)
.orEmpty()
.asSequence()
.mapNotNull {
val file = it.toPath()
if (file.toString().contains("java") || file.toString().contains("jdk")) {
if (file.name.equals("bin", true)) {
file
} else {
file.resolve("bin")
}
} else {
null
}
}
.mapNotNull { getJavaFromPath(it) }
.firstOrNull()
private suspend fun runService() {
process?.destroy()
withIOContext {
process?.waitFor()
}
_initialized.value = if (host.value) {
ServerResult.STARTING
} else {
ServerResult.UNUSED
return
}
val jarFile = userDataDir / "Tachidesk.jar"
if (!FileSystem.SYSTEM.exists(jarFile)) {
log.info { "Copying server to resources" }
withIOContext { copyJar(jarFile) }
} else {
try {
val jarVersion = withIOContext {
JarInputStream(FileSystem.SYSTEM.source(jarFile).buffer().inputStream()).use { jar ->
jar.manifest?.mainAttributes?.getValue("JUI-KEY")?.toIntOrNull()
}
}
if (jarVersion != BuildKonfig.SERVER_CODE) {
log.info { "Updating server file from resources" }
withIOContext { copyJar(jarFile) }
}
} catch (e: IOException) {
log.error(e) {
"Error accessing server jar, cannot update server, ${BuildKonfig.NAME} may not work properly"
}
}
}
val javaPath = getRuntimeJava() ?: getPossibleJava() ?: "java"
log.info { "Starting server with $javaPath" }
val properties = serverHostPreferences.properties()
log.info { "Using server properties:\n" + properties.joinToString(separator = "\n") }
withIOContext {
val reader: Reader
process = ProcessBuilder(javaPath, *properties, "-jar", jarFile.toString())
.redirectErrorStream(true)
.start()
.also {
reader = it.inputStream.reader()
}
log.info { "Server started successfully" }
val log = logging("Server")
reader.forEachLine {
if (_initialized.value == ServerResult.STARTING) {
when {
it.contains("Javalin started") ->
_initialized.value = ServerResult.STARTED
it.contains("Javalin has stopped") ->
_initialized.value = ServerResult.FAILED
}
}
log.info { it }
}
if (_initialized.value == ServerResult.STARTING) {
_initialized.value = ServerResult.FAILED
}
log.info { "Server closed" }
val exitVal = process?.waitFor()
log.info { "Process exitValue: $exitVal" }
process = null
}
}
fun startServer() {
scope.coroutineContext.cancelChildren()
host
.mapLatest {
runService()
}
.catch {
log.error(it) { "Error launching Tachidesk.jar" }
if (_initialized.value == ServerResult.STARTING || _initialized.value == ServerResult.STARTED) {
_initialized.value = ServerResult.FAILED
}
}
.launchIn(scope)
}
init {
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
process?.destroy()
process = null
},
)
}
enum class ServerResult {
UNUSED,
STARTING,
STARTED,
FAILED,
}
private companion object {
private val log = logging()
private fun getJavaFromPath(javaPath: Path): String? {
val javaExeFile = javaPath.resolve("java.exe").toNioPath()
val javaUnixFile = javaPath.resolve("java").toNioPath()
return when {
javaExeFile.exists() && javaExeFile.isExecutable() -> javaExeFile.absolutePathString()
javaUnixFile.exists() && javaUnixFile.isExecutable() -> javaUnixFile.absolutePathString()
else -> null
}
}
private fun getRuntimeJava(): String? = System.getProperty("java.home")?.let { getJavaFromPath(it.toPath().resolve("bin")) }
private fun getPossibleJava(): String? =
System.getProperty("java.library.path")?.split(pathSeparatorChar)
.orEmpty()
.asSequence()
.mapNotNull {
val file = it.toPath()
if (file.toString().contains("java") || file.toString().contains("jdk")) {
if (file.name.equals("bin", true)) {
file
} else {
file.resolve("bin")
}
} else {
null
}
}
.mapNotNull { getJavaFromPath(it) }
.firstOrNull()
private suspend fun runService() {
process?.destroy()
withIOContext {
process?.waitFor()
}
_initialized.value = if (host.value) {
ServerResult.STARTING
} else {
ServerResult.UNUSED
return
}
val jarFile = userDataDir / "Tachidesk.jar"
if (!FileSystem.SYSTEM.exists(jarFile)) {
log.info { "Copying server to resources" }
withIOContext { copyJar(jarFile) }
} else {
try {
val jarVersion = withIOContext {
JarInputStream(FileSystem.SYSTEM.source(jarFile).buffer().inputStream()).use { jar ->
jar.manifest?.mainAttributes?.getValue("JUI-KEY")?.toIntOrNull()
}
}
if (jarVersion != BuildKonfig.SERVER_CODE) {
log.info { "Updating server file from resources" }
withIOContext { copyJar(jarFile) }
}
} catch (e: IOException) {
log.error(e) {
"Error accessing server jar, cannot update server, ${BuildKonfig.NAME} may not work properly"
}
}
}
val javaPath = getRuntimeJava() ?: getPossibleJava() ?: "java"
log.info { "Starting server with $javaPath" }
val properties = serverHostPreferences.properties()
log.info { "Using server properties:\n" + properties.joinToString(separator = "\n") }
withIOContext {
val reader: Reader
process = ProcessBuilder(javaPath, *properties, "-jar", jarFile.toString())
.redirectErrorStream(true)
.start()
.also {
reader = it.inputStream.reader()
}
log.info { "Server started successfully" }
val log = logging("Server")
reader.forEachLine {
if (_initialized.value == ServerResult.STARTING) {
when {
it.contains("Javalin started") ->
_initialized.value = ServerResult.STARTED
it.contains("Javalin has stopped") ->
_initialized.value = ServerResult.FAILED
}
}
log.info { it }
}
if (_initialized.value == ServerResult.STARTING) {
_initialized.value = ServerResult.FAILED
}
log.info { "Server closed" }
val exitVal = process?.waitFor()
log.info { "Process exitValue: $exitVal" }
process = null
}
}
fun startServer() {
scope.coroutineContext.cancelChildren()
host
.mapLatest {
runService()
}
.catch {
log.error(it) { "Error launching Tachidesk.jar" }
if (_initialized.value == ServerResult.STARTING || _initialized.value == ServerResult.STARTED) {
_initialized.value = ServerResult.FAILED
}
}
.launchIn(scope)
}
init {
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
process?.destroy()
process = null
},
)
}
enum class ServerResult {
UNUSED,
STARTING,
STARTED,
FAILED,
}
private companion object {
private val log = logging()
}
}

View File

@@ -15,8 +15,7 @@ import me.tatarka.inject.annotations.Inject
@Composable
actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostViewModel): LazyListScope.() -> Unit = {}
actual class SettingsServerHostViewModel
@Inject
constructor(
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper)
@Inject
actual class SettingsServerHostViewModel(
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper)

View File

@@ -30,104 +30,103 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import me.tatarka.inject.annotations.Inject
class ImageLoaderProvider
@Inject
constructor(
private val http: Http,
serverPreferences: ServerPreferences,
private val context: ContextWrapper,
) {
@OptIn(DelicateCoroutinesApi::class)
val serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope)
@Inject
class ImageLoaderProvider(
private val http: Http,
serverPreferences: ServerPreferences,
private val context: ContextWrapper,
) {
@OptIn(DelicateCoroutinesApi::class)
val serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope)
fun get(imageCache: ImageCache): ImageLoader =
ImageLoader {
components {
register(context, http)
add(MokoResourceFetcher.Factory())
add(MangaCoverMapper())
add(MangaCoverKeyer())
add(ExtensionIconMapper())
add(ExtensionIconKeyer())
add(SourceIconMapper())
add(SourceIconKeyer())
}
options {
configure(context)
}
interceptor {
diskCache { imageCache }
bitmapMemoryCacheConfig { configure(context) }
}
fun get(imageCache: ImageCache): ImageLoader =
ImageLoader {
components {
register(context, http)
add(MokoResourceFetcher.Factory())
add(MangaCoverMapper())
add(MangaCoverKeyer())
add(ExtensionIconMapper())
add(ExtensionIconKeyer())
add(SourceIconMapper())
add(SourceIconKeyer())
}
inner class MangaCoverMapper : Mapper<Url> {
override fun map(
data: Any,
options: Options,
): Url? {
if (data !is Manga) return null
if (data.thumbnailUrl.isNullOrBlank()) return null
return Url(serverUrl.value.toString() + data.thumbnailUrl)
options {
configure(context)
}
interceptor {
diskCache { imageCache }
bitmapMemoryCacheConfig { configure(context) }
}
}
class MangaCoverKeyer : Keyer {
override fun key(
data: Any,
options: Options,
type: Keyer.Type,
): String? {
if (data !is Manga) return null
return "${data.sourceId}-${data.thumbnailUrl}-${data.thumbnailUrlLastFetched}"
}
}
inner class ExtensionIconMapper : Mapper<Url> {
override fun map(
data: Any,
options: Options,
): Url? {
if (data !is Extension) return null
if (data.iconUrl.isBlank()) return null
return Url("${serverUrl.value}${data.iconUrl}")
}
}
class ExtensionIconKeyer : Keyer {
override fun key(
data: Any,
options: Options,
type: Keyer.Type,
): String? {
if (data !is Extension) return null
return data.iconUrl
}
}
inner class SourceIconMapper : Mapper<Url> {
override fun map(
data: Any,
options: Options,
): Url? {
if (data !is Source) return null
if (data.iconUrl.isBlank()) return null
return Url(serverUrl.value.toString() + data.iconUrl)
}
}
class SourceIconKeyer : Keyer {
override fun key(
data: Any,
options: Options,
type: Keyer.Type,
): String? {
if (data !is Source) return null
return data.iconUrl
}
inner class MangaCoverMapper : Mapper<Url> {
override fun map(
data: Any,
options: Options,
): Url? {
if (data !is Manga) return null
if (data.thumbnailUrl.isNullOrBlank()) return null
return Url(serverUrl.value.toString() + data.thumbnailUrl)
}
}
class MangaCoverKeyer : Keyer {
override fun key(
data: Any,
options: Options,
type: Keyer.Type,
): String? {
if (data !is Manga) return null
return "${data.sourceId}-${data.thumbnailUrl}-${data.thumbnailUrlLastFetched}"
}
}
inner class ExtensionIconMapper : Mapper<Url> {
override fun map(
data: Any,
options: Options,
): Url? {
if (data !is Extension) return null
if (data.iconUrl.isBlank()) return null
return Url("${serverUrl.value}${data.iconUrl}")
}
}
class ExtensionIconKeyer : Keyer {
override fun key(
data: Any,
options: Options,
type: Keyer.Type,
): String? {
if (data !is Extension) return null
return data.iconUrl
}
}
inner class SourceIconMapper : Mapper<Url> {
override fun map(
data: Any,
options: Options,
): Url? {
if (data !is Source) return null
if (data.iconUrl.isBlank()) return null
return Url(serverUrl.value.toString() + data.iconUrl)
}
}
class SourceIconKeyer : Keyer {
override fun key(
data: Any,
options: Options,
type: Keyer.Type,
): String? {
if (data !is Source) return null
return data.iconUrl
}
}
}
expect fun OptionsBuilder.configure(contextWrapper: ContextWrapper)
expect fun ComponentRegistryBuilder.register(

View File

@@ -59,100 +59,102 @@ fun AppTheme(content: @Composable () -> Unit) {
}
}
class AppThemeViewModel
@Inject
constructor(
private val uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
override val scope = MainScope()
@Inject
class AppThemeViewModel(
private val uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
override val scope = MainScope()
private val themeMode = uiPreferences.themeMode().asStateFlow()
private val lightTheme = uiPreferences.lightTheme().asStateFlow()
private val darkTheme = uiPreferences.darkTheme().asStateFlow()
private val themeMode = uiPreferences.themeMode().asStateFlow()
private val lightTheme = uiPreferences.lightTheme().asStateFlow()
private val darkTheme = uiPreferences.darkTheme().asStateFlow()
private val baseThemeJob = SupervisorJob()
private val baseThemeScope = CoroutineScope(baseThemeJob)
private val baseThemeJob = SupervisorJob()
private val baseThemeScope = CoroutineScope(baseThemeJob)
@Composable
fun getColors(): Pair<Colors, ExtraColors> {
val themeMode by themeMode.collectAsState()
val lightTheme by lightTheme.collectAsState()
val darkTheme by darkTheme.collectAsState()
@Composable
fun getColors(): Pair<Colors, ExtraColors> {
val themeMode by themeMode.collectAsState()
val lightTheme by lightTheme.collectAsState()
val darkTheme by darkTheme.collectAsState()
val baseTheme = getBaseTheme(themeMode, lightTheme, darkTheme)
val colors = remember(baseTheme.colors.isLight) {
baseThemeJob.cancelChildren()
val baseTheme = getBaseTheme(themeMode, lightTheme, darkTheme)
val colors = remember(baseTheme.colors.isLight) {
baseThemeJob.cancelChildren()
if (baseTheme.colors.isLight) {
uiPreferences.getLightColors().asStateFlow(baseThemeScope)
} else {
uiPreferences.getDarkColors().asStateFlow(baseThemeScope)
}
}
val primary by colors.primaryStateFlow.collectAsState()
val secondary by colors.secondaryStateFlow.collectAsState()
val tertiary by colors.tertiaryStateFlow.collectAsState()
return getMaterialColors(baseTheme.colors, primary, secondary) to getExtraColors(baseTheme.extraColors, tertiary)
}
@Composable
private fun getBaseTheme(
themeMode: ThemeMode,
lightTheme: Int,
darkTheme: Int,
): Theme {
fun getTheme(
id: Int,
isLight: Boolean,
): Theme =
themes.find { it.id == id && it.colors.isLight == isLight }
?: themes.first { it.colors.isLight == isLight }
return when (themeMode) {
ThemeMode.System -> if (!isSystemInDarkTheme()) {
getTheme(lightTheme, true)
} else {
getTheme(darkTheme, false)
}
ThemeMode.Light -> getTheme(lightTheme, true)
ThemeMode.Dark -> getTheme(darkTheme, false)
if (baseTheme.colors.isLight) {
uiPreferences.getLightColors().asStateFlow(baseThemeScope)
} else {
uiPreferences.getDarkColors().asStateFlow(baseThemeScope)
}
}
private fun getMaterialColors(
baseColors: Colors,
colorPrimary: Color,
colorSecondary: Color,
): Colors {
val primary = colorPrimary.takeOrElse { baseColors.primary }
val secondary = colorSecondary.takeOrElse { baseColors.secondary }
return baseColors.copy(
primary = primary,
primaryVariant = primary,
secondary = secondary,
secondaryVariant = secondary,
onPrimary = if (primary.luminance() > 0.5) Color.Black else Color.White,
onSecondary = if (secondary.luminance() > 0.5) Color.Black else Color.White,
)
}
val primary by colors.primaryStateFlow.collectAsState()
val secondary by colors.secondaryStateFlow.collectAsState()
val tertiary by colors.tertiaryStateFlow.collectAsState()
private fun getExtraColors(
baseExtraColors: ExtraColors,
colorTertiary: Color,
): ExtraColors {
val tertiary = colorTertiary.takeOrElse { baseExtraColors.tertiary }
return baseExtraColors.copy(
tertiary = tertiary,
)
}
return getMaterialColors(baseTheme.colors, primary, secondary) to getExtraColors(
baseTheme.extraColors,
tertiary,
)
}
override fun onDispose() {
baseThemeScope.cancel()
scope.cancel()
@Composable
private fun getBaseTheme(
themeMode: ThemeMode,
lightTheme: Int,
darkTheme: Int,
): Theme {
fun getTheme(
id: Int,
isLight: Boolean,
): Theme =
themes.find { it.id == id && it.colors.isLight == isLight }
?: themes.first { it.colors.isLight == isLight }
return when (themeMode) {
ThemeMode.System -> if (!isSystemInDarkTheme()) {
getTheme(lightTheme, true)
} else {
getTheme(darkTheme, false)
}
ThemeMode.Light -> getTheme(lightTheme, true)
ThemeMode.Dark -> getTheme(darkTheme, false)
}
}
private fun getMaterialColors(
baseColors: Colors,
colorPrimary: Color,
colorSecondary: Color,
): Colors {
val primary = colorPrimary.takeOrElse { baseColors.primary }
val secondary = colorSecondary.takeOrElse { baseColors.secondary }
return baseColors.copy(
primary = primary,
primaryVariant = primary,
secondary = secondary,
secondaryVariant = secondary,
onPrimary = if (primary.luminance() > 0.5) Color.Black else Color.White,
onSecondary = if (secondary.luminance() > 0.5) Color.Black else Color.White,
)
}
private fun getExtraColors(
baseExtraColors: ExtraColors,
colorTertiary: Color,
): ExtraColors {
val tertiary = colorTertiary.takeOrElse { baseExtraColors.tertiary }
return baseExtraColors.copy(
tertiary = tertiary,
)
}
override fun onDispose() {
baseThemeScope.cancel()
scope.cancel()
}
}

View File

@@ -27,121 +27,126 @@ import kotlinx.coroutines.launch
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class CategoriesScreenViewModel
@Inject
constructor(
private val getCategories: GetCategories,
private val createCategory: CreateCategory,
private val deleteCategory: DeleteCategory,
private val modifyCategory: ModifyCategory,
private val reorderCategory: ReorderCategory,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
private var originalCategories = emptyList<Category>()
private val _categories = MutableStateFlow<ImmutableList<MenuCategory>>(persistentListOf())
val categories = _categories.asStateFlow()
@Inject
class CategoriesScreenViewModel(
private val getCategories: GetCategories,
private val createCategory: CreateCategory,
private val deleteCategory: DeleteCategory,
private val modifyCategory: ModifyCategory,
private val reorderCategory: ReorderCategory,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
private var originalCategories = emptyList<Category>()
private val _categories = MutableStateFlow<ImmutableList<MenuCategory>>(persistentListOf())
val categories = _categories.asStateFlow()
init {
scope.launch {
getCategories()
}
}
private suspend fun getCategories() {
_categories.value = persistentListOf()
val categories = getCategories.await(true, onError = { toast(it.message.orEmpty()) })
if (categories != null) {
_categories.value = categories
.sortedBy { it.order }
.also { originalCategories = it }
.map { it.toMenuCategory() }
.toImmutableList()
}
}
suspend fun updateRemoteCategories(manualUpdate: Boolean = false) {
val categories = _categories.value
val newCategories = categories.filter { it.id == null }
newCategories.forEach {
createCategory.await(it.name, onError = { toast(it.message.orEmpty()) })
}
originalCategories.forEach { originalCategory ->
val category = categories.find { it.id == originalCategory.id }
if (category == null) {
deleteCategory.await(originalCategory, onError = { toast(it.message.orEmpty()) })
} else if (category.name != originalCategory.name) {
modifyCategory.await(originalCategory, category.name, onError = { toast(it.message.orEmpty()) })
}
}
var updatedCategories = getCategories.await(true, onError = { toast(it.message.orEmpty()) })
categories.sortedBy { it.order }.forEach { category ->
val updatedCategory = updatedCategories?.find { it.id == category.id || it.name == category.name } ?: return@forEach
if (category.order != updatedCategory.order) {
log.debug { "${category.name}: ${updatedCategory.order} to ${category.order}" }
reorderCategory.await(category.id!!, category.order, onError = { toast(it.message.orEmpty()) })
}
updatedCategories = getCategories.await(true, onError = { toast(it.message.orEmpty()) })
}
if (manualUpdate) {
getCategories()
}
}
fun renameCategory(
category: MenuCategory,
newName: String,
) {
_categories.value = (_categories.value.toPersistentList() - category + category.copy(name = newName)).sortedBy { it.order }
.toImmutableList()
}
fun deleteCategory(category: MenuCategory) {
_categories.value = _categories.value.toPersistentList() - category
}
fun createCategory(name: String) {
_categories.value =
_categories.value.toPersistentList() + MenuCategory(order = categories.value.size + 1, name = name, default = false)
}
fun moveUp(category: MenuCategory) {
val categories = _categories.value.toMutableList()
val index = categories.indexOf(category)
if (index == -1) throw Exception("Invalid index")
categories.add(index - 1, categories.removeAt(index))
_categories.value = categories
.mapIndexed { i, menuCategory ->
menuCategory.copy(order = i + 1)
}
.sortedBy { it.order }
.toImmutableList()
}
fun moveDown(category: MenuCategory) {
val categories = _categories.value.toMutableList()
val index = categories.indexOf(category)
if (index == -1) throw Exception("Invalid index")
categories.add(index + 1, categories.removeAt(index))
_categories.value = categories
.mapIndexed { i, menuCategory ->
menuCategory.copy(order = i + 1)
}
.sortedBy { it.order }
.toImmutableList()
}
private fun Category.toMenuCategory() = MenuCategory(id, order, name, default)
@Stable
data class MenuCategory(
val id: Long? = null,
val order: Int,
val name: String,
val default: Boolean = false,
)
private companion object {
private val log = logging()
init {
scope.launch {
getCategories()
}
}
private suspend fun getCategories() {
_categories.value = persistentListOf()
val categories = getCategories.await(true, onError = { toast(it.message.orEmpty()) })
if (categories != null) {
_categories.value = categories
.sortedBy { it.order }
.also { originalCategories = it }
.map { it.toMenuCategory() }
.toImmutableList()
}
}
suspend fun updateRemoteCategories(manualUpdate: Boolean = false) {
val categories = _categories.value
val newCategories = categories.filter { it.id == null }
newCategories.forEach {
createCategory.await(it.name, onError = { toast(it.message.orEmpty()) })
}
originalCategories.forEach { originalCategory ->
val category = categories.find { it.id == originalCategory.id }
if (category == null) {
deleteCategory.await(originalCategory, onError = { toast(it.message.orEmpty()) })
} else if (category.name != originalCategory.name) {
modifyCategory.await(originalCategory, category.name, onError = { toast(it.message.orEmpty()) })
}
}
var updatedCategories = getCategories.await(true, onError = { toast(it.message.orEmpty()) })
categories.sortedBy { it.order }.forEach { category ->
val updatedCategory =
updatedCategories?.find { it.id == category.id || it.name == category.name } ?: return@forEach
if (category.order != updatedCategory.order) {
log.debug { "${category.name}: ${updatedCategory.order} to ${category.order}" }
reorderCategory.await(category.id!!, category.order, onError = { toast(it.message.orEmpty()) })
}
updatedCategories = getCategories.await(true, onError = { toast(it.message.orEmpty()) })
}
if (manualUpdate) {
getCategories()
}
}
fun renameCategory(
category: MenuCategory,
newName: String,
) {
_categories.value =
(_categories.value.toPersistentList() - category + category.copy(name = newName)).sortedBy { it.order }
.toImmutableList()
}
fun deleteCategory(category: MenuCategory) {
_categories.value = _categories.value.toPersistentList() - category
}
fun createCategory(name: String) {
_categories.value =
_categories.value.toPersistentList() + MenuCategory(
order = categories.value.size + 1,
name = name,
default = false,
)
}
fun moveUp(category: MenuCategory) {
val categories = _categories.value.toMutableList()
val index = categories.indexOf(category)
if (index == -1) throw Exception("Invalid index")
categories.add(index - 1, categories.removeAt(index))
_categories.value = categories
.mapIndexed { i, menuCategory ->
menuCategory.copy(order = i + 1)
}
.sortedBy { it.order }
.toImmutableList()
}
fun moveDown(category: MenuCategory) {
val categories = _categories.value.toMutableList()
val index = categories.indexOf(category)
if (index == -1) throw Exception("Invalid index")
categories.add(index + 1, categories.removeAt(index))
_categories.value = categories
.mapIndexed { i, menuCategory ->
menuCategory.copy(order = i + 1)
}
.sortedBy { it.order }
.toImmutableList()
}
private fun Category.toMenuCategory() = MenuCategory(id, order, name, default)
@Stable
data class MenuCategory(
val id: Long? = null,
val order: Int,
val name: String,
val default: Boolean = false,
)
private companion object {
private val log = logging()
}
}

View File

@@ -31,85 +31,90 @@ import me.tatarka.inject.annotations.Assisted
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class DownloadsScreenViewModel
@Inject
constructor(
private val downloadService: DownloadService,
private val startDownloading: StartDownloading,
private val stopDownloading: StopDownloading,
private val clearDownloadQueue: ClearDownloadQueue,
private val queueChapterDownload: QueueChapterDownload,
private val stopChapterDownload: StopChapterDownload,
private val reorderChapterDownload: ReorderChapterDownload,
private val contextWrapper: ContextWrapper,
@Assisted standalone: Boolean,
) : ViewModel(contextWrapper) {
private val uiScope = if (standalone) {
MainScope()
} else {
null
}
@Inject
class DownloadsScreenViewModel(
private val downloadService: DownloadService,
private val startDownloading: StartDownloading,
private val stopDownloading: StopDownloading,
private val clearDownloadQueue: ClearDownloadQueue,
private val queueChapterDownload: QueueChapterDownload,
private val stopChapterDownload: StopChapterDownload,
private val reorderChapterDownload: ReorderChapterDownload,
private val contextWrapper: ContextWrapper,
@Assisted standalone: Boolean,
) : ViewModel(contextWrapper) {
private val uiScope = if (standalone) {
MainScope()
} else {
null
}
override val scope: CoroutineScope
get() = uiScope ?: super.scope
override val scope: CoroutineScope
get() = uiScope ?: super.scope
val serviceStatus = DownloadService.status.asStateFlow()
val downloaderStatus = DownloadService.downloaderStatus.asStateFlow()
val downloadQueue = DownloadService.downloadQueue.map { it.toImmutableList() }
.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val serviceStatus = DownloadService.status.asStateFlow()
val downloaderStatus = DownloadService.downloaderStatus.asStateFlow()
val downloadQueue = DownloadService.downloadQueue.map { it.toImmutableList() }
.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
fun start() {
scope.launch { startDownloading.await(onError = { toast(it.message.orEmpty()) }) }
}
fun start() {
scope.launch { startDownloading.await(onError = { toast(it.message.orEmpty()) }) }
}
fun pause() {
scope.launch { stopDownloading.await(onError = { toast(it.message.orEmpty()) }) }
}
fun pause() {
scope.launch { stopDownloading.await(onError = { toast(it.message.orEmpty()) }) }
}
fun clear() {
scope.launch { clearDownloadQueue.await(onError = { toast(it.message.orEmpty()) }) }
}
fun clear() {
scope.launch { clearDownloadQueue.await(onError = { toast(it.message.orEmpty()) }) }
}
fun stopDownload(chapter: Chapter) {
scope.launch { stopChapterDownload.await(chapter.id, onError = { toast(it.message.orEmpty()) }) }
}
fun stopDownload(chapter: Chapter) {
scope.launch { stopChapterDownload.await(chapter.id, onError = { toast(it.message.orEmpty()) }) }
}
fun moveUp(chapter: Chapter) {
scope.launch {
val index = downloadQueue.value.indexOfFirst { it.mangaId == chapter.mangaId && it.chapterIndex == chapter.index }
if (index == -1 || index <= 0) return@launch
reorderChapterDownload.await(chapter.id, index - 1, onError = { toast(it.message.orEmpty()) })
}
}
fun moveDown(chapter: Chapter) {
scope.launch {
val index = downloadQueue.value.indexOfFirst { it.mangaId == chapter.mangaId && it.chapterIndex == chapter.index }
if (index == -1 || index >= downloadQueue.value.lastIndex) return@launch
reorderChapterDownload.await(chapter.id, index + 1, onError = { toast(it.message.orEmpty()) })
}
}
fun moveToTop(chapter: Chapter) {
scope.launch {
reorderChapterDownload.await(chapter.id, 0, onError = { toast(it.message.orEmpty()) })
}
}
fun moveToBottom(chapter: Chapter) {
scope.launch {
reorderChapterDownload.await(chapter.id, downloadQueue.value.lastIndex, onError = { toast(it.message.orEmpty()) })
}
}
fun restartDownloader() = startDownloadService(contextWrapper, downloadService, Actions.RESTART)
override fun onDispose() {
super.onDispose()
uiScope?.cancel()
}
private companion object {
private val log = logging()
fun moveUp(chapter: Chapter) {
scope.launch {
val index =
downloadQueue.value.indexOfFirst { it.mangaId == chapter.mangaId && it.chapterIndex == chapter.index }
if (index == -1 || index <= 0) return@launch
reorderChapterDownload.await(chapter.id, index - 1, onError = { toast(it.message.orEmpty()) })
}
}
fun moveDown(chapter: Chapter) {
scope.launch {
val index =
downloadQueue.value.indexOfFirst { it.mangaId == chapter.mangaId && it.chapterIndex == chapter.index }
if (index == -1 || index >= downloadQueue.value.lastIndex) return@launch
reorderChapterDownload.await(chapter.id, index + 1, onError = { toast(it.message.orEmpty()) })
}
}
fun moveToTop(chapter: Chapter) {
scope.launch {
reorderChapterDownload.await(chapter.id, 0, onError = { toast(it.message.orEmpty()) })
}
}
fun moveToBottom(chapter: Chapter) {
scope.launch {
reorderChapterDownload.await(
chapter.id,
downloadQueue.value.lastIndex,
onError = { toast(it.message.orEmpty()) },
)
}
}
fun restartDownloader() = startDownloadService(contextWrapper, downloadService, Actions.RESTART)
override fun onDispose() {
super.onDispose()
uiScope?.cancel()
}
private companion object {
private val log = logging()
}
}

View File

@@ -41,188 +41,187 @@ import okio.Source
import org.lighthousegames.logging.logging
import kotlin.random.Random
class ExtensionsScreenViewModel
@Inject
constructor(
private val getExtensionList: GetExtensionList,
private val installExtensionFile: InstallExtensionFile,
private val installExtension: InstallExtension,
private val updateExtension: UpdateExtension,
private val uninstallExtension: UninstallExtension,
extensionPreferences: ExtensionPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
private val extensionList = MutableStateFlow<List<Extension>?>(null)
@Inject
class ExtensionsScreenViewModel(
private val getExtensionList: GetExtensionList,
private val installExtensionFile: InstallExtensionFile,
private val installExtension: InstallExtension,
private val updateExtension: UpdateExtension,
private val uninstallExtension: UninstallExtension,
extensionPreferences: ExtensionPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
private val extensionList = MutableStateFlow<List<Extension>?>(null)
private val _enabledLangs = extensionPreferences.languages().asStateFlow()
val enabledLangs = _enabledLangs.map { it.toImmutableSet() }
.stateIn(scope, SharingStarted.Eagerly, persistentSetOf())
private val _enabledLangs = extensionPreferences.languages().asStateFlow()
val enabledLangs = _enabledLangs.map { it.toImmutableSet() }
.stateIn(scope, SharingStarted.Eagerly, persistentSetOf())
private val _searchQuery = MutableStateFlow<String?>(null)
val searchQuery = _searchQuery.asStateFlow()
private val _searchQuery = MutableStateFlow<String?>(null)
val searchQuery = _searchQuery.asStateFlow()
private val workingExtensions = MutableStateFlow<List<String>>(emptyList())
private val workingExtensions = MutableStateFlow<List<String>>(emptyList())
val extensions = combine(
searchQuery,
extensionList,
enabledLangs,
workingExtensions,
) { searchQuery, extensions, enabledLangs, workingExtensions ->
search(searchQuery, extensions, enabledLangs, workingExtensions)
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val extensions = combine(
searchQuery,
extensionList,
enabledLangs,
workingExtensions,
) { searchQuery, extensions, enabledLangs, workingExtensions ->
search(searchQuery, extensions, enabledLangs, workingExtensions)
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val availableLangs = extensionList.filterNotNull().map { langs ->
langs.map { it.lang }.distinct().toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val availableLangs = extensionList.filterNotNull().map { langs ->
langs.map { it.lang }.distinct().toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val isLoading = extensionList.map { it == null }.stateIn(scope, SharingStarted.Eagerly, true)
val isLoading = extensionList.map { it == null }.stateIn(scope, SharingStarted.Eagerly, true)
init {
scope.launch {
getExtensions()
}
}
private suspend fun getExtensions() {
extensionList.value = getExtensionList.await(onError = { toast(it.message.orEmpty()) }).orEmpty()
}
fun install(source: Source) {
log.info { "Install file clicked" }
scope.launch {
try {
val file = FileSystem.SYSTEM_TEMPORARY_DIRECTORY
.resolve("tachidesk.${Random.nextLong()}.apk")
.also { file ->
source.saveTo(file)
}
installExtensionFile.await(file, onError = { toast(it.message.orEmpty()) })
} catch (e: Exception) {
log.warn(e) { "Error creating apk file" }
// todo toast if error
e.throwIfCancellation()
}
getExtensions()
}
}
fun install(extension: Extension) {
log.info { "Install clicked" }
scope.launch {
workingExtensions.update { it + extension.apkName }
installExtension.await(extension, onError = { toast(it.message.orEmpty()) })
getExtensions()
workingExtensions.update { it - extension.apkName }
}
}
fun update(extension: Extension) {
log.info { "Update clicked" }
scope.launch {
workingExtensions.update { it + extension.apkName }
updateExtension.await(extension, onError = { toast(it.message.orEmpty()) })
getExtensions()
workingExtensions.update { it - extension.apkName }
}
}
fun uninstall(extension: Extension) {
log.info { "Uninstall clicked" }
scope.launch {
workingExtensions.update { it + extension.apkName }
uninstallExtension.await(extension, onError = { toast(it.message.orEmpty()) })
getExtensions()
workingExtensions.update { it - extension.apkName }
}
}
fun setEnabledLanguages(langs: Set<String>) {
_enabledLangs.value = langs
}
fun setQuery(query: String) {
_searchQuery.value = query
}
private fun search(
searchQuery: String?,
extensionList: List<Extension>?,
enabledLangs: Set<String>,
workingExtensions: List<String>,
): ImmutableList<ExtensionUI> {
val extensions = extensionList?.filter { it.lang in enabledLangs }
.orEmpty()
return if (searchQuery.isNullOrBlank()) {
extensions.splitSort(workingExtensions)
} else {
val queries = searchQuery.split(" ")
val filteredExtensions = extensions.toMutableList()
queries.forEach { query ->
filteredExtensions.removeAll { !it.name.contains(query, true) }
}
filteredExtensions.toList().splitSort(workingExtensions)
}
}
private fun List<Extension>.splitSort(workingExtensions: List<String>): ImmutableList<ExtensionUI> {
val all = MR.strings.all.toPlatformString()
return this
.filter(Extension::installed)
.sortedWith(
compareBy<Extension> {
when {
it.obsolete -> 1
it.hasUpdate -> 2
else -> 3
}
}
.thenBy(Extension::lang)
.thenBy(String.CASE_INSENSITIVE_ORDER, Extension::name),
)
.map { ExtensionUI.ExtensionItem(it, it.apkName in workingExtensions) }
.let {
if (it.isNotEmpty()) {
listOf(ExtensionUI.Header(MR.strings.installed.toPlatformString())) + it
} else {
it
}
}.plus(
filterNot(Extension::installed)
.groupBy(Extension::lang)
.mapKeys { (key) ->
when (key) {
"all" -> all
else -> Locale(key).displayName
}
}
.mapValues {
it.value
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, Extension::name))
.map { ExtensionUI.ExtensionItem(it, it.apkName in workingExtensions) }
}
.toList()
.sortedWith(
compareBy<Pair<String, *>> { (key) ->
when (key) {
all -> 1
else -> 2
}
}.thenBy(String.CASE_INSENSITIVE_ORDER, Pair<String, *>::first),
)
.flatMap { (key, value) ->
listOf(ExtensionUI.Header(key)) + value
},
)
.toImmutableList()
}
private companion object {
private val log = logging()
init {
scope.launch {
getExtensions()
}
}
private suspend fun getExtensions() {
extensionList.value = getExtensionList.await(onError = { toast(it.message.orEmpty()) }).orEmpty()
}
fun install(source: Source) {
log.info { "Install file clicked" }
scope.launch {
try {
val file = FileSystem.SYSTEM_TEMPORARY_DIRECTORY
.resolve("tachidesk.${Random.nextLong()}.apk")
.also { file ->
source.saveTo(file)
}
installExtensionFile.await(file, onError = { toast(it.message.orEmpty()) })
} catch (e: Exception) {
log.warn(e) { "Error creating apk file" }
// todo toast if error
e.throwIfCancellation()
}
getExtensions()
}
}
fun install(extension: Extension) {
log.info { "Install clicked" }
scope.launch {
workingExtensions.update { it + extension.apkName }
installExtension.await(extension, onError = { toast(it.message.orEmpty()) })
getExtensions()
workingExtensions.update { it - extension.apkName }
}
}
fun update(extension: Extension) {
log.info { "Update clicked" }
scope.launch {
workingExtensions.update { it + extension.apkName }
updateExtension.await(extension, onError = { toast(it.message.orEmpty()) })
getExtensions()
workingExtensions.update { it - extension.apkName }
}
}
fun uninstall(extension: Extension) {
log.info { "Uninstall clicked" }
scope.launch {
workingExtensions.update { it + extension.apkName }
uninstallExtension.await(extension, onError = { toast(it.message.orEmpty()) })
getExtensions()
workingExtensions.update { it - extension.apkName }
}
}
fun setEnabledLanguages(langs: Set<String>) {
_enabledLangs.value = langs
}
fun setQuery(query: String) {
_searchQuery.value = query
}
private fun search(
searchQuery: String?,
extensionList: List<Extension>?,
enabledLangs: Set<String>,
workingExtensions: List<String>,
): ImmutableList<ExtensionUI> {
val extensions = extensionList?.filter { it.lang in enabledLangs }
.orEmpty()
return if (searchQuery.isNullOrBlank()) {
extensions.splitSort(workingExtensions)
} else {
val queries = searchQuery.split(" ")
val filteredExtensions = extensions.toMutableList()
queries.forEach { query ->
filteredExtensions.removeAll { !it.name.contains(query, true) }
}
filteredExtensions.toList().splitSort(workingExtensions)
}
}
private fun List<Extension>.splitSort(workingExtensions: List<String>): ImmutableList<ExtensionUI> {
val all = MR.strings.all.toPlatformString()
return this
.filter(Extension::installed)
.sortedWith(
compareBy<Extension> {
when {
it.obsolete -> 1
it.hasUpdate -> 2
else -> 3
}
}
.thenBy(Extension::lang)
.thenBy(String.CASE_INSENSITIVE_ORDER, Extension::name),
)
.map { ExtensionUI.ExtensionItem(it, it.apkName in workingExtensions) }
.let {
if (it.isNotEmpty()) {
listOf(ExtensionUI.Header(MR.strings.installed.toPlatformString())) + it
} else {
it
}
}.plus(
filterNot(Extension::installed)
.groupBy(Extension::lang)
.mapKeys { (key) ->
when (key) {
"all" -> all
else -> Locale(key).displayName
}
}
.mapValues {
it.value
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, Extension::name))
.map { ExtensionUI.ExtensionItem(it, it.apkName in workingExtensions) }
}
.toList()
.sortedWith(
compareBy<Pair<String, *>> { (key) ->
when (key) {
all -> 1
else -> 2
}
}.thenBy(String.CASE_INSENSITIVE_ORDER, Pair<String, *>::first),
)
.flatMap { (key, value) ->
listOf(ExtensionUI.Header(key)) + value
},
)
.toImmutableList()
}
private companion object {
private val log = logging()
}
}
@Immutable
sealed class ExtensionUI {
data class Header(

View File

@@ -119,224 +119,223 @@ private fun LibraryMap.setManga(
}
}
class LibraryScreenViewModel
@Inject
constructor(
private val getCategories: GetCategories,
private val getMangaListFromCategory: GetMangaListFromCategory,
private val removeMangaFromLibrary: RemoveMangaFromLibrary,
private val updateLibrary: UpdateLibrary,
private val updateCategory: UpdateCategory,
libraryPreferences: LibraryPreferences,
contextWrapper: ContextWrapper,
@Assisted private val savedStateHandle: SavedStateHandle,
) : ViewModel(contextWrapper) {
private val library = Library(MutableStateFlow(LibraryState.Loading), mutableMapOf())
val categories = library.categories.asStateFlow()
@Inject
class LibraryScreenViewModel(
private val getCategories: GetCategories,
private val getMangaListFromCategory: GetMangaListFromCategory,
private val removeMangaFromLibrary: RemoveMangaFromLibrary,
private val updateLibrary: UpdateLibrary,
private val updateCategory: UpdateCategory,
libraryPreferences: LibraryPreferences,
contextWrapper: ContextWrapper,
@Assisted private val savedStateHandle: SavedStateHandle,
) : ViewModel(contextWrapper) {
private val library = Library(MutableStateFlow(LibraryState.Loading), mutableMapOf())
val categories = library.categories.asStateFlow()
private val _selectedCategoryIndex by savedStateHandle.getStateFlow { 0 }
val selectedCategoryIndex = _selectedCategoryIndex.asStateFlow()
private val _selectedCategoryIndex by savedStateHandle.getStateFlow { 0 }
val selectedCategoryIndex = _selectedCategoryIndex.asStateFlow()
private val _showingMenu by savedStateHandle.getStateFlow { false }
val showingMenu = _showingMenu.asStateFlow()
private val _showingMenu by savedStateHandle.getStateFlow { false }
val showingMenu = _showingMenu.asStateFlow()
val displayMode = libraryPreferences.displayMode().stateIn(scope)
val gridColumns = libraryPreferences.gridColumns().stateIn(scope)
val gridSize = libraryPreferences.gridSize().stateIn(scope)
val unreadBadges = libraryPreferences.unreadBadge().stateIn(scope)
val downloadBadges = libraryPreferences.downloadBadge().stateIn(scope)
val languageBadges = libraryPreferences.languageBadge().stateIn(scope)
val localBadges = libraryPreferences.localBadge().stateIn(scope)
val displayMode = libraryPreferences.displayMode().stateIn(scope)
val gridColumns = libraryPreferences.gridColumns().stateIn(scope)
val gridSize = libraryPreferences.gridSize().stateIn(scope)
val unreadBadges = libraryPreferences.unreadBadge().stateIn(scope)
val downloadBadges = libraryPreferences.downloadBadge().stateIn(scope)
val languageBadges = libraryPreferences.languageBadge().stateIn(scope)
val localBadges = libraryPreferences.localBadge().stateIn(scope)
private val sortMode = libraryPreferences.sortMode().stateIn(scope)
private val sortAscending = libraryPreferences.sortAscending().stateIn(scope)
private val sortMode = libraryPreferences.sortMode().stateIn(scope)
private val sortAscending = libraryPreferences.sortAscending().stateIn(scope)
private val filter: Flow<(Manga) -> Boolean> = combine(
libraryPreferences.filterDownloaded().getAsFlow(),
libraryPreferences.filterUnread().getAsFlow(),
libraryPreferences.filterCompleted().getAsFlow(),
) { downloaded, unread, completed ->
{ manga ->
when (downloaded) {
FilterState.EXCLUDED -> manga.downloadCount == null || manga.downloadCount == 0
FilterState.INCLUDED -> manga.downloadCount != null && (manga.downloadCount ?: 0) > 0
private val filter: Flow<(Manga) -> Boolean> = combine(
libraryPreferences.filterDownloaded().getAsFlow(),
libraryPreferences.filterUnread().getAsFlow(),
libraryPreferences.filterCompleted().getAsFlow(),
) { downloaded, unread, completed ->
{ manga ->
when (downloaded) {
FilterState.EXCLUDED -> manga.downloadCount == null || manga.downloadCount == 0
FilterState.INCLUDED -> manga.downloadCount != null && (manga.downloadCount ?: 0) > 0
FilterState.IGNORED -> true
} &&
when (unread) {
FilterState.EXCLUDED -> manga.unreadCount == null || manga.unreadCount == 0
FilterState.INCLUDED -> manga.unreadCount != null && (manga.unreadCount ?: 0) > 0
FilterState.IGNORED -> true
} &&
when (unread) {
FilterState.EXCLUDED -> manga.unreadCount == null || manga.unreadCount == 0
FilterState.INCLUDED -> manga.unreadCount != null && (manga.unreadCount ?: 0) > 0
FilterState.IGNORED -> true
} &&
when (completed) {
FilterState.EXCLUDED -> manga.status != MangaStatus.COMPLETED
FilterState.INCLUDED -> manga.status == MangaStatus.COMPLETED
FilterState.IGNORED -> true
}
}
}
private val _query by savedStateHandle.getStateFlow { "" }
val query = _query.asStateFlow()
private val comparator = combine(sortMode, sortAscending) { sortMode, sortAscending ->
getComparator(sortMode, sortAscending)
}.stateIn(scope, SharingStarted.Eagerly, compareBy { it.title })
init {
getLibrary()
}
private fun getLibrary() {
library.categories.value = LibraryState.Loading
getCategories.asFlow()
.onEach { categories ->
if (categories.isEmpty()) {
throw Exception(MR.strings.library_empty.toPlatformString())
}
library.categories.value = LibraryState.Loaded(
categories.sortedBy { it.order }
.toImmutableList(),
)
categories.forEach { category ->
getMangaListFromCategory.asFlow(category)
.onEach {
library.mangaMap.setManga(
id = category.id,
manga = it.toImmutableList(),
getItemsFlow = ::getMangaItemsFlow,
)
}
.catch {
log.warn(it) { "Failed to get manga list from category ${category.name}" }
library.mangaMap.setError(category.id, it)
}
.launchIn(scope)
}
when (completed) {
FilterState.EXCLUDED -> manga.status != MangaStatus.COMPLETED
FilterState.INCLUDED -> manga.status == MangaStatus.COMPLETED
FilterState.IGNORED -> true
}
.catch {
library.categories.value = LibraryState.Failed(it)
log.warn(it) { "Failed to get categories" }
}
.launchIn(scope)
}
fun setSelectedPage(page: Int) {
_selectedCategoryIndex.value = page
}
fun setShowingMenu(showingMenu: Boolean) {
_showingMenu.value = showingMenu
}
private fun getComparator(
sortMode: Sort,
ascending: Boolean,
): Comparator<Manga> {
val sortFn: (Manga, Manga) -> Int = when (sortMode) {
Sort.ALPHABETICAL -> {
val locale = Locale.current
val collator = CollatorComparator(locale);
{ a, b ->
collator.compare(a.title.toLowerCase(locale), b.title.toLowerCase(locale))
}
}
Sort.UNREAD -> {
{ a, b ->
when {
// Ensure unread content comes first
(a.unreadCount ?: 0) == (b.unreadCount ?: 0) -> 0
a.unreadCount == null || a.unreadCount == 0 -> if (ascending) 1 else -1
b.unreadCount == null || b.unreadCount == 0 -> if (ascending) -1 else 1
else -> (a.unreadCount ?: 0).compareTo(b.unreadCount ?: 0)
}
}
}
Sort.DATE_ADDED -> {
{ a, b ->
a.inLibraryAt.compareTo(b.inLibraryAt)
}
}
}
return if (ascending) {
Comparator(sortFn)
} else {
Comparator(sortFn).reversed()
}
}
private suspend fun filterManga(
query: String,
mangaList: List<Manga>,
): List<Manga> {
if (query.isBlank()) return mangaList
val queries = query.split(" ")
return mangaList.asFlow()
.filter { manga ->
queries.all { query ->
manga.title.contains(query, true) ||
manga.author.orEmpty().contains(query, true) ||
manga.artist.orEmpty().contains(query, true) ||
manga.genre.any { it.contains(query, true) } ||
manga.description.orEmpty().contains(query, true) ||
manga.status.name.contains(query, true)
}
}
.cancellable()
.buffer()
.toList()
}
private fun getMangaItemsFlow(unfilteredItemsFlow: StateFlow<List<Manga>>): StateFlow<ImmutableList<Manga>> =
combine(
unfilteredItemsFlow,
query,
) {
unfilteredItems,
query,
->
filterManga(query, unfilteredItems)
}.combine(filter) { filteredManga, filterer ->
filteredManga.filter(filterer)
}.combine(comparator) { filteredManga, comparator ->
filteredManga.sortedWith(comparator)
}.map {
it.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
fun getLibraryForCategoryId(id: Long): StateFlow<CategoryState> = library.mangaMap.getManga(id)
private fun getCategoriesToUpdate(mangaId: Long): List<Category> =
library.mangaMap
.filter { mangaMapEntry ->
(mangaMapEntry.value.value as? CategoryState.Loaded)?.items?.value?.firstOrNull { it.id == mangaId } != null
}
.mapNotNull { (id) -> (library.categories.value as? LibraryState.Loaded)?.categories?.first { it.id == id } }
fun removeManga(mangaId: Long) {
scope.launch {
removeMangaFromLibrary.await(mangaId, onError = { toast(it.message.orEmpty()) })
}
}
fun updateQuery(query: String) {
_query.value = query
}
fun updateLibrary() {
scope.launch { updateLibrary.await(onError = { toast(it.message.orEmpty()) }) }
}
fun updateCategory(category: Category) {
scope.launch { updateCategory.await(category, onError = { toast(it.message.orEmpty()) }) }
}
private companion object {
private val log = logging()
}
}
private val _query by savedStateHandle.getStateFlow { "" }
val query = _query.asStateFlow()
private val comparator = combine(sortMode, sortAscending) { sortMode, sortAscending ->
getComparator(sortMode, sortAscending)
}.stateIn(scope, SharingStarted.Eagerly, compareBy { it.title })
init {
getLibrary()
}
private fun getLibrary() {
library.categories.value = LibraryState.Loading
getCategories.asFlow()
.onEach { categories ->
if (categories.isEmpty()) {
throw Exception(MR.strings.library_empty.toPlatformString())
}
library.categories.value = LibraryState.Loaded(
categories.sortedBy { it.order }
.toImmutableList(),
)
categories.forEach { category ->
getMangaListFromCategory.asFlow(category)
.onEach {
library.mangaMap.setManga(
id = category.id,
manga = it.toImmutableList(),
getItemsFlow = ::getMangaItemsFlow,
)
}
.catch {
log.warn(it) { "Failed to get manga list from category ${category.name}" }
library.mangaMap.setError(category.id, it)
}
.launchIn(scope)
}
}
.catch {
library.categories.value = LibraryState.Failed(it)
log.warn(it) { "Failed to get categories" }
}
.launchIn(scope)
}
fun setSelectedPage(page: Int) {
_selectedCategoryIndex.value = page
}
fun setShowingMenu(showingMenu: Boolean) {
_showingMenu.value = showingMenu
}
private fun getComparator(
sortMode: Sort,
ascending: Boolean,
): Comparator<Manga> {
val sortFn: (Manga, Manga) -> Int = when (sortMode) {
Sort.ALPHABETICAL -> {
val locale = Locale.current
val collator = CollatorComparator(locale);
{ a, b ->
collator.compare(a.title.toLowerCase(locale), b.title.toLowerCase(locale))
}
}
Sort.UNREAD -> {
{ a, b ->
when {
// Ensure unread content comes first
(a.unreadCount ?: 0) == (b.unreadCount ?: 0) -> 0
a.unreadCount == null || a.unreadCount == 0 -> if (ascending) 1 else -1
b.unreadCount == null || b.unreadCount == 0 -> if (ascending) -1 else 1
else -> (a.unreadCount ?: 0).compareTo(b.unreadCount ?: 0)
}
}
}
Sort.DATE_ADDED -> {
{ a, b ->
a.inLibraryAt.compareTo(b.inLibraryAt)
}
}
}
return if (ascending) {
Comparator(sortFn)
} else {
Comparator(sortFn).reversed()
}
}
private suspend fun filterManga(
query: String,
mangaList: List<Manga>,
): List<Manga> {
if (query.isBlank()) return mangaList
val queries = query.split(" ")
return mangaList.asFlow()
.filter { manga ->
queries.all { query ->
manga.title.contains(query, true) ||
manga.author.orEmpty().contains(query, true) ||
manga.artist.orEmpty().contains(query, true) ||
manga.genre.any { it.contains(query, true) } ||
manga.description.orEmpty().contains(query, true) ||
manga.status.name.contains(query, true)
}
}
.cancellable()
.buffer()
.toList()
}
private fun getMangaItemsFlow(unfilteredItemsFlow: StateFlow<List<Manga>>): StateFlow<ImmutableList<Manga>> =
combine(
unfilteredItemsFlow,
query,
) {
unfilteredItems,
query,
->
filterManga(query, unfilteredItems)
}.combine(filter) { filteredManga, filterer ->
filteredManga.filter(filterer)
}.combine(comparator) { filteredManga, comparator ->
filteredManga.sortedWith(comparator)
}.map {
it.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
fun getLibraryForCategoryId(id: Long): StateFlow<CategoryState> = library.mangaMap.getManga(id)
private fun getCategoriesToUpdate(mangaId: Long): List<Category> =
library.mangaMap
.filter { mangaMapEntry ->
(mangaMapEntry.value.value as? CategoryState.Loaded)?.items?.value?.firstOrNull { it.id == mangaId } != null
}
.mapNotNull { (id) -> (library.categories.value as? LibraryState.Loaded)?.categories?.first { it.id == id } }
fun removeManga(mangaId: Long) {
scope.launch {
removeMangaFromLibrary.await(mangaId, onError = { toast(it.message.orEmpty()) })
}
}
fun updateQuery(query: String) {
_query.value = query
}
fun updateLibrary() {
scope.launch { updateLibrary.await(onError = { toast(it.message.orEmpty()) }) }
}
fun updateCategory(category: Category) {
scope.launch { updateCategory.await(category, onError = { toast(it.message.orEmpty()) }) }
}
private companion object {
private val log = logging()
}
}

View File

@@ -11,22 +11,21 @@ import ca.gosyer.jui.uicore.vm.ContextWrapper
import ca.gosyer.jui.uicore.vm.ViewModel
import me.tatarka.inject.annotations.Inject
class LibrarySettingsViewModel
@Inject
constructor(
libraryPreferences: LibraryPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val filterDownloaded = libraryPreferences.filterDownloaded().asStateFlow()
val filterUnread = libraryPreferences.filterUnread().asStateFlow()
val filterCompleted = libraryPreferences.filterCompleted().asStateFlow()
@Inject
class LibrarySettingsViewModel(
libraryPreferences: LibraryPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val filterDownloaded = libraryPreferences.filterDownloaded().asStateFlow()
val filterUnread = libraryPreferences.filterUnread().asStateFlow()
val filterCompleted = libraryPreferences.filterCompleted().asStateFlow()
val sortMode = libraryPreferences.sortMode().asStateFlow()
val sortAscending = libraryPreferences.sortAscending().asStateFlow()
val sortMode = libraryPreferences.sortMode().asStateFlow()
val sortAscending = libraryPreferences.sortAscending().asStateFlow()
val displayMode = libraryPreferences.displayMode().asStateFlow()
val unreadBadges = libraryPreferences.unreadBadge().asStateFlow()
val downloadBadges = libraryPreferences.downloadBadge().asStateFlow()
val languageBadges = libraryPreferences.languageBadge().asStateFlow()
val localBadges = libraryPreferences.localBadge().asStateFlow()
}
val displayMode = libraryPreferences.displayMode().asStateFlow()
val unreadBadges = libraryPreferences.unreadBadge().asStateFlow()
val downloadBadges = libraryPreferences.downloadBadge().asStateFlow()
val languageBadges = libraryPreferences.languageBadge().asStateFlow()
val localBadges = libraryPreferences.localBadge().asStateFlow()
}

View File

@@ -14,23 +14,22 @@ import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import me.tatarka.inject.annotations.Inject
class MainViewModel
@Inject
constructor(
uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
override val scope = MainScope()
@Inject
class MainViewModel(
uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
override val scope = MainScope()
val startScreen = uiPreferences.startScreen().get()
val confirmExit = uiPreferences.confirmExit().stateIn(scope)
val startScreen = uiPreferences.startScreen().get()
val confirmExit = uiPreferences.confirmExit().stateIn(scope)
override fun onDispose() {
super.onDispose()
scope.cancel()
}
fun confirmExitToast() {
toast(MR.strings.confirm_exit_toast.toPlatformString())
}
override fun onDispose() {
super.onDispose()
scope.cancel()
}
fun confirmExitToast() {
toast(MR.strings.confirm_exit_toast.toPlatformString())
}
}

View File

@@ -26,49 +26,48 @@ import kotlinx.datetime.Instant
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class AboutViewModel
@Inject
constructor(
private val dateHandler: DateHandler,
private val aboutServer: AboutServer,
private val updateChecker: UpdateChecker,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
private val _aboutHolder = MutableStateFlow<About?>(null)
val aboutHolder = _aboutHolder.asStateFlow()
@Inject
class AboutViewModel(
private val dateHandler: DateHandler,
private val aboutServer: AboutServer,
private val updateChecker: UpdateChecker,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
private val _aboutHolder = MutableStateFlow<About?>(null)
val aboutHolder = _aboutHolder.asStateFlow()
val formattedBuildTime = aboutHolder.map { about ->
about ?: return@map ""
getFormattedDate(Instant.fromEpochSeconds(about.buildTime))
}.stateIn(scope, SharingStarted.Eagerly, "")
val formattedBuildTime = aboutHolder.map { about ->
about ?: return@map ""
getFormattedDate(Instant.fromEpochSeconds(about.buildTime))
}.stateIn(scope, SharingStarted.Eagerly, "")
private val _updates = MutableSharedFlow<Update.UpdateFound>()
val updates = _updates.asSharedFlow()
private val _updates = MutableSharedFlow<Update.UpdateFound>()
val updates = _updates.asSharedFlow()
init {
getAbout()
}
init {
getAbout()
}
private fun getAbout() {
scope.launch {
_aboutHolder.value = aboutServer.await(onError = { toast(it.message.orEmpty()) })
}
}
fun checkForUpdates() {
scope.launch {
toast(MR.strings.update_check_look_for_updates.toPlatformString())
when (val update = updateChecker.await(true, onError = { toast(it.message.orEmpty()) })) {
is Update.UpdateFound -> _updates.emit(update)
is Update.NoUpdatesFound -> toast(MR.strings.update_check_no_new_updates.toPlatformString())
null -> Unit
}
}
}
private fun getFormattedDate(time: Instant): String = dateHandler.dateTimeFormat(time)
companion object {
private val log = logging()
private fun getAbout() {
scope.launch {
_aboutHolder.value = aboutServer.await(onError = { toast(it.message.orEmpty()) })
}
}
fun checkForUpdates() {
scope.launch {
toast(MR.strings.update_check_look_for_updates.toPlatformString())
when (val update = updateChecker.await(true, onError = { toast(it.message.orEmpty()) })) {
is Update.UpdateFound -> _updates.emit(update)
is Update.NoUpdatesFound -> toast(MR.strings.update_check_no_new_updates.toPlatformString())
null -> Unit
}
}
}
private fun getFormattedDate(time: Instant): String = dateHandler.dateTimeFormat(time)
companion object {
private val log = logging()
}
}

View File

@@ -18,33 +18,32 @@ import me.tatarka.inject.annotations.Assisted
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class LibraryUpdatesViewModel
@Inject
constructor(
private val libraryUpdateService: LibraryUpdateService,
private val contextWrapper: ContextWrapper,
@Assisted standalone: Boolean,
) : ViewModel(contextWrapper) {
private val uiScope = if (standalone) {
MainScope()
} else {
null
}
override val scope: CoroutineScope
get() = uiScope ?: super.scope
val serviceStatus = LibraryUpdateService.status.asStateFlow()
val updateStatus = LibraryUpdateService.updateStatus.asStateFlow()
fun restartLibraryUpdates() = startLibraryUpdatesService(contextWrapper, libraryUpdateService, Actions.RESTART)
override fun onDispose() {
super.onDispose()
uiScope?.cancel()
}
private companion object {
private val log = logging()
}
@Inject
class LibraryUpdatesViewModel(
private val libraryUpdateService: LibraryUpdateService,
private val contextWrapper: ContextWrapper,
@Assisted standalone: Boolean,
) : ViewModel(contextWrapper) {
private val uiScope = if (standalone) {
MainScope()
} else {
null
}
override val scope: CoroutineScope
get() = uiScope ?: super.scope
val serviceStatus = LibraryUpdateService.status.asStateFlow()
val updateStatus = LibraryUpdateService.updateStatus.asStateFlow()
fun restartLibraryUpdates() = startLibraryUpdatesService(contextWrapper, libraryUpdateService, Actions.RESTART)
override fun onDispose() {
super.onDispose()
uiScope?.cancel()
}
private companion object {
private val log = logging()
}
}

View File

@@ -55,386 +55,386 @@ import me.tatarka.inject.annotations.Assisted
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class MangaScreenViewModel
@Inject
constructor(
private val dateHandler: DateHandler,
private val getManga: GetManga,
private val refreshManga: RefreshManga,
private val getChapters: GetChapters,
private val refreshChapters: RefreshChapters,
private val updateChapter: UpdateChapter,
private val queueChapterDownload: QueueChapterDownload,
private val stopChapterDownload: StopChapterDownload,
private val deleteChapterDownload: DeleteChapterDownload,
private val getCategories: GetCategories,
private val getMangaCategories: GetMangaCategories,
private val addMangaToCategory: AddMangaToCategory,
private val removeMangaFromCategory: RemoveMangaFromCategory,
private val addMangaToLibrary: AddMangaToLibrary,
private val removeMangaFromLibrary: RemoveMangaFromLibrary,
private val batchChapterDownload: BatchChapterDownload,
uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
@Assisted private val params: Params,
) : ViewModel(contextWrapper) {
private val _manga = MutableStateFlow<Manga?>(null)
val manga = _manga.asStateFlow()
@Inject
class MangaScreenViewModel(
private val dateHandler: DateHandler,
private val getManga: GetManga,
private val refreshManga: RefreshManga,
private val getChapters: GetChapters,
private val refreshChapters: RefreshChapters,
private val updateChapter: UpdateChapter,
private val queueChapterDownload: QueueChapterDownload,
private val stopChapterDownload: StopChapterDownload,
private val deleteChapterDownload: DeleteChapterDownload,
private val getCategories: GetCategories,
private val getMangaCategories: GetMangaCategories,
private val addMangaToCategory: AddMangaToCategory,
private val removeMangaFromCategory: RemoveMangaFromCategory,
private val addMangaToLibrary: AddMangaToLibrary,
private val removeMangaFromLibrary: RemoveMangaFromLibrary,
private val batchChapterDownload: BatchChapterDownload,
uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
@Assisted private val params: Params,
) : ViewModel(contextWrapper) {
private val _manga = MutableStateFlow<Manga?>(null)
val manga = _manga.asStateFlow()
private val _chapters = MutableStateFlow<ImmutableList<ChapterDownloadItem>>(persistentListOf())
val chapters = _chapters.asStateFlow()
private val _chapters = MutableStateFlow<ImmutableList<ChapterDownloadItem>>(persistentListOf())
val chapters = _chapters.asStateFlow()
private val _selectedIds = MutableStateFlow<ImmutableList<Long>>(persistentListOf())
val selectedItems = combine(chapters, _selectedIds) { chapters, selecteditems ->
chapters.filter { it.isSelected(selecteditems) }.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val _selectedIds = MutableStateFlow<ImmutableList<Long>>(persistentListOf())
val selectedItems = combine(chapters, _selectedIds) { chapters, selecteditems ->
chapters.filter { it.isSelected(selecteditems) }.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val loadingManga = MutableStateFlow(true)
private val loadingChapters = MutableStateFlow(true)
private val refreshingChapters = MutableStateFlow(false)
private val refreshingManga = MutableStateFlow(false)
val isLoading = combine(loadingManga, loadingChapters, refreshingManga, refreshingChapters) { a, b, c, d -> a || b || c || d }
private val loadingManga = MutableStateFlow(true)
private val loadingChapters = MutableStateFlow(true)
private val refreshingChapters = MutableStateFlow(false)
private val refreshingManga = MutableStateFlow(false)
val isLoading =
combine(loadingManga, loadingChapters, refreshingManga, refreshingChapters) { a, b, c, d -> a || b || c || d }
.stateIn(scope, SharingStarted.Eagerly, true)
val categories = getCategories.asFlow(true)
.map { it.toImmutableList() }
val categories = getCategories.asFlow(true)
.map { it.toImmutableList() }
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Failed to get categories" }
}
.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val _mangaCategories = MutableStateFlow<ImmutableList<Category>>(persistentListOf())
val mangaCategories = _mangaCategories.asStateFlow()
val categoriesExist = categories.map { it.isNotEmpty() }
.stateIn(scope, SharingStarted.Eagerly, true)
val inActionMode = _selectedIds.map { it.isNotEmpty() }
.stateIn(scope, SharingStarted.Eagerly, false)
private val chooseCategoriesFlow = MutableSharedFlow<Unit>()
val chooseCategoriesFlowHolder = StableHolder(chooseCategoriesFlow.asSharedFlow())
private val reloadManga = MutableSharedFlow<Unit>()
private val reloadChapters = MutableSharedFlow<Unit>()
val dateTimeFormatter = uiPreferences.dateFormat().changes()
.map {
dateHandler.getDateFormat(it)
}
.asStateFlow(dateHandler.getDateFormat(uiPreferences.dateFormat().get()))
init {
DownloadService.registerWatch(params.mangaId)
.mapLatest { downloadingChapters ->
chapters.value.forEach { chapter ->
chapter.updateFrom(downloadingChapters)
}
}
.launchIn(scope)
reloadManga.onStart { emit(Unit) }.flatMapLatest {
loadingManga.value = true
getManga.asFlow(params.mangaId)
}
.onEach {
_manga.value = it
loadingManga.value = false
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Failed to get categories" }
log.warn(it) { "Error when loading manga" }
loadingManga.value = false
}
.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
.launchIn(scope)
private val _mangaCategories = MutableStateFlow<ImmutableList<Category>>(persistentListOf())
val mangaCategories = _mangaCategories.asStateFlow()
val categoriesExist = categories.map { it.isNotEmpty() }
.stateIn(scope, SharingStarted.Eagerly, true)
val inActionMode = _selectedIds.map { it.isNotEmpty() }
.stateIn(scope, SharingStarted.Eagerly, false)
private val chooseCategoriesFlow = MutableSharedFlow<Unit>()
val chooseCategoriesFlowHolder = StableHolder(chooseCategoriesFlow.asSharedFlow())
private val reloadManga = MutableSharedFlow<Unit>()
private val reloadChapters = MutableSharedFlow<Unit>()
val dateTimeFormatter = uiPreferences.dateFormat().changes()
.map {
dateHandler.getDateFormat(it)
reloadChapters.onStart { emit(Unit) }.flatMapLatest {
loadingChapters.value = true
getChapters.asFlow(params.mangaId)
}
.onEach {
updateChapters(it)
loadingChapters.value = false
}
.asStateFlow(dateHandler.getDateFormat(uiPreferences.dateFormat().get()))
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error when getting chapters" }
loadingChapters.value = false
}
.launchIn(scope)
init {
DownloadService.registerWatch(params.mangaId)
.mapLatest { downloadingChapters ->
chapters.value.forEach { chapter ->
chapter.updateFrom(downloadingChapters)
scope.launch {
val mangaCategories = getMangaCategories.await(params.mangaId, onError = { toast(it.message.orEmpty()) })
if (mangaCategories != null) {
_mangaCategories.value = mangaCategories.toImmutableList()
}
}
scope.launch {
val manga = manga.first { it != null }!!
if (!manga.initialized) {
refreshManga()
}
}
}
fun loadManga() {
scope.launch {
reloadManga.emit(Unit)
}
}
fun loadChapters() {
scope.launch {
reloadChapters.emit(Unit)
}
}
fun updateChapters(chapters: List<Chapter>) {
_chapters.value = chapters.sortedByDescending { it.index }.toDownloadChapters()
}
fun refreshManga() {
scope.launch {
refreshingManga.value = true
val manga = refreshManga.await(
params.mangaId,
onError = {
log.warn(it) { "Error when refreshing manga" }
toast(it.message.orEmpty())
},
)
if (manga != null) {
_manga.value = manga
}
refreshingManga.value = false
}
scope.launch {
refreshingChapters.value = true
val chapters = refreshChapters.await(
params.mangaId,
onError = {
log.warn(it) { "Error when refreshing chapters" }
toast(it.message.orEmpty())
},
)
if (!chapters.isNullOrEmpty()) {
updateChapters(chapters)
}
refreshingChapters.value = false
}
}
fun setCategories() {
scope.launch {
manga.value ?: return@launch
chooseCategoriesFlow.emit(Unit)
}
}
fun toggleFavorite() {
scope.launch {
manga.value?.let { manga ->
if (manga.inLibrary) {
removeMangaFromLibrary.await(manga, onError = { toast(it.message.orEmpty()) })
} else {
if (categories.value.isEmpty()) {
addFavorite(emptyList(), emptyList())
} else {
chooseCategoriesFlow.emit(Unit)
}
}
.launchIn(scope)
reloadManga.onStart { emit(Unit) }.flatMapLatest {
loadingManga.value = true
getManga.asFlow(params.mangaId)
loadManga()
}
.onEach {
_manga.value = it
loadingManga.value = false
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error when loading manga" }
loadingManga.value = false
}
.launchIn(scope)
}
}
reloadChapters.onStart { emit(Unit) }.flatMapLatest {
loadingChapters.value = true
getChapters.asFlow(params.mangaId)
}
.onEach {
updateChapters(it)
loadingChapters.value = false
fun addFavorite(
categories: List<Category>,
oldCategories: List<Category>,
) {
scope.launch {
manga.value?.let { manga ->
if (manga.inLibrary) {
if (oldCategories.isEmpty()) {
removeMangaFromCategory.await(manga.id, 0, onError = { toast(it.message.orEmpty()) })
} else {
oldCategories.filterNot { it in categories }.forEach {
removeMangaFromCategory.await(manga, it, onError = { toast(it.message.orEmpty()) })
}
}
} else {
addMangaToLibrary.await(manga, onError = { toast(it.message.orEmpty()) })
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error when getting chapters" }
loadingChapters.value = false
if (categories.isEmpty()) {
addMangaToCategory.await(manga.id, 0, onError = { toast(it.message.orEmpty()) })
} else {
categories.filterNot { it in oldCategories }.forEach {
addMangaToCategory.await(manga, it, onError = { toast(it.message.orEmpty()) })
}
}
.launchIn(scope)
scope.launch {
val mangaCategories = getMangaCategories.await(params.mangaId, onError = { toast(it.message.orEmpty()) })
val mangaCategories = getMangaCategories.await(manga.id, onError = { toast(it.message.orEmpty()) })
if (mangaCategories != null) {
_mangaCategories.value = mangaCategories.toImmutableList()
}
loadManga()
}
scope.launch {
val manga = manga.first { it != null }!!
if (!manga.initialized) {
refreshManga()
}
}
}
fun loadManga() {
scope.launch {
reloadManga.emit(Unit)
}
}
fun loadChapters() {
scope.launch {
reloadChapters.emit(Unit)
}
}
fun updateChapters(chapters: List<Chapter>) {
_chapters.value = chapters.sortedByDescending { it.index }.toDownloadChapters()
}
fun refreshManga() {
scope.launch {
refreshingManga.value = true
val manga = refreshManga.await(
params.mangaId,
onError = {
log.warn(it) { "Error when refreshing manga" }
toast(it.message.orEmpty())
}
)
if (manga != null) {
_manga.value = manga
}
refreshingManga.value = false
}
scope.launch {
refreshingChapters.value = true
val chapters = refreshChapters.await(
params.mangaId,
onError = {
log.warn(it) { "Error when refreshing chapters" }
toast(it.message.orEmpty())
}
)
if (!chapters.isNullOrEmpty()) {
updateChapters(chapters)
}
refreshingChapters.value = false
}
}
fun setCategories() {
scope.launch {
manga.value ?: return@launch
chooseCategoriesFlow.emit(Unit)
}
}
fun toggleFavorite() {
scope.launch {
manga.value?.let { manga ->
if (manga.inLibrary) {
removeMangaFromLibrary.await(manga, onError = { toast(it.message.orEmpty()) })
} else {
if (categories.value.isEmpty()) {
addFavorite(emptyList(), emptyList())
} else {
chooseCategoriesFlow.emit(Unit)
}
}
loadManga()
}
}
}
fun addFavorite(
categories: List<Category>,
oldCategories: List<Category>,
) {
scope.launch {
manga.value?.let { manga ->
if (manga.inLibrary) {
if (oldCategories.isEmpty()) {
removeMangaFromCategory.await(manga.id, 0, onError = { toast(it.message.orEmpty()) })
} else {
oldCategories.filterNot { it in categories }.forEach {
removeMangaFromCategory.await(manga, it, onError = { toast(it.message.orEmpty()) })
}
}
} else {
addMangaToLibrary.await(manga, onError = { toast(it.message.orEmpty()) })
}
if (categories.isEmpty()) {
addMangaToCategory.await(manga.id, 0, onError = { toast(it.message.orEmpty()) })
} else {
categories.filterNot { it in oldCategories }.forEach {
addMangaToCategory.await(manga, it, onError = { toast(it.message.orEmpty()) })
}
}
val mangaCategories = getMangaCategories.await(manga.id, onError = { toast(it.message.orEmpty()) })
if (mangaCategories != null) {
_mangaCategories.value = mangaCategories.toImmutableList()
}
loadManga()
}
}
}
private fun setRead(
chapterIds: List<Long>,
read: Boolean,
) {
scope.launch {
manga.value?.let {
updateChapter.await(chapterIds, read = read, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
loadChapters()
}
}
}
fun markRead(id: Long?) = setRead(listOfNotNull(id).ifEmpty { _selectedIds.value }, true)
fun markUnread(id: Long?) = setRead(listOfNotNull(id).ifEmpty { _selectedIds.value }, false)
private fun setBookmarked(
chapterIds: List<Long>,
bookmark: Boolean,
) {
scope.launch {
manga.value?.let {
updateChapter.await(chapterIds, bookmarked = bookmark, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
loadChapters()
}
}
}
fun bookmarkChapter(id: Long?) = setBookmarked(listOfNotNull(id).ifEmpty { _selectedIds.value }, true)
fun unBookmarkChapter(id: Long?) = setBookmarked(listOfNotNull(id).ifEmpty { _selectedIds.value }, false)
fun markPreviousRead(index: Int) {
scope.launch {
manga.value?.let {
val chapters = chapters.value
.sortedBy { it.chapter.index }
.subList(0, index).map{it.chapter.id} // todo test
updateChapter.await(chapters, read = true, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
loadChapters()
}
}
}
fun downloadChapter(chapterId: Long) {
scope.launch { queueChapterDownload.await(chapterId, onError = { toast(it.message.orEmpty()) }) }
}
fun deleteDownload(id: Long?) {
scope.launch {
if (id == null) {
val chapterIds = _selectedIds.value
deleteChapterDownload.await(chapterIds, onError = { toast(it.message.orEmpty()) })
selectedItems.value.forEach {
it.setNotDownloaded()
}
_selectedIds.value = persistentListOf()
} else {
chapters.value.find { it.chapter.id == id }
?.deleteDownload(deleteChapterDownload)
}
}
}
fun stopDownloadingChapter(chapterId: Long) {
scope.launch {
chapters.value.find { it.chapter.id == chapterId }
?.stopDownloading(stopChapterDownload)
}
}
fun selectAll() {
scope.launch {
_selectedIds.value = chapters.value.map { it.chapter.id }.toImmutableList()
}
}
fun invertSelection() {
scope.launch {
_selectedIds.value = chapters.value.map { it.chapter.id }.minus(_selectedIds.value).toImmutableList()
}
}
fun selectChapter(id: Long) {
scope.launch {
_selectedIds.value = _selectedIds.value.plus(id).toImmutableList()
}
}
fun unselectChapter(id: Long) {
scope.launch {
_selectedIds.value = _selectedIds.value.minus(id).toImmutableList()
}
}
fun clearSelection() {
scope.launch {
_selectedIds.value = persistentListOf()
}
}
fun downloadChapters() {
scope.launch {
batchChapterDownload.await(_selectedIds.value)
_selectedIds.value = persistentListOf()
}
}
fun downloadNext(next: Int) {
scope.launch {
batchChapterDownload.await(
_chapters.value.filter { !it.chapter.read && it.downloadState.value == ChapterDownloadState.NotDownloaded }
.map { it.chapter.id }
.takeLast(next),
)
}
}
fun downloadUnread() {
scope.launch {
batchChapterDownload.await(
_chapters.value.filter { !it.chapter.read && it.downloadState.value == ChapterDownloadState.NotDownloaded }
.map { it.chapter.id },
)
}
}
fun downloadAll() {
scope.launch {
batchChapterDownload.await(
_chapters.value
.filter { it.downloadState.value == ChapterDownloadState.NotDownloaded }
.map { it.chapter.id },
)
}
}
private fun List<Chapter>.toDownloadChapters() =
map {
ChapterDownloadItem(null, it)
}.toImmutableList()
data class Params(
val mangaId: Long,
)
private companion object {
private val log = logging()
}
}
private fun setRead(
chapterIds: List<Long>,
read: Boolean,
) {
scope.launch {
manga.value?.let {
updateChapter.await(chapterIds, read = read, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
loadChapters()
}
}
}
fun markRead(id: Long?) = setRead(listOfNotNull(id).ifEmpty { _selectedIds.value }, true)
fun markUnread(id: Long?) = setRead(listOfNotNull(id).ifEmpty { _selectedIds.value }, false)
private fun setBookmarked(
chapterIds: List<Long>,
bookmark: Boolean,
) {
scope.launch {
manga.value?.let {
updateChapter.await(chapterIds, bookmarked = bookmark, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
loadChapters()
}
}
}
fun bookmarkChapter(id: Long?) = setBookmarked(listOfNotNull(id).ifEmpty { _selectedIds.value }, true)
fun unBookmarkChapter(id: Long?) = setBookmarked(listOfNotNull(id).ifEmpty { _selectedIds.value }, false)
fun markPreviousRead(index: Int) {
scope.launch {
manga.value?.let {
val chapters = chapters.value
.sortedBy { it.chapter.index }
.subList(0, index).map { it.chapter.id } // todo test
updateChapter.await(chapters, read = true, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
loadChapters()
}
}
}
fun downloadChapter(chapterId: Long) {
scope.launch { queueChapterDownload.await(chapterId, onError = { toast(it.message.orEmpty()) }) }
}
fun deleteDownload(id: Long?) {
scope.launch {
if (id == null) {
val chapterIds = _selectedIds.value
deleteChapterDownload.await(chapterIds, onError = { toast(it.message.orEmpty()) })
selectedItems.value.forEach {
it.setNotDownloaded()
}
_selectedIds.value = persistentListOf()
} else {
chapters.value.find { it.chapter.id == id }
?.deleteDownload(deleteChapterDownload)
}
}
}
fun stopDownloadingChapter(chapterId: Long) {
scope.launch {
chapters.value.find { it.chapter.id == chapterId }
?.stopDownloading(stopChapterDownload)
}
}
fun selectAll() {
scope.launch {
_selectedIds.value = chapters.value.map { it.chapter.id }.toImmutableList()
}
}
fun invertSelection() {
scope.launch {
_selectedIds.value = chapters.value.map { it.chapter.id }.minus(_selectedIds.value).toImmutableList()
}
}
fun selectChapter(id: Long) {
scope.launch {
_selectedIds.value = _selectedIds.value.plus(id).toImmutableList()
}
}
fun unselectChapter(id: Long) {
scope.launch {
_selectedIds.value = _selectedIds.value.minus(id).toImmutableList()
}
}
fun clearSelection() {
scope.launch {
_selectedIds.value = persistentListOf()
}
}
fun downloadChapters() {
scope.launch {
batchChapterDownload.await(_selectedIds.value)
_selectedIds.value = persistentListOf()
}
}
fun downloadNext(next: Int) {
scope.launch {
batchChapterDownload.await(
_chapters.value.filter { !it.chapter.read && it.downloadState.value == ChapterDownloadState.NotDownloaded }
.map { it.chapter.id }
.takeLast(next),
)
}
}
fun downloadUnread() {
scope.launch {
batchChapterDownload.await(
_chapters.value.filter { !it.chapter.read && it.downloadState.value == ChapterDownloadState.NotDownloaded }
.map { it.chapter.id },
)
}
}
fun downloadAll() {
scope.launch {
batchChapterDownload.await(
_chapters.value
.filter { it.downloadState.value == ChapterDownloadState.NotDownloaded }
.map { it.chapter.id },
)
}
}
private fun List<Chapter>.toDownloadChapters() =
map {
ChapterDownloadItem(null, it)
}.toImmutableList()
data class Params(
val mangaId: Long,
)
private companion object {
private val log = logging()
}
}

View File

@@ -42,7 +42,6 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -69,372 +68,371 @@ import me.tatarka.inject.annotations.Assisted
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class ReaderMenuViewModel
@Inject
constructor(
private val readerPreferences: ReaderPreferences,
private val getManga: GetManga,
private val getChapters: GetChapters,
private val getChapter: GetChapter,
private val getChapterPages: GetChapterPages,
private val updateChapter: UpdateChapter,
private val updateMangaMeta: UpdateMangaMeta,
private val updateChapterMeta: UpdateChapterMeta,
private val chapterCache: ChapterCache,
private val http: Http,
contextWrapper: ContextWrapper,
@Assisted private val params: Params,
) : ViewModel(contextWrapper) {
override val scope = MainScope()
private val _manga = MutableStateFlow<Manga?>(null)
private val viewerChapters = MutableStateFlow(ViewerChapters(null, null, null))
val previousChapter = viewerChapters.map { it.prevChapter }.stateIn(scope, SharingStarted.Eagerly, null)
val chapter = viewerChapters.map { it.currChapter }.stateIn(scope, SharingStarted.Eagerly, null)
val nextChapter = viewerChapters.map { it.nextChapter }.stateIn(scope, SharingStarted.Eagerly, null)
@Inject
class ReaderMenuViewModel(
private val readerPreferences: ReaderPreferences,
private val getManga: GetManga,
private val getChapters: GetChapters,
private val getChapter: GetChapter,
private val getChapterPages: GetChapterPages,
private val updateChapter: UpdateChapter,
private val updateMangaMeta: UpdateMangaMeta,
private val updateChapterMeta: UpdateChapterMeta,
private val chapterCache: ChapterCache,
private val http: Http,
contextWrapper: ContextWrapper,
@Assisted private val params: Params,
) : ViewModel(contextWrapper) {
override val scope = MainScope()
private val _manga = MutableStateFlow<Manga?>(null)
private val viewerChapters = MutableStateFlow(ViewerChapters(null, null, null))
val previousChapter = viewerChapters.map { it.prevChapter }.stateIn(scope, SharingStarted.Eagerly, null)
val chapter = viewerChapters.map { it.currChapter }.stateIn(scope, SharingStarted.Eagerly, null)
val nextChapter = viewerChapters.map { it.nextChapter }.stateIn(scope, SharingStarted.Eagerly, null)
private val _state = MutableStateFlow<ReaderChapter.State>(ReaderChapter.State.Wait)
val state = _state.asStateFlow()
private val _state = MutableStateFlow<ReaderChapter.State>(ReaderChapter.State.Wait)
val state = _state.asStateFlow()
val pages = viewerChapters.flatMapLatest { viewerChapters ->
val previousChapterPages = viewerChapters.prevChapter
?.pages
?.map { (it as? PagesState.Success)?.pages }
?: flowOf(null)
val chapterPages = viewerChapters.currChapter
?.pages
?.map { (it as? PagesState.Success)?.pages }
?: flowOf(null)
val nextChapterPages = viewerChapters.nextChapter
?.pages
?.map { (it as? PagesState.Success)?.pages }
?: flowOf(null)
combine(previousChapterPages, chapterPages, nextChapterPages) { prev, cur, next ->
(
prev.orEmpty() +
ReaderPageSeparator(viewerChapters.prevChapter, viewerChapters.currChapter) +
cur.orEmpty() +
ReaderPageSeparator(viewerChapters.currChapter, viewerChapters.nextChapter) +
next.orEmpty()
val pages = viewerChapters.flatMapLatest { viewerChapters ->
val previousChapterPages = viewerChapters.prevChapter
?.pages
?.map { (it as? PagesState.Success)?.pages }
?: flowOf(null)
val chapterPages = viewerChapters.currChapter
?.pages
?.map { (it as? PagesState.Success)?.pages }
?: flowOf(null)
val nextChapterPages = viewerChapters.nextChapter
?.pages
?.map { (it as? PagesState.Success)?.pages }
?: flowOf(null)
combine(previousChapterPages, chapterPages, nextChapterPages) { prev, cur, next ->
(
prev.orEmpty() +
ReaderPageSeparator(viewerChapters.prevChapter, viewerChapters.currChapter) +
cur.orEmpty() +
ReaderPageSeparator(viewerChapters.currChapter, viewerChapters.nextChapter) +
next.orEmpty()
).toImmutableList()
}
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val _currentPage = MutableStateFlow<ReaderItem?>(null)
val currentPage = _currentPage.asStateFlow()
private val _currentPageOffset = MutableStateFlow(1)
val currentPageOffset = _currentPageOffset.asStateFlow()
private val _readerSettingsMenuOpen = MutableStateFlow(false)
val readerSettingsMenuOpen = _readerSettingsMenuOpen.asStateFlow()
private val _pageEmitter = MutableSharedFlow<PageMove>()
val pageEmitter = StableHolder(_pageEmitter.asSharedFlow())
val readerModes = readerPreferences.modes()
.getAsFlow()
.map { it.toImmutableList() }
.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val readerMode = combine(readerPreferences.mode().getAsFlow(), _manga) { mode, manga ->
val mangaMode = manga?.meta?.juiReaderMode?.decodeURLQueryComponent()
if (
mangaMode != null &&
mangaMode != MangaMeta.DEFAULT_READER_MODE &&
mangaMode in readerModes.value
) {
mangaMode
} else {
mode
}
}.stateIn(scope, SharingStarted.Eagerly, readerPreferences.mode().get())
val readerModeSettings = ReaderModeWatch(readerPreferences, scope, readerMode)
private val loader = ChapterLoader(
readerPreferences = readerPreferences,
http = http,
chapterCache = chapterCache,
bitmapDecoderFactory = BitmapDecoderFactory(contextWrapper),
getChapterPages = getChapterPages,
)
init {
init()
}
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
fun init() {
scope.launchDefault {
runCatching {
initManga(params.mangaId)
initChapters(params.mangaId, params.chapterId)
}
}
private val _currentPage = MutableStateFlow<ReaderItem?>(null)
val currentPage = _currentPage.asStateFlow()
private val _currentPageOffset = MutableStateFlow(1)
val currentPageOffset = _currentPageOffset.asStateFlow()
private val _readerSettingsMenuOpen = MutableStateFlow(false)
val readerSettingsMenuOpen = _readerSettingsMenuOpen.asStateFlow()
private val _pageEmitter = MutableSharedFlow<PageMove>()
val pageEmitter = StableHolder(_pageEmitter.asSharedFlow())
val readerModes = readerPreferences.modes()
.getAsFlow()
.map { it.toImmutableList() }
.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val readerMode = combine(readerPreferences.mode().getAsFlow(), _manga) { mode, manga ->
val mangaMode = manga?.meta?.juiReaderMode?.decodeURLQueryComponent()
if (
mangaMode != null &&
mangaMode != MangaMeta.DEFAULT_READER_MODE &&
mangaMode in readerModes.value
) {
mangaMode
} else {
mode
}
}.stateIn(scope, SharingStarted.Eagerly, readerPreferences.mode().get())
init {
scope.launchDefault {
currentPage
.filterIsInstance<ReaderPage>()
.collectLatest { page ->
page.chapter.pageLoader?.loadPage(page)
if (page.chapter == chapter.value) {
val pages = page.chapter.pages.value as? PagesState.Success
?: return@collectLatest
if ((page.index2 + 1) >= pages.pages.size) {
markChapterRead(page.chapter)
}
val nextChapter = nextChapter.value
if (nextChapter != null && (page.index2 + 1) >= ((pages.pages.size - 5).coerceAtLeast(1))) {
requestPreloadChapter(nextChapter)
}
} else {
val previousChapter = previousChapter.value
val nextChapter = nextChapter.value
if (page.chapter == previousChapter) {
viewerChapters.value = viewerChapters.value.movePrev()
initChapters(params.mangaId, page.chapter.chapter.id, fromMenuButton = false)
} else if (page.chapter == nextChapter) {
viewerChapters.value = viewerChapters.value.moveNext()
initChapters(params.mangaId, page.chapter.chapter.id, fromMenuButton = false)
}
}
}
}
}
val readerModeSettings = ReaderModeWatch(readerPreferences, scope, readerMode)
fun navigate(navigationRegion: Navigation): Boolean {
scope.launch {
val moveTo = when (navigationRegion) {
Navigation.MENU -> {
setReaderSettingsMenuOpen(!readerSettingsMenuOpen.value)
null
}
private val loader = ChapterLoader(
readerPreferences = readerPreferences,
http = http,
chapterCache = chapterCache,
bitmapDecoderFactory = BitmapDecoderFactory(contextWrapper),
getChapterPages = getChapterPages,
)
Navigation.NEXT -> MoveTo.Next
init {
init()
}
Navigation.PREV -> MoveTo.Previous
Navigation.RIGHT -> when (readerModeSettings.direction.value) {
Direction.Left -> MoveTo.Previous
else -> MoveTo.Next
}
Navigation.LEFT -> when (readerModeSettings.direction.value) {
Direction.Left -> MoveTo.Next
else -> MoveTo.Previous
}
Navigation.DOWN -> when (readerModeSettings.direction.value) {
Direction.Up -> MoveTo.Previous
else -> MoveTo.Next
}
Navigation.UP -> when (readerModeSettings.direction.value) {
Direction.Up -> MoveTo.Next
else -> MoveTo.Previous
}
}
if (moveTo != null) {
_pageEmitter.emit(PageMove.Direction(moveTo))
}
}
return true
}
fun navigate(page: Int) {
log.info { "Navigate to $page" }
scope.launch {
_pageEmitter.emit(PageMove.Page(pages.value.getOrNull(page) ?: return@launch))
}
}
fun progress(page: ReaderItem) {
log.info { "Progressed to $page" }
_currentPage.value = page
}
fun retry(page: ReaderPage) {
log.info { "Retrying ${page.index2}" }
chapter.value?.pageLoader?.retryPage(page)
}
fun setMangaReaderMode(mode: String) {
scope.launchDefault {
_manga.value?.let {
updateMangaMeta.await(it, mode, onError = { toast(it.message.orEmpty()) })
}
fun init() {
scope.launchDefault {
runCatching {
initManga(params.mangaId)
initChapters(params.mangaId, params.chapterId)
}
}
fun setReaderSettingsMenuOpen(open: Boolean) {
_readerSettingsMenuOpen.value = open
}
fun prevChapter() {
scope.launchDefault {
val prevChapter = previousChapter.value ?: return@launchDefault
try {
_state.value = ReaderChapter.State.Wait
sendProgress()
viewerChapters.value = viewerChapters.value.movePrev()
initChapters(params.mangaId, prevChapter.chapter.id, fromMenuButton = true)
} catch (e: Exception) {
log.warn(e) { "Error loading prev chapter" }
}
}
}
fun nextChapter() {
scope.launchDefault {
val nextChapter = nextChapter.value ?: return@launchDefault
try {
_state.value = ReaderChapter.State.Wait
sendProgress()
viewerChapters.value = viewerChapters.value.moveNext()
initChapters(params.mangaId, nextChapter.chapter.id, fromMenuButton = true)
} catch (e: Exception) {
log.warn(e) { "Error loading next chapter" }
}
}
}
private suspend fun initManga(mangaId: Long) {
getManga.asFlow(mangaId)
.take(1)
.onEach {
_manga.value = it
}
.catch {
_state.value = ReaderChapter.State.Error(it)
log.warn(it) { "Error loading manga" }
}
.collect()
}
private suspend fun initChapters(
mangaId: Long,
chapterId: Long,
fromMenuButton: Boolean = true,
) {
log.debug { "Loading chapter index $chapterId" }
val (chapter, pages) = coroutineScope {
val chapters = getChapters.asFlow(mangaId)
.take(1)
.catch {
_state.value = ReaderChapter.State.Error(it)
log.warn(it) { "Error getting chapters for $mangaId" }
}
.singleOrNull()
?: return@coroutineScope null
val chapter = chapters.find { it.id == chapterId }
?.let { ReaderChapter(it) }
?: return@coroutineScope null
val pages = loader.loadChapter(chapter)
viewerChapters.update { it.copy(currChapter = chapter) }
if (viewerChapters.value.nextChapter == null) {
val nextChapter = chapters.find { it.index == chapter.chapter.index + 1 }
if (nextChapter != null) {
val nextReaderChapter = ReaderChapter(nextChapter)
viewerChapters.update { it.copy(nextChapter = nextReaderChapter) }
} else {
viewerChapters.update { it.copy(nextChapter = null) }
}
}
if (viewerChapters.value.prevChapter == null) {
val prevChapter = chapters.find { it.index == chapter.chapter.index - 1 }
if (prevChapter != null) {
val prevReaderChapter = ReaderChapter(prevChapter)
viewerChapters.update { it.copy(prevChapter = prevReaderChapter) }
} else {
viewerChapters.update { it.copy(prevChapter = null) }
}
}
chapter to pages
} ?: return
if (fromMenuButton) {
chapter.stateObserver
.onEach {
_state.value = it
}
.launchIn(chapter.scope)
pages
.filterIsInstance<PagesState.Success>()
.onEach { (pageList) ->
val lastPageReadOffset = chapter.chapter.meta.juiPageOffset
if (lastPageReadOffset != 0) {
_currentPageOffset.value = lastPageReadOffset
}
val lastPageRead = chapter.chapter.lastPageRead
_currentPage.value = if (lastPageRead > 0) {
pageList[lastPageRead.coerceAtMost(pageList.lastIndex)]
} else {
pageList.first()
}.also { chapter.pageLoader?.loadPage(it) }
}
.launchIn(chapter.scope)
}
}
fun requestPreloadChapter(chapter: ReaderChapter) {
if (chapter.state != ReaderChapter.State.Wait && chapter.state !is ReaderChapter.State.Error) {
return
}
log.debug { "Preloading ${chapter.chapter.index}" }
loader.loadChapter(chapter)
}
private fun markChapterRead(chapter: ReaderChapter) {
scope.launch {
updateChapter.await(chapter.chapter, read = true, onError = { toast(it.message.orEmpty()) })
}
}
@OptIn(DelicateCoroutinesApi::class)
fun sendProgress(
chapter: Chapter? = this.chapter.value?.chapter,
lastPageRead: Int = (currentPage.value as? ReaderPage)?.index2 ?: 0,
) {
chapter ?: return
if (chapter.read) return
GlobalScope.launch {
updateChapter.await(
chapter,
lastPageRead = lastPageRead,
onError = { toast(it.message.orEmpty()) },
)
}
}
fun updateLastPageReadOffset(offset: Int) {
updateLastPageReadOffset(chapter.value?.chapter ?: return, offset)
}
@OptIn(DelicateCoroutinesApi::class)
private fun updateLastPageReadOffset(
chapter: Chapter,
offset: Int,
) {
GlobalScope.launch {
updateChapterMeta.await(chapter, offset, onError = { toast(it.message.orEmpty()) })
}
}
override fun onDispose() {
viewerChapters.value.recycle()
scope.cancel()
}
data class Params(
val chapterId: Long,
val mangaId: Long,
)
private companion object {
private val log = logging()
}
}
init {
scope.launchDefault {
currentPage
.filterIsInstance<ReaderPage>()
.collectLatest { page ->
page.chapter.pageLoader?.loadPage(page)
if (page.chapter == chapter.value) {
val pages = page.chapter.pages.value as? PagesState.Success
?: return@collectLatest
if ((page.index2 + 1) >= pages.pages.size) {
markChapterRead(page.chapter)
}
val nextChapter = nextChapter.value
if (nextChapter != null && (page.index2 + 1) >= ((pages.pages.size - 5).coerceAtLeast(1))) {
requestPreloadChapter(nextChapter)
}
} else {
val previousChapter = previousChapter.value
val nextChapter = nextChapter.value
if (page.chapter == previousChapter) {
viewerChapters.value = viewerChapters.value.movePrev()
initChapters(params.mangaId, page.chapter.chapter.id, fromMenuButton = false)
} else if (page.chapter == nextChapter) {
viewerChapters.value = viewerChapters.value.moveNext()
initChapters(params.mangaId, page.chapter.chapter.id, fromMenuButton = false)
}
}
}
}
}
fun navigate(navigationRegion: Navigation): Boolean {
scope.launch {
val moveTo = when (navigationRegion) {
Navigation.MENU -> {
setReaderSettingsMenuOpen(!readerSettingsMenuOpen.value)
null
}
Navigation.NEXT -> MoveTo.Next
Navigation.PREV -> MoveTo.Previous
Navigation.RIGHT -> when (readerModeSettings.direction.value) {
Direction.Left -> MoveTo.Previous
else -> MoveTo.Next
}
Navigation.LEFT -> when (readerModeSettings.direction.value) {
Direction.Left -> MoveTo.Next
else -> MoveTo.Previous
}
Navigation.DOWN -> when (readerModeSettings.direction.value) {
Direction.Up -> MoveTo.Previous
else -> MoveTo.Next
}
Navigation.UP -> when (readerModeSettings.direction.value) {
Direction.Up -> MoveTo.Next
else -> MoveTo.Previous
}
}
if (moveTo != null) {
_pageEmitter.emit(PageMove.Direction(moveTo))
}
}
return true
}
fun navigate(page: Int) {
log.info { "Navigate to $page" }
scope.launch {
_pageEmitter.emit(PageMove.Page(pages.value.getOrNull(page) ?: return@launch))
}
}
fun progress(page: ReaderItem) {
log.info { "Progressed to $page" }
_currentPage.value = page
}
fun retry(page: ReaderPage) {
log.info { "Retrying ${page.index2}" }
chapter.value?.pageLoader?.retryPage(page)
}
fun setMangaReaderMode(mode: String) {
scope.launchDefault {
_manga.value?.let {
updateMangaMeta.await(it, mode, onError = { toast(it.message.orEmpty()) })
}
initManga(params.mangaId)
}
}
fun setReaderSettingsMenuOpen(open: Boolean) {
_readerSettingsMenuOpen.value = open
}
fun prevChapter() {
scope.launchDefault {
val prevChapter = previousChapter.value ?: return@launchDefault
try {
_state.value = ReaderChapter.State.Wait
sendProgress()
viewerChapters.value = viewerChapters.value.movePrev()
initChapters(params.mangaId, prevChapter.chapter.id, fromMenuButton = true)
} catch (e: Exception) {
log.warn(e) { "Error loading prev chapter" }
}
}
}
fun nextChapter() {
scope.launchDefault {
val nextChapter = nextChapter.value ?: return@launchDefault
try {
_state.value = ReaderChapter.State.Wait
sendProgress()
viewerChapters.value = viewerChapters.value.moveNext()
initChapters(params.mangaId, nextChapter.chapter.id, fromMenuButton = true)
} catch (e: Exception) {
log.warn(e) { "Error loading next chapter" }
}
}
}
private suspend fun initManga(mangaId: Long) {
getManga.asFlow(mangaId)
.take(1)
.onEach {
_manga.value = it
}
.catch {
_state.value = ReaderChapter.State.Error(it)
log.warn(it) { "Error loading manga" }
}
.collect()
}
private suspend fun initChapters(
mangaId: Long,
chapterId: Long,
fromMenuButton: Boolean = true,
) {
log.debug { "Loading chapter index $chapterId" }
val (chapter, pages) = coroutineScope {
val chapters = getChapters.asFlow(mangaId)
.take(1)
.catch {
_state.value = ReaderChapter.State.Error(it)
log.warn(it) { "Error getting chapters for $mangaId" }
}
.singleOrNull()
?: return@coroutineScope null
val chapter = chapters.find { it.id == chapterId }
?.let { ReaderChapter(it) }
?: return@coroutineScope null
val pages = loader.loadChapter(chapter)
viewerChapters.update { it.copy(currChapter = chapter) }
if (viewerChapters.value.nextChapter == null) {
val nextChapter = chapters.find { it.index == chapter.chapter.index + 1 }
if (nextChapter != null) {
val nextReaderChapter = ReaderChapter(nextChapter)
viewerChapters.update { it.copy(nextChapter = nextReaderChapter) }
} else {
viewerChapters.update { it.copy(nextChapter = null) }
}
}
if (viewerChapters.value.prevChapter == null) {
val prevChapter = chapters.find { it.index == chapter.chapter.index - 1 }
if (prevChapter != null) {
val prevReaderChapter = ReaderChapter(prevChapter)
viewerChapters.update { it.copy(prevChapter = prevReaderChapter) }
} else {
viewerChapters.update { it.copy(prevChapter = null) }
}
}
chapter to pages
} ?: return
if (fromMenuButton) {
chapter.stateObserver
.onEach {
_state.value = it
}
.launchIn(chapter.scope)
pages
.filterIsInstance<PagesState.Success>()
.onEach { (pageList) ->
val lastPageReadOffset = chapter.chapter.meta.juiPageOffset
if (lastPageReadOffset != 0) {
_currentPageOffset.value = lastPageReadOffset
}
val lastPageRead = chapter.chapter.lastPageRead
_currentPage.value = if (lastPageRead > 0) {
pageList[lastPageRead.coerceAtMost(pageList.lastIndex)]
} else {
pageList.first()
}.also { chapter.pageLoader?.loadPage(it) }
}
.launchIn(chapter.scope)
}
}
fun requestPreloadChapter(chapter: ReaderChapter) {
if (chapter.state != ReaderChapter.State.Wait && chapter.state !is ReaderChapter.State.Error) {
return
}
log.debug { "Preloading ${chapter.chapter.index}" }
loader.loadChapter(chapter)
}
private fun markChapterRead(chapter: ReaderChapter) {
scope.launch {
updateChapter.await(chapter.chapter, read = true, onError = { toast(it.message.orEmpty()) })
}
}
@OptIn(DelicateCoroutinesApi::class)
fun sendProgress(
chapter: Chapter? = this.chapter.value?.chapter,
lastPageRead: Int = (currentPage.value as? ReaderPage)?.index2 ?: 0,
) {
chapter ?: return
if (chapter.read) return
GlobalScope.launch {
updateChapter.await(
chapter,
lastPageRead = lastPageRead,
onError = { toast(it.message.orEmpty()) },
)
}
}
fun updateLastPageReadOffset(offset: Int) {
updateLastPageReadOffset(chapter.value?.chapter ?: return, offset)
}
@OptIn(DelicateCoroutinesApi::class)
private fun updateLastPageReadOffset(
chapter: Chapter,
offset: Int,
) {
GlobalScope.launch {
updateChapterMeta.await(chapter, offset, onError = { toast(it.message.orEmpty()) })
}
}
override fun onDispose() {
viewerChapters.value.recycle()
scope.cancel()
}
data class Params(
val chapterId: Long,
val mangaId: Long,
)
private companion object {
private val log = logging()
}
}

View File

@@ -73,47 +73,46 @@ class SettingsAdvancedScreen : Screen {
}
}
class SettingsAdvancedViewModel
@Inject
constructor(
updatePreferences: UpdatePreferences,
private val imageCache: ImageCache,
private val chapterCache: ChapterCache,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val updatesEnabled = updatePreferences.enabled().asStateFlow()
@Inject
class SettingsAdvancedViewModel(
updatePreferences: UpdatePreferences,
private val imageCache: ImageCache,
private val chapterCache: ChapterCache,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val updatesEnabled = updatePreferences.enabled().asStateFlow()
val imageCacheSize = flow {
while (currentCoroutineContext().isActive) {
emit(imageCache.size.bytesIntoHumanReadable())
delay(1.seconds)
}
}.stateIn(scope, SharingStarted.Eagerly, "")
val chapterCacheSize = flow {
while (currentCoroutineContext().isActive) {
emit(chapterCache.size.bytesIntoHumanReadable())
delay(1.seconds)
}
}.stateIn(scope, SharingStarted.Eagerly, "")
fun clearImageCache() {
scope.launchIO {
imageCache.clear()
}
val imageCacheSize = flow {
while (currentCoroutineContext().isActive) {
emit(imageCache.size.bytesIntoHumanReadable())
delay(1.seconds)
}
}.stateIn(scope, SharingStarted.Eagerly, "")
fun clearChapterCache() {
scope.launchIO {
chapterCache.clear()
}
val chapterCacheSize = flow {
while (currentCoroutineContext().isActive) {
emit(chapterCache.size.bytesIntoHumanReadable())
delay(1.seconds)
}
}.stateIn(scope, SharingStarted.Eagerly, "")
companion object {
private val log = logging()
fun clearImageCache() {
scope.launchIO {
imageCache.clear()
}
}
fun clearChapterCache() {
scope.launchIO {
chapterCache.clear()
}
}
companion object {
private val log = logging()
}
}
@Composable
fun SettingsAdvancedScreenContent(
updatesEnabled: PreferenceMutableStateFlow<Boolean>,

View File

@@ -108,23 +108,22 @@ class SettingsAppearanceScreen : Screen {
}
}
class ThemesViewModel
@Inject
constructor(
private val uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val themeMode = uiPreferences.themeMode().asStateFlow()
val lightTheme = uiPreferences.lightTheme().asStateFlow()
val darkTheme = uiPreferences.darkTheme().asStateFlow()
val lightColors = uiPreferences.getLightColors().asStateFlow(scope)
val darkColors = uiPreferences.getDarkColors().asStateFlow(scope)
@Inject
class ThemesViewModel(
private val uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val themeMode = uiPreferences.themeMode().asStateFlow()
val lightTheme = uiPreferences.lightTheme().asStateFlow()
val darkTheme = uiPreferences.darkTheme().asStateFlow()
val lightColors = uiPreferences.getLightColors().asStateFlow(scope)
val darkColors = uiPreferences.getDarkColors().asStateFlow(scope)
val windowDecorations = uiPreferences.windowDecorations().asStateFlow()
val windowDecorations = uiPreferences.windowDecorations().asStateFlow()
@Composable
fun getActiveColors(): AppColorsPreferenceState = if (MaterialTheme.colors.isLight) lightColors else darkColors
}
@Composable
fun getActiveColors(): AppColorsPreferenceState = if (MaterialTheme.colors.isLight) lightColors else darkColors
}
expect val showWindowDecorationsOption: Boolean

View File

@@ -118,157 +118,156 @@ class SettingsBackupScreen : Screen {
}
}
class SettingsBackupViewModel
@Inject
constructor(
private val validateBackupFile: ValidateBackupFile,
private val importBackupFile: ImportBackupFile,
private val exportBackupFile: ExportBackupFile,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
private val _restoreStatus = MutableStateFlow<Status>(Status.Nothing)
val restoreStatus = _restoreStatus.asStateFlow()
@Inject
class SettingsBackupViewModel(
private val validateBackupFile: ValidateBackupFile,
private val importBackupFile: ImportBackupFile,
private val exportBackupFile: ExportBackupFile,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
private val _restoreStatus = MutableStateFlow<Status>(Status.Nothing)
val restoreStatus = _restoreStatus.asStateFlow()
private val _missingSourceFlow = MutableSharedFlow<Pair<Path, ImmutableList<String>>>()
val missingSourceFlowHolder = StableHolder(_missingSourceFlow.asSharedFlow())
private val _missingSourceFlow = MutableSharedFlow<Pair<Path, ImmutableList<String>>>()
val missingSourceFlowHolder = StableHolder(_missingSourceFlow.asSharedFlow())
private val _creatingStatus = MutableStateFlow<Status>(Status.Nothing)
val creatingStatus = _creatingStatus.asStateFlow()
private val _createFlow = MutableSharedFlow<String>()
val createFlowHolder = StableHolder(_createFlow.asSharedFlow())
private val _creatingStatus = MutableStateFlow<Status>(Status.Nothing)
val creatingStatus = _creatingStatus.asStateFlow()
private val _createFlow = MutableSharedFlow<String>()
val createFlowHolder = StableHolder(_createFlow.asSharedFlow())
fun restoreFile(source: Source) {
scope.launch {
val file = try {
FileSystem.SYSTEM_TEMPORARY_DIRECTORY
.resolve("tachidesk.${Random.nextLong()}.tachibk")
.also { file ->
source.saveTo(file)
}
} catch (e: Exception) {
log.warn(e) { "Error creating backup file" }
_restoreStatus.value = Status.Error
e.throwIfCancellation()
return@launch
}
validateBackupFile.asFlow(file)
.onEach { (missingSources) ->
if (missingSources.isEmpty()) {
restoreBackup(file)
} else {
_missingSourceFlow.emit(file to missingSources.toImmutableList())
}
fun restoreFile(source: Source) {
scope.launch {
val file = try {
FileSystem.SYSTEM_TEMPORARY_DIRECTORY
.resolve("tachidesk.${Random.nextLong()}.tachibk")
.also { file ->
source.saveTo(file)
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error importing backup" }
_restoreStatus.value = Status.Error
}
.collect()
} catch (e: Exception) {
log.warn(e) { "Error creating backup file" }
_restoreStatus.value = Status.Error
e.throwIfCancellation()
return@launch
}
}
fun restoreBackup(file: Path) {
importBackupFile
.asFlow(file)
.onStart {
_restoreStatus.value = Status.InProgress(null)
}
.onEach {
_restoreStatus.value = it.second.toStatus()
validateBackupFile.asFlow(file)
.onEach { (missingSources) ->
if (missingSources.isEmpty()) {
restoreBackup(file)
} else {
_missingSourceFlow.emit(file to missingSources.toImmutableList())
}
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error importing backup" }
_restoreStatus.value = Status.Error
}
.launchIn(scope)
.collect()
}
}
private fun RestoreStatus.toStatus() = when (state) {
RestoreState.IDLE -> Status.Success
RestoreState.SUCCESS -> Status.Success
RestoreState.FAILURE -> Status.Error
RestoreState.RESTORING_CATEGORIES -> Status.InProgress(0.01f)
RestoreState.RESTORING_SETTINGS -> Status.InProgress(0.02f)
RestoreState.RESTORING_MANGA -> Status.InProgress((completed.toFloat() / total).coerceIn(0f, 0.99f))
RestoreState.RESTORING_META -> Status.InProgress(1f)
RestoreState.UNKNOWN -> Status.Error
}
fun restoreBackup(file: Path) {
importBackupFile
.asFlow(file)
.onStart {
_restoreStatus.value = Status.InProgress(null)
}
.onEach {
_restoreStatus.value = it.second.toStatus()
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error importing backup" }
_restoreStatus.value = Status.Error
}
.launchIn(scope)
}
fun stopRestore() {
_restoreStatus.value = Status.Error
}
private fun RestoreStatus.toStatus() = when (state) {
RestoreState.IDLE -> Status.Success
RestoreState.SUCCESS -> Status.Success
RestoreState.FAILURE -> Status.Error
RestoreState.RESTORING_CATEGORIES -> Status.InProgress(0.01f)
RestoreState.RESTORING_SETTINGS -> Status.InProgress(0.02f)
RestoreState.RESTORING_MANGA -> Status.InProgress((completed.toFloat() / total).coerceIn(0f, 0.99f))
RestoreState.RESTORING_META -> Status.InProgress(1f)
RestoreState.UNKNOWN -> Status.Error
}
private val tempFile = MutableStateFlow<Path?>(null)
private val mutex = Mutex()
fun stopRestore() {
_restoreStatus.value = Status.Error
}
fun exportBackup() {
exportBackupFile
.asFlow(
true, true // todo
) {
onDownload { bytesSentTotal, contentLength ->
_creatingStatus.value = Status.InProgress(
(bytesSentTotal.toFloat() / contentLength)
.coerceAtMost(0.99F),
)
}
private val tempFile = MutableStateFlow<Path?>(null)
private val mutex = Mutex()
fun exportBackup() {
exportBackupFile
.asFlow(
true, true, // todo
) {
onDownload { bytesSentTotal, contentLength ->
_creatingStatus.value = Status.InProgress(
(bytesSentTotal.toFloat() / contentLength)
.coerceAtMost(0.99F),
)
}
.onStart {
_creatingStatus.value = Status.InProgress(null)
}
.onEach { (filename, source) ->
tempFile.value = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.resolve(filename).also {
mutex.tryLock()
scope.launch {
try {
source.saveTo(it)
} catch (e: Exception) {
e.throwIfCancellation()
log.warn(e) { "Error creating backup" }
_creatingStatus.value = Status.Error
} finally {
mutex.unlock()
}
}
}
_createFlow.emit(filename)
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error exporting backup" }
_creatingStatus.value = Status.Error
}
.launchIn(scope)
}
fun exportBackupFileFound(backupSink: Sink) {
scope.launch {
mutex.withLock {
val tempFile = tempFile.value
if (creatingStatus.value is Status.InProgress && tempFile != null) {
}
.onStart {
_creatingStatus.value = Status.InProgress(null)
}
.onEach { (filename, source) ->
tempFile.value = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.resolve(filename).also {
mutex.tryLock()
scope.launch {
try {
FileSystem.SYSTEM.source(tempFile).copyTo(backupSink.buffer())
_creatingStatus.value = Status.Success
source.saveTo(it)
} catch (e: Exception) {
e.throwIfCancellation()
log.error(e) { "Error moving created backup" }
log.warn(e) { "Error creating backup" }
_creatingStatus.value = Status.Error
} finally {
mutex.unlock()
}
} else {
}
}
_createFlow.emit(filename)
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error exporting backup" }
_creatingStatus.value = Status.Error
}
.launchIn(scope)
}
fun exportBackupFileFound(backupSink: Sink) {
scope.launch {
mutex.withLock {
val tempFile = tempFile.value
if (creatingStatus.value is Status.InProgress && tempFile != null) {
try {
FileSystem.SYSTEM.source(tempFile).copyTo(backupSink.buffer())
_creatingStatus.value = Status.Success
} catch (e: Exception) {
e.throwIfCancellation()
log.error(e) { "Error moving created backup" }
_creatingStatus.value = Status.Error
}
} else {
_creatingStatus.value = Status.Error
}
}
}
private companion object {
private val log = logging()
}
}
private companion object {
private val log = logging()
}
}
sealed class Status {
data object Nothing : Status()

View File

@@ -78,63 +78,67 @@ class SettingsGeneralScreen : Screen {
}
}
class SettingsGeneralViewModel
@Inject
constructor(
private val dateHandler: DateHandler,
uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val startScreen = uiPreferences.startScreen().asStateFlow()
val confirmExit = uiPreferences.confirmExit().asStateFlow()
val language = uiPreferences.language().asStateFlow()
val dateFormat = uiPreferences.dateFormat().asStateFlow()
@Inject
class SettingsGeneralViewModel(
private val dateHandler: DateHandler,
uiPreferences: UiPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val startScreen = uiPreferences.startScreen().asStateFlow()
val confirmExit = uiPreferences.confirmExit().asStateFlow()
val language = uiPreferences.language().asStateFlow()
val dateFormat = uiPreferences.dateFormat().asStateFlow()
private val now = Clock.System.now()
private val currentLocale = Locale.current
private val now = Clock.System.now()
private val currentLocale = Locale.current
@Composable
fun getStartScreenChoices(): ImmutableMap<StartScreen, String> =
persistentMapOf(
StartScreen.Library to stringResource(MR.strings.location_library),
StartScreen.Updates to stringResource(MR.strings.location_updates),
StartScreen.Sources to stringResource(MR.strings.location_sources),
StartScreen.Extensions to stringResource(MR.strings.location_extensions),
)
@Composable
fun getStartScreenChoices(): ImmutableMap<StartScreen, String> =
persistentMapOf(
StartScreen.Library to stringResource(MR.strings.location_library),
StartScreen.Updates to stringResource(MR.strings.location_updates),
StartScreen.Sources to stringResource(MR.strings.location_sources),
StartScreen.Extensions to stringResource(MR.strings.location_extensions),
)
@Composable
fun getLanguageChoices(): ImmutableMap<String, String> {
val langJsonState = MR.files.languages_json.readTextAsync()
val langs by produceState(emptyMap(), langJsonState.value) {
val langJson = langJsonState.value
if (langJson != null) {
withIOContext {
value = Json.decodeFromString<JsonObject>(langJson)["langs"]
?.jsonArray
.orEmpty()
.map { it.jsonPrimitive.content }
.associateWith { Locale(it).getDisplayName(currentLocale) }
}
@Composable
fun getLanguageChoices(): ImmutableMap<String, String> {
val langJsonState = MR.files.languages_json.readTextAsync()
val langs by produceState(emptyMap(), langJsonState.value) {
val langJson = langJsonState.value
if (langJson != null) {
withIOContext {
value = Json.decodeFromString<JsonObject>(langJson)["langs"]
?.jsonArray
.orEmpty()
.map { it.jsonPrimitive.content }
.associateWith { Locale(it).getDisplayName(currentLocale) }
}
}
return mapOf("" to stringResource(MR.strings.language_system_default, currentLocale.getDisplayName(currentLocale)))
.plus(langs)
.toImmutableMap()
}
@Composable
fun getDateChoices(): ImmutableMap<String, String> =
dateHandler.formatOptions
.associateWith {
it.ifEmpty { stringResource(MR.strings.date_system_default) } +
" (${getFormattedDate(it)})"
}
.toImmutableMap()
@Composable
private fun getFormattedDate(prefValue: String): String = dateHandler.getDateFormat(prefValue).invoke(now)
return mapOf(
"" to stringResource(
MR.strings.language_system_default,
currentLocale.getDisplayName(currentLocale),
),
)
.plus(langs)
.toImmutableMap()
}
@Composable
fun getDateChoices(): ImmutableMap<String, String> =
dateHandler.formatOptions
.associateWith {
it.ifEmpty { stringResource(MR.strings.date_system_default) } +
" (${getFormattedDate(it)})"
}
.toImmutableMap()
@Composable
private fun getFormattedDate(prefValue: String): String = dateHandler.getDateFormat(prefValue).invoke(now)
}
@Composable
fun SettingsGeneralScreenContent(
startScreen: PreferenceMutableStateFlow<StartScreen>,

View File

@@ -94,38 +94,37 @@ class SettingsLibraryScreen : Screen {
}
}
class SettingsLibraryViewModel
@Inject
constructor(
libraryPreferences: LibraryPreferences,
private val getCategories: GetCategories,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val displayMode = libraryPreferences.displayMode().asStateFlow()
val gridColumns = libraryPreferences.gridColumns().asStateFlow()
val gridSize = libraryPreferences.gridSize().asStateFlow()
@Inject
class SettingsLibraryViewModel(
libraryPreferences: LibraryPreferences,
private val getCategories: GetCategories,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val displayMode = libraryPreferences.displayMode().asStateFlow()
val gridColumns = libraryPreferences.gridColumns().asStateFlow()
val gridSize = libraryPreferences.gridSize().asStateFlow()
val showAllCategory = libraryPreferences.showAllCategory().asStateFlow()
private val _categories = MutableStateFlow(0)
val categories = _categories.asStateFlow()
val showAllCategory = libraryPreferences.showAllCategory().asStateFlow()
private val _categories = MutableStateFlow(0)
val categories = _categories.asStateFlow()
init {
refreshCategoryCount()
}
fun refreshCategoryCount() {
scope.launch {
_categories.value = getCategories.await(true, onError = { toast(it.message.orEmpty()) })?.size ?: 0
}
}
@Composable
fun getDisplayModeChoices() =
DisplayMode.entries
.associateWith { stringResource(it.res) }
.toImmutableMap()
init {
refreshCategoryCount()
}
fun refreshCategoryCount() {
scope.launch {
_categories.value = getCategories.await(true, onError = { toast(it.message.orEmpty()) })?.size ?: 0
}
}
@Composable
fun getDisplayModeChoices() =
DisplayMode.entries
.associateWith { stringResource(it.res) }
.toImmutableMap()
}
@Composable
fun SettingsLibraryScreenContent(
displayMode: PreferenceMutableStateFlow<DisplayMode>,

View File

@@ -88,75 +88,75 @@ class SettingsReaderScreen : Screen {
}
}
class SettingsReaderViewModel
@Inject
constructor(
readerPreferences: ReaderPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val modes = readerPreferences.modes().asStateFlow()
.map {
it.associateWith { it }
.toImmutableMap()
}
.stateIn(scope, SharingStarted.Eagerly, persistentMapOf())
val selectedMode = readerPreferences.mode().asStateIn(scope)
@Inject
class SettingsReaderViewModel(
readerPreferences: ReaderPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val modes = readerPreferences.modes().asStateFlow()
.map {
it.associateWith { it }
.toImmutableMap()
}
.stateIn(scope, SharingStarted.Eagerly, persistentMapOf())
val selectedMode = readerPreferences.mode().asStateIn(scope)
private val _modeSettings = MutableStateFlow<ImmutableList<StableHolder<ReaderModePreference>>>(
persistentListOf(),
)
val modeSettings = _modeSettings.asStateFlow()
private val _modeSettings = MutableStateFlow<ImmutableList<StableHolder<ReaderModePreference>>>(
persistentListOf(),
)
val modeSettings = _modeSettings.asStateFlow()
init {
modes.onEach { modes ->
val modeSettings = _modeSettings.value
val modesInSettings = modeSettings.map { it.item.mode }
_modeSettings.value = modeSettings.filter { it.item.mode in modes }.toPersistentList() + modes.filter { (it) ->
init {
modes.onEach { modes ->
val modeSettings = _modeSettings.value
val modesInSettings = modeSettings.map { it.item.mode }
_modeSettings.value =
modeSettings.filter { it.item.mode in modes }.toPersistentList() + modes.filter { (it) ->
it !in modesInSettings
}.map { (it) ->
StableHolder(ReaderModePreference(scope, it, readerPreferences.getMode(it)))
}
}.launchIn(scope)
}
fun getDirectionChoices() =
Direction.entries.associateWith { it.res.toPlatformString() }
.toImmutableMap()
fun getPaddingChoices() =
mapOf(
0 to MR.strings.page_padding_none.toPlatformString(),
8 to "8 Dp",
16 to "16 Dp",
32 to "32 Dp",
).toImmutableMap()
fun getMaxSizeChoices(direction: Direction) =
if (direction == Direction.Right || direction == Direction.Left) {
mapOf(
0 to MR.strings.max_size_unrestricted.toPlatformString(),
700 to "700 Dp",
900 to "900 Dp",
1100 to "1100 Dp",
)
} else {
mapOf(
0 to MR.strings.max_size_unrestricted.toPlatformString(),
500 to "500 Dp",
700 to "700 Dp",
900 to "900 Dp",
)
}.toImmutableMap()
fun getImageScaleChoices() =
ImageScale.entries.associateWith { it.res.toPlatformString() }
.toImmutableMap()
fun getNavigationModeChoices() =
NavigationMode.entries.associateWith { it.res.toPlatformString() }
.toImmutableMap()
}.launchIn(scope)
}
fun getDirectionChoices() =
Direction.entries.associateWith { it.res.toPlatformString() }
.toImmutableMap()
fun getPaddingChoices() =
mapOf(
0 to MR.strings.page_padding_none.toPlatformString(),
8 to "8 Dp",
16 to "16 Dp",
32 to "32 Dp",
).toImmutableMap()
fun getMaxSizeChoices(direction: Direction) =
if (direction == Direction.Right || direction == Direction.Left) {
mapOf(
0 to MR.strings.max_size_unrestricted.toPlatformString(),
700 to "700 Dp",
900 to "900 Dp",
1100 to "1100 Dp",
)
} else {
mapOf(
0 to MR.strings.max_size_unrestricted.toPlatformString(),
500 to "500 Dp",
700 to "700 Dp",
900 to "900 Dp",
)
}.toImmutableMap()
fun getImageScaleChoices() =
ImageScale.entries.associateWith { it.res.toPlatformString() }
.toImmutableMap()
fun getNavigationModeChoices() =
NavigationMode.entries.associateWith { it.res.toPlatformString() }
.toImmutableMap()
}
data class ReaderModePreference(
val scope: CoroutineScope,
val mode: String,

View File

@@ -191,7 +191,8 @@ class ServerSettings(
getSetting = { it.backupTime },
getInput = { SetSettingsInput(backupTime = it) },
)
// val basicAuthEnabled = getServerFlow(
// val basicAuthEnabled = getServerFlow(
// getSetting = { it.basicAuthEnabled },
// getInput = { SetSettingsInput(basicAuthEnabled = it) },
// )
@@ -263,7 +264,8 @@ class ServerSettings(
getSetting = { it.globalUpdateInterval.toString() },
getInput = { SetSettingsInput(globalUpdateInterval = it.toDoubleOrNull()?.takeIf { it !in 0.01..5.99 }) },
)
// val gqlDebugLogsEnabled = getServerFlow(
// val gqlDebugLogsEnabled = getServerFlow(
// getSetting = { it.gqlDebugLogsEnabled },
// getInput = { SetSettingsInput(gqlDebugLogsEnabled = it) },
// )
@@ -360,67 +362,66 @@ class ServerSettings(
)
}
class SettingsServerViewModel
@Inject
constructor(
private val getSettings: GetSettings,
private val setSettings: SetSettings,
serverPreferences: ServerPreferences,
serverHostPreferences: ServerHostPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val serverUrl = serverPreferences.server().asStateIn(scope)
val serverPort = serverPreferences.port().asStringStateIn(scope)
val serverPathPrefix = serverPreferences.pathPrefix().asStateIn(scope)
@Inject
class SettingsServerViewModel(
private val getSettings: GetSettings,
private val setSettings: SetSettings,
serverPreferences: ServerPreferences,
serverHostPreferences: ServerHostPreferences,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val serverUrl = serverPreferences.server().asStateIn(scope)
val serverPort = serverPreferences.port().asStringStateIn(scope)
val serverPathPrefix = serverPreferences.pathPrefix().asStateIn(scope)
val proxy = serverPreferences.proxy().asStateIn(scope)
val proxy = serverPreferences.proxy().asStateIn(scope)
val host = serverHostPreferences.host().asStateIn(scope)
val host = serverHostPreferences.host().asStateIn(scope)
@Composable
fun getProxyChoices(): ImmutableMap<Proxy, String> =
persistentMapOf(
Proxy.NO_PROXY to stringResource(MR.strings.no_proxy),
Proxy.HTTP_PROXY to stringResource(MR.strings.http_proxy),
Proxy.SOCKS_PROXY to stringResource(MR.strings.socks_proxy),
)
@Composable
fun getProxyChoices(): ImmutableMap<Proxy, String> =
persistentMapOf(
Proxy.NO_PROXY to stringResource(MR.strings.no_proxy),
Proxy.HTTP_PROXY to stringResource(MR.strings.http_proxy),
Proxy.SOCKS_PROXY to stringResource(MR.strings.socks_proxy),
)
val httpHost = serverPreferences.proxyHttpHost().asStateIn(scope)
val httpPort = serverPreferences.proxyHttpPort().asStringStateIn(scope)
val socksHost = serverPreferences.proxySocksHost().asStateIn(scope)
val socksPort = serverPreferences.proxySocksPort().asStringStateIn(scope)
val httpHost = serverPreferences.proxyHttpHost().asStateIn(scope)
val httpPort = serverPreferences.proxyHttpPort().asStringStateIn(scope)
val socksHost = serverPreferences.proxySocksHost().asStateIn(scope)
val socksPort = serverPreferences.proxySocksPort().asStringStateIn(scope)
val auth = serverPreferences.auth().asStateIn(scope)
val auth = serverPreferences.auth().asStateIn(scope)
@Composable
fun getAuthChoices(): ImmutableMap<Auth, String> =
persistentMapOf(
Auth.NONE to stringResource(MR.strings.no_auth),
Auth.BASIC to stringResource(MR.strings.basic_auth),
Auth.DIGEST to stringResource(MR.strings.digest_auth),
)
@Composable
fun getAuthChoices(): ImmutableMap<Auth, String> =
persistentMapOf(
Auth.NONE to stringResource(MR.strings.no_auth),
Auth.BASIC to stringResource(MR.strings.basic_auth),
Auth.DIGEST to stringResource(MR.strings.digest_auth),
)
val authUsername = serverPreferences.authUsername().asStateIn(scope)
val authPassword = serverPreferences.authPassword().asStateIn(scope)
val authUsername = serverPreferences.authUsername().asStateIn(scope)
val authPassword = serverPreferences.authPassword().asStateIn(scope)
private val _serverSettings = MutableStateFlow<ServerSettings?>(null)
val serverSettings = _serverSettings.asStateFlow()
private val _serverSettings = MutableStateFlow<ServerSettings?>(null)
val serverSettings = _serverSettings.asStateFlow()
init {
scope.launchIO {
val initialSettings = getSettings.await(onError = { toast(it.message.orEmpty()) })
if (initialSettings != null) {
_serverSettings.value = ServerSettings(
getSettings,
setSettings,
scope,
initialSettings,
onError = { toast(it) },
)
}
init {
scope.launchIO {
val initialSettings = getSettings.await(onError = { toast(it.message.orEmpty()) })
if (initialSettings != null) {
_serverSettings.value = ServerSettings(
getSettings,
setSettings,
scope,
initialSettings,
onError = { toast(it) },
)
}
}
}
}
@Composable
fun SettingsServerScreenContent(
@@ -886,7 +887,7 @@ private val repoRegex =
(
"https:\\/\\/(?>www\\.|raw\\.)?(github|githubusercontent)\\.com" +
"\\/([^\\/]+)\\/([^\\/]+)(?>(?>\\/tree|\\/blob)?\\/([^\\/\\n]*))?(?>\\/([^\\/\\n]*\\.json)?)?"
).toRegex()
).toRegex()
@Composable
fun ExtensionReposDialog(

View File

@@ -43,129 +43,128 @@ import me.tatarka.inject.annotations.Assisted
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class GlobalSearchViewModel
@Inject
constructor(
private val getSourceList: GetSourceList,
private val getSearchManga: GetSearchManga,
catalogPreferences: CatalogPreferences,
contextWrapper: ContextWrapper,
@Assisted private val savedStateHandle: SavedStateHandle,
@Assisted params: Params,
) : ViewModel(contextWrapper) {
private val _query by savedStateHandle.getStateFlow { params.initialQuery }
val query = _query.asStateFlow()
@Inject
class GlobalSearchViewModel(
private val getSourceList: GetSourceList,
private val getSearchManga: GetSearchManga,
catalogPreferences: CatalogPreferences,
contextWrapper: ContextWrapper,
@Assisted private val savedStateHandle: SavedStateHandle,
@Assisted params: Params,
) : ViewModel(contextWrapper) {
private val _query by savedStateHandle.getStateFlow { params.initialQuery }
val query = _query.asStateFlow()
private val installedSources = MutableStateFlow(emptyList<Source>())
private val installedSources = MutableStateFlow(emptyList<Source>())
private val languages = catalogPreferences.languages().stateIn(scope)
val displayMode = catalogPreferences.displayMode().stateIn(scope)
private val languages = catalogPreferences.languages().stateIn(scope)
val displayMode = catalogPreferences.displayMode().stateIn(scope)
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
val sources = combine(installedSources, languages) { installedSources, languages ->
installedSources.filter {
it.lang in languages || it.id == Source.LOCAL_SOURCE_ID
}.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val sources = combine(installedSources, languages) { installedSources, languages ->
installedSources.filter {
it.lang in languages || it.id == Source.LOCAL_SOURCE_ID
}.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val search by savedStateHandle.getStateFlow { params.initialQuery }
private val search by savedStateHandle.getStateFlow { params.initialQuery }
val results = SnapshotStateMap<Long, Search>()
val results = SnapshotStateMap<Long, Search>()
init {
getSources()
readySearch()
}
init {
getSources()
readySearch()
}
private fun getSources() {
getSourceList.asFlow()
.onEach { sources ->
installedSources.value = sources.sortedWith(
compareBy<Source, String>(String.CASE_INSENSITIVE_ORDER) { it.lang }
.thenBy(String.CASE_INSENSITIVE_ORDER) {
it.name
},
)
_isLoading.value = false
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error getting sources" }
_isLoading.value = false
}
.launchIn(scope)
}
private val semaphore = Semaphore(5)
private fun readySearch() {
search
.combine(sources) { query, sources ->
query to sources
}
.mapLatest { (query, sources) ->
results.clear()
supervisorScope {
sources.map { source ->
async {
semaphore.withPermit {
getSearchManga.asFlow(source, 1, query, null)
.map {
if (it.mangaList.isEmpty()) {
Search.Failure(MR.strings.no_results_found.toPlatformString())
} else {
Search.Success(it.mangaList.toImmutableList())
}
}
.catch {
log.warn(it) { "Error getting search from ${source.displayName}" }
emit(Search.Failure(it))
}
.onEach {
results[source.id] = it
}
.collect()
}
}
}.awaitAll()
}
}
.catch {
log.warn(it) { "Error getting sources" }
}
.flowOn(Dispatchers.IO)
.launchIn(scope)
}
fun setQuery(query: String) {
_query.value = query
}
fun startSearch(query: String) {
search.value = query
}
data class Params(
val initialQuery: String,
)
sealed class Search {
data object Searching : Search()
data class Success(
val mangaList: ImmutableList<Manga>,
) : Search()
data class Failure(
val e: String?,
) : Search() {
constructor(e: Throwable) : this(e.message)
private fun getSources() {
getSourceList.asFlow()
.onEach { sources ->
installedSources.value = sources.sortedWith(
compareBy<Source, String>(String.CASE_INSENSITIVE_ORDER) { it.lang }
.thenBy(String.CASE_INSENSITIVE_ORDER) {
it.name
},
)
_isLoading.value = false
}
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error getting sources" }
_isLoading.value = false
}
.launchIn(scope)
}
private companion object {
private val log = logging()
private val semaphore = Semaphore(5)
private fun readySearch() {
search
.combine(sources) { query, sources ->
query to sources
}
.mapLatest { (query, sources) ->
results.clear()
supervisorScope {
sources.map { source ->
async {
semaphore.withPermit {
getSearchManga.asFlow(source, 1, query, null)
.map {
if (it.mangaList.isEmpty()) {
Search.Failure(MR.strings.no_results_found.toPlatformString())
} else {
Search.Success(it.mangaList.toImmutableList())
}
}
.catch {
log.warn(it) { "Error getting search from ${source.displayName}" }
emit(Search.Failure(it))
}
.onEach {
results[source.id] = it
}
.collect()
}
}
}.awaitAll()
}
}
.catch {
log.warn(it) { "Error getting sources" }
}
.flowOn(Dispatchers.IO)
.launchIn(scope)
}
fun setQuery(query: String) {
_query.value = query
}
fun startSearch(query: String) {
search.value = query
}
data class Params(
val initialQuery: String,
)
sealed class Search {
data object Searching : Search()
data class Success(
val mangaList: ImmutableList<Manga>,
) : Search()
data class Failure(
val e: String?,
) : Search() {
constructor(e: Throwable) : this(e.message)
}
}
private companion object {
private val log = logging()
}
}

View File

@@ -34,100 +34,99 @@ import me.tatarka.inject.annotations.Assisted
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class SourceHomeScreenViewModel
@Inject
constructor(
private val getSourceList: GetSourceList,
catalogPreferences: CatalogPreferences,
contextWrapper: ContextWrapper,
@Assisted private val savedStateHandle: SavedStateHandle,
) : ViewModel(contextWrapper) {
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
@Inject
class SourceHomeScreenViewModel(
private val getSourceList: GetSourceList,
catalogPreferences: CatalogPreferences,
contextWrapper: ContextWrapper,
@Assisted private val savedStateHandle: SavedStateHandle,
) : ViewModel(contextWrapper) {
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
private val installedSources = MutableStateFlow(emptyList<Source>())
private val installedSources = MutableStateFlow(emptyList<Source>())
private val _languages = catalogPreferences.languages().asStateFlow()
val languages = _languages.asStateFlow()
.map { it.toImmutableSet() }
.stateIn(scope, SharingStarted.Eagerly, persistentSetOf())
private val _languages = catalogPreferences.languages().asStateFlow()
val languages = _languages.asStateFlow()
.map { it.toImmutableSet() }
.stateIn(scope, SharingStarted.Eagerly, persistentSetOf())
val sources = combine(installedSources, languages) { installedSources, languages ->
val all = MR.strings.all.toPlatformString()
val other = MR.strings.other.toPlatformString()
installedSources
.distinctBy { it.id }
.filter {
it.lang in languages || it.id == Source.LOCAL_SOURCE_ID
val sources = combine(installedSources, languages) { installedSources, languages ->
val all = MR.strings.all.toPlatformString()
val other = MR.strings.other.toPlatformString()
installedSources
.distinctBy { it.id }
.filter {
it.lang in languages || it.id == Source.LOCAL_SOURCE_ID
}
.groupBy(Source::displayLang)
.mapValues {
it.value.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, Source::name))
.map(SourceUI::SourceItem)
}
.mapKeys { (key) ->
when (key) {
"all" -> all
"other" -> other
else -> Locale(key).displayName
}
.groupBy(Source::displayLang)
.mapValues {
it.value.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, Source::name))
.map(SourceUI::SourceItem)
}
.mapKeys { (key) ->
}
.toList()
.sortedWith(
compareBy<Pair<String, *>> { (key) ->
when (key) {
"all" -> all
"other" -> other
else -> Locale(key).displayName
all -> 1
other -> 3
else -> 2
}
}
.toList()
.sortedWith(
compareBy<Pair<String, *>> { (key) ->
when (key) {
all -> 1
other -> 3
else -> 2
}
}.thenBy(String.CASE_INSENSITIVE_ORDER, Pair<String, *>::first),
)
.flatMap { (key, value) ->
listOf(SourceUI.Header(key)) + value
}
.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
}.thenBy(String.CASE_INSENSITIVE_ORDER, Pair<String, *>::first),
)
.flatMap { (key, value) ->
listOf(SourceUI.Header(key)) + value
}
.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val sourceLanguages = installedSources.map { sources ->
sources.map { it.lang }.distinct().minus(Source.LOCAL_SOURCE_LANG)
.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val sourceLanguages = installedSources.map { sources ->
sources.map { it.lang }.distinct().minus(Source.LOCAL_SOURCE_LANG)
.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val _query by savedStateHandle.getStateFlow { "" }
val query = _query.asStateFlow()
private val _query by savedStateHandle.getStateFlow { "" }
val query = _query.asStateFlow()
init {
getSources()
}
private fun getSources() {
getSourceList.asFlow()
.onEach {
installedSources.value = it
_isLoading.value = false
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error getting sources" }
_isLoading.value = false
}
.launchIn(scope)
}
fun setEnabledLanguages(langs: Set<String>) {
log.info { langs }
_languages.value = langs
}
fun setQuery(query: String) {
_query.value = query
}
private companion object {
private val log = logging()
}
init {
getSources()
}
private fun getSources() {
getSourceList.asFlow()
.onEach {
installedSources.value = it
_isLoading.value = false
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error getting sources" }
_isLoading.value = false
}
.launchIn(scope)
}
fun setEnabledLanguages(langs: Set<String>) {
log.info { langs }
_languages.value = langs
}
fun setQuery(query: String) {
_query.value = query
}
private companion object {
private val log = logging()
}
}
@Stable
sealed class SourceUI {
@Stable

View File

@@ -28,65 +28,64 @@ import me.tatarka.inject.annotations.Assisted
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class SourceSettingsScreenViewModel
@Inject
constructor(
private val getSourceSettings: GetSourceSettings,
private val setSourceSetting: SetSourceSetting,
contextWrapper: ContextWrapper,
@Assisted private val params: Params,
) : ViewModel(contextWrapper) {
private val _loading = MutableStateFlow(true)
val loading = _loading.asStateFlow()
@Inject
class SourceSettingsScreenViewModel(
private val getSourceSettings: GetSourceSettings,
private val setSourceSetting: SetSourceSetting,
contextWrapper: ContextWrapper,
@Assisted private val params: Params,
) : ViewModel(contextWrapper) {
private val _loading = MutableStateFlow(true)
val loading = _loading.asStateFlow()
private val _sourceSettings = MutableStateFlow<ImmutableList<SourceSettingsView<*, *>>>(persistentListOf())
val sourceSettings = _sourceSettings.asStateFlow()
private val _sourceSettings = MutableStateFlow<ImmutableList<SourceSettingsView<*, *>>>(persistentListOf())
val sourceSettings = _sourceSettings.asStateFlow()
init {
getSourceSettings()
sourceSettings.mapLatest { settings ->
supervisorScope {
settings.forEach { setting ->
setting.state.drop(1)
.filterNotNull()
.onEach {
setSourceSetting.await(
sourceId = params.sourceId,
setting.props,
onError = { toast(it.message.orEmpty()) },
)
getSourceSettings()
}
.launchIn(this)
}
init {
getSourceSettings()
sourceSettings.mapLatest { settings ->
supervisorScope {
settings.forEach { setting ->
setting.state.drop(1)
.filterNotNull()
.onEach {
setSourceSetting.await(
sourceId = params.sourceId,
setting.props,
onError = { toast(it.message.orEmpty()) },
)
getSourceSettings()
}
.launchIn(this)
}
}.launchIn(scope)
}
private fun getSourceSettings() {
getSourceSettings.asFlow(params.sourceId)
.onEach {
_sourceSettings.value = it.toView()
_loading.value = false
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error setting source setting" }
_loading.value = false
}
.launchIn(scope)
}
data class Params(
val sourceId: Long,
)
private fun List<SourcePreference>.toView() =
mapIndexed { index, sourcePreference ->
SourceSettingsView(index, sourcePreference)
}.toImmutableList()
private companion object {
private val log = logging()
}
}
}.launchIn(scope)
}
private fun getSourceSettings() {
getSourceSettings.asFlow(params.sourceId)
.onEach {
_sourceSettings.value = it.toView()
_loading.value = false
}
.catch {
toast(it.message.orEmpty())
log.warn(it) { "Error setting source setting" }
_loading.value = false
}
.launchIn(scope)
}
data class Params(
val sourceId: Long,
)
private fun List<SourcePreference>.toView() =
mapIndexed { index, sourcePreference ->
SourceSettingsView(index, sourcePreference)
}.toImmutableList()
private companion object {
private val log = logging()
}
}

View File

@@ -40,204 +40,203 @@ import kotlinx.coroutines.launch
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class UpdatesScreenViewModel
@Inject
constructor(
private val queueChapterDownload: QueueChapterDownload,
private val stopChapterDownload: StopChapterDownload,
private val deleteChapterDownload: DeleteChapterDownload,
private val getRecentUpdates: GetRecentUpdates,
private val updateChapter: UpdateChapter,
private val batchChapterDownload: BatchChapterDownload,
private val updateLibrary: UpdateLibrary,
private val updatesPager: UpdatesPager,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
@Inject
class UpdatesScreenViewModel(
private val queueChapterDownload: QueueChapterDownload,
private val stopChapterDownload: StopChapterDownload,
private val deleteChapterDownload: DeleteChapterDownload,
private val getRecentUpdates: GetRecentUpdates,
private val updateChapter: UpdateChapter,
private val batchChapterDownload: BatchChapterDownload,
private val updateLibrary: UpdateLibrary,
private val updatesPager: UpdatesPager,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
private val _isLoading = MutableStateFlow(true)
val isLoading = _isLoading.asStateFlow()
val updates = updatesPager.updates.map { updates ->
updates.map {
when (it) {
is UpdatesPager.Updates.Date -> UpdatesUI.Header(it.date)
is UpdatesPager.Updates.Update -> UpdatesUI.Item(ChapterDownloadItem(it.manga, it.chapter))
val updates = updatesPager.updates.map { updates ->
updates.map {
when (it) {
is UpdatesPager.Updates.Date -> UpdatesUI.Header(it.date)
is UpdatesPager.Updates.Update -> UpdatesUI.Item(ChapterDownloadItem(it.manga, it.chapter))
}
}.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val _selectedIds = MutableStateFlow<ImmutableList<Long>>(persistentListOf())
val selectedItems = combine(updates, _selectedIds) { updates, selectedItems ->
updates.asSequence()
.filterIsInstance<UpdatesUI.Item>()
.filter { it.chapterDownloadItem.isSelected(selectedItems) }
.map { it.chapterDownloadItem }
.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val inActionMode = _selectedIds.map { it.isNotEmpty() }
.stateIn(scope, SharingStarted.Eagerly, false)
init {
updatesPager.loadNextPage(
onComplete = {
_isLoading.value = false
},
onError = {
toast(it.message.orEmpty())
},
)
updates
.map { updates ->
updates.filterIsInstance<UpdatesUI.Item>().mapNotNull {
it.chapterDownloadItem.manga?.id
}.toSet()
}
.combine(DownloadService.downloadQueue) { mangaIds, queue ->
mangaIds to queue
}
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.onEach { (mangaIds, queue) ->
val chapters = queue.filter { it.mangaId in mangaIds }
updates.value.filterIsInstance<UpdatesUI.Item>().forEach {
it.chapterDownloadItem.updateFrom(chapters)
}
}.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val _selectedIds = MutableStateFlow<ImmutableList<Long>>(persistentListOf())
val selectedItems = combine(updates, _selectedIds) { updates, selectedItems ->
updates.asSequence()
.filterIsInstance<UpdatesUI.Item>()
.filter { it.chapterDownloadItem.isSelected(selectedItems) }
.map { it.chapterDownloadItem }
.toImmutableList()
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
val inActionMode = _selectedIds.map { it.isNotEmpty() }
.stateIn(scope, SharingStarted.Eagerly, false)
init {
updatesPager.loadNextPage(
onComplete = {
_isLoading.value = false
},
onError = {
toast(it.message.orEmpty())
},
)
updates
.map { updates ->
updates.filterIsInstance<UpdatesUI.Item>().mapNotNull {
it.chapterDownloadItem.manga?.id
}.toSet()
}
.combine(DownloadService.downloadQueue) { mangaIds, queue ->
mangaIds to queue
}
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.onEach { (mangaIds, queue) ->
val chapters = queue.filter { it.mangaId in mangaIds }
updates.value.filterIsInstance<UpdatesUI.Item>().forEach {
it.chapterDownloadItem.updateFrom(chapters)
}
}
.flowOn(Dispatchers.Default)
.launchIn(scope)
}
fun loadNextPage() {
updatesPager.loadNextPage(
onError = {
toast(it.message.orEmpty())
},
)
}
private fun setRead(
chapterIds: List<Long>,
read: Boolean,
) {
scope.launch {
updateChapter.await(chapterIds, read = read, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
}
}
.flowOn(Dispatchers.Default)
.launchIn(scope)
}
fun markRead(id: Long?) = setRead(listOfNotNull(id).ifEmpty { _selectedIds.value }, true)
fun loadNextPage() {
updatesPager.loadNextPage(
onError = {
toast(it.message.orEmpty())
},
)
}
fun markUnread(id: Long?) = setRead(listOfNotNull(id).ifEmpty { _selectedIds.value }, false)
private fun setBookmarked(
chapterIds: List<Long>,
bookmark: Boolean,
) {
scope.launch {
updateChapter.await(chapterIds, bookmarked = bookmark, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
}
}
fun bookmarkChapter(id: Long?) = setBookmarked(listOfNotNull(id).ifEmpty { _selectedIds.value }, true)
fun unBookmarkChapter(id: Long?) = setBookmarked(listOfNotNull(id).ifEmpty { _selectedIds.value }, false)
fun downloadChapter(chapter: Chapter?) {
scope.launch {
if (chapter == null) {
val selectedIds = _selectedIds.value
batchChapterDownload.await(selectedIds, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
return@launch
}
queueChapterDownload.await(chapter.id, onError = { toast(it.message.orEmpty()) })
}
}
fun deleteDownloadedChapter(chapter: Chapter?) {
scope.launchDefault {
if (chapter == null) {
val selectedIds = _selectedIds.value
deleteChapterDownload.await(selectedIds, onError = { toast(it.message.orEmpty()) })
selectedItems.value.forEach {
it.setNotDownloaded()
}
_selectedIds.value = persistentListOf()
return@launchDefault
}
updates.value
.filterIsInstance<UpdatesUI.Item>()
.find { (chapterDownloadItem) ->
chapterDownloadItem.chapter.mangaId == chapter.mangaId &&
chapterDownloadItem.chapter.index == chapter.index
}
?.chapterDownloadItem
?.deleteDownload(deleteChapterDownload)
}
}
fun stopDownloadingChapter(chapter: Chapter) {
scope.launchDefault {
updates.value
.filterIsInstance<UpdatesUI.Item>()
.find { (chapterDownloadItem) ->
chapterDownloadItem.chapter.mangaId == chapter.mangaId &&
chapterDownloadItem.chapter.index == chapter.index
}
?.chapterDownloadItem
?.stopDownloading(stopChapterDownload)
}
}
fun selectAll() {
scope.launchDefault {
_selectedIds.value = updates.value.filterIsInstance<UpdatesUI.Item>()
.map { it.chapterDownloadItem.chapter.id }
.toImmutableList()
}
}
fun invertSelection() {
scope.launchDefault {
_selectedIds.value = updates.value.filterIsInstance<UpdatesUI.Item>()
.map { it.chapterDownloadItem.chapter.id }
.minus(_selectedIds.value)
.toImmutableList()
}
}
fun selectChapter(id: Long) {
scope.launchDefault {
_selectedIds.value = _selectedIds.value.plus(id).toImmutableList()
}
}
fun unselectChapter(id: Long) {
scope.launchDefault {
_selectedIds.value = _selectedIds.value.minus(id).toImmutableList()
}
}
fun clearSelection() {
scope.launchDefault {
_selectedIds.value = persistentListOf()
}
}
fun updateLibrary() {
scope.launchDefault { updateLibrary.await(onError = { toast(it.message.orEmpty()) }) }
}
override fun onDispose() {
super.onDispose()
updatesPager.cancel()
}
private companion object {
private val log = logging()
private fun setRead(
chapterIds: List<Long>,
read: Boolean,
) {
scope.launch {
updateChapter.await(chapterIds, read = read, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
}
}
fun markRead(id: Long?) = setRead(listOfNotNull(id).ifEmpty { _selectedIds.value }, true)
fun markUnread(id: Long?) = setRead(listOfNotNull(id).ifEmpty { _selectedIds.value }, false)
private fun setBookmarked(
chapterIds: List<Long>,
bookmark: Boolean,
) {
scope.launch {
updateChapter.await(chapterIds, bookmarked = bookmark, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
}
}
fun bookmarkChapter(id: Long?) = setBookmarked(listOfNotNull(id).ifEmpty { _selectedIds.value }, true)
fun unBookmarkChapter(id: Long?) = setBookmarked(listOfNotNull(id).ifEmpty { _selectedIds.value }, false)
fun downloadChapter(chapter: Chapter?) {
scope.launch {
if (chapter == null) {
val selectedIds = _selectedIds.value
batchChapterDownload.await(selectedIds, onError = { toast(it.message.orEmpty()) })
_selectedIds.value = persistentListOf()
return@launch
}
queueChapterDownload.await(chapter.id, onError = { toast(it.message.orEmpty()) })
}
}
fun deleteDownloadedChapter(chapter: Chapter?) {
scope.launchDefault {
if (chapter == null) {
val selectedIds = _selectedIds.value
deleteChapterDownload.await(selectedIds, onError = { toast(it.message.orEmpty()) })
selectedItems.value.forEach {
it.setNotDownloaded()
}
_selectedIds.value = persistentListOf()
return@launchDefault
}
updates.value
.filterIsInstance<UpdatesUI.Item>()
.find { (chapterDownloadItem) ->
chapterDownloadItem.chapter.mangaId == chapter.mangaId &&
chapterDownloadItem.chapter.index == chapter.index
}
?.chapterDownloadItem
?.deleteDownload(deleteChapterDownload)
}
}
fun stopDownloadingChapter(chapter: Chapter) {
scope.launchDefault {
updates.value
.filterIsInstance<UpdatesUI.Item>()
.find { (chapterDownloadItem) ->
chapterDownloadItem.chapter.mangaId == chapter.mangaId &&
chapterDownloadItem.chapter.index == chapter.index
}
?.chapterDownloadItem
?.stopDownloading(stopChapterDownload)
}
}
fun selectAll() {
scope.launchDefault {
_selectedIds.value = updates.value.filterIsInstance<UpdatesUI.Item>()
.map { it.chapterDownloadItem.chapter.id }
.toImmutableList()
}
}
fun invertSelection() {
scope.launchDefault {
_selectedIds.value = updates.value.filterIsInstance<UpdatesUI.Item>()
.map { it.chapterDownloadItem.chapter.id }
.minus(_selectedIds.value)
.toImmutableList()
}
}
fun selectChapter(id: Long) {
scope.launchDefault {
_selectedIds.value = _selectedIds.value.plus(id).toImmutableList()
}
}
fun unselectChapter(id: Long) {
scope.launchDefault {
_selectedIds.value = _selectedIds.value.minus(id).toImmutableList()
}
}
fun clearSelection() {
scope.launchDefault {
_selectedIds.value = persistentListOf()
}
}
fun updateLibrary() {
scope.launchDefault { updateLibrary.await(onError = { toast(it.message.orEmpty()) }) }
}
override fun onDispose() {
super.onDispose()
updatesPager.cancel()
}
private companion object {
private val log = logging()
}
}
sealed class UpdatesUI {
data class Item(
val chapterDownloadItem: ChapterDownloadItem,

View File

@@ -18,26 +18,26 @@ import kotlinx.coroutines.flow.shareIn
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
@Inject
class TrayViewModel
@Inject
constructor(
updateChecker: UpdateChecker,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
override val scope = MainScope()
constructor(
updateChecker: UpdateChecker,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
override val scope = MainScope()
val updateFound = updateChecker
.asFlow(false)
.catch { log.warn(it) { "Failed to check for updates" } }
.filterIsInstance<UpdateChecker.Update.UpdateFound>()
.shareIn(scope, SharingStarted.Eagerly, 1)
val updateFound = updateChecker
.asFlow(false)
.catch { log.warn(it) { "Failed to check for updates" } }
.filterIsInstance<UpdateChecker.Update.UpdateFound>()
.shareIn(scope, SharingStarted.Eagerly, 1)
override fun onDispose() {
super.onDispose()
scope.cancel()
}
companion object {
private val log = logging()
}
override fun onDispose() {
super.onDispose()
scope.cancel()
}
companion object {
private val log = logging()
}
}

View File

@@ -66,72 +66,72 @@ actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostVie
}
}
@Inject
actual class SettingsServerHostViewModel
@Inject
constructor(
serverPreferences: ServerPreferences,
serverHostPreferences: ServerHostPreferences,
private val serverService: ServerService,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val host = serverHostPreferences.host().asStateIn(scope)
constructor(
serverPreferences: ServerPreferences,
serverHostPreferences: ServerHostPreferences,
private val serverService: ServerService,
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
val host = serverHostPreferences.host().asStateIn(scope)
// IP
val ip = serverHostPreferences.ip().asStateIn(scope)
val port = serverHostPreferences.port().asStringStateIn(scope)
// IP
val ip = serverHostPreferences.ip().asStateIn(scope)
val port = serverHostPreferences.port().asStringStateIn(scope)
// Root
val rootPath = serverHostPreferences.rootPath().asStateIn(scope)
// Root
val rootPath = serverHostPreferences.rootPath().asStateIn(scope)
// Downloader
val downloadPath = serverHostPreferences.downloadPath().asStateIn(scope)
// Downloader
val downloadPath = serverHostPreferences.downloadPath().asStateIn(scope)
// Backup
val backupPath = serverHostPreferences.backupPath().asStateIn(scope)
// Backup
val backupPath = serverHostPreferences.backupPath().asStateIn(scope)
// LocalSource
val localSourcePath = serverHostPreferences.localSourcePath().asStateIn(scope)
// LocalSource
val localSourcePath = serverHostPreferences.localSourcePath().asStateIn(scope)
// Authentication
val basicAuthEnabled = serverHostPreferences.basicAuthEnabled().asStateIn(scope)
val basicAuthUsername = serverHostPreferences.basicAuthUsername().asStateIn(scope)
val basicAuthPassword = serverHostPreferences.basicAuthPassword().asStateIn(scope)
// Authentication
val basicAuthEnabled = serverHostPreferences.basicAuthEnabled().asStateIn(scope)
val basicAuthUsername = serverHostPreferences.basicAuthUsername().asStateIn(scope)
val basicAuthPassword = serverHostPreferences.basicAuthPassword().asStateIn(scope)
private val _serverSettingChanged = MutableStateFlow(false)
val serverSettingChanged = _serverSettingChanged.asStateFlow()
private val _serverSettingChanged = MutableStateFlow(false)
val serverSettingChanged = _serverSettingChanged.asStateFlow()
fun serverSettingChanged() {
_serverSettingChanged.value = true
}
fun serverSettingChanged() {
_serverSettingChanged.value = true
}
fun restartServer() {
if (serverSettingChanged.value) {
serverService.startServer()
}
}
// Handle password connection to hosted server
val auth = serverPreferences.auth().asStateIn(scope)
val authUsername = serverPreferences.authUsername().asStateIn(scope)
val authPassword = serverPreferences.authPassword().asStateIn(scope)
init {
combine(host, basicAuthEnabled, basicAuthUsername, basicAuthPassword) { host, enabled, username, password ->
if (host) {
if (enabled) {
auth.value = Auth.BASIC
authUsername.value = username
authPassword.value = password
} else {
auth.value = Auth.NONE
authUsername.value = ""
authPassword.value = ""
}
}
}.launchIn(scope)
fun restartServer() {
if (serverSettingChanged.value) {
serverService.startServer()
}
}
// Handle password connection to hosted server
val auth = serverPreferences.auth().asStateIn(scope)
val authUsername = serverPreferences.authUsername().asStateIn(scope)
val authPassword = serverPreferences.authPassword().asStateIn(scope)
init {
combine(host, basicAuthEnabled, basicAuthUsername, basicAuthPassword) { host, enabled, username, password ->
if (host) {
if (enabled) {
auth.value = Auth.BASIC
authUsername.value = username
authPassword.value = password
} else {
auth.value = Auth.NONE
authUsername.value = ""
authPassword.value = ""
}
}
}.launchIn(scope)
}
}
fun LazyListScope.ServerHostItems(
hostValue: Boolean,
basicAuthEnabledValue: Boolean,

View File

@@ -11,12 +11,12 @@ import ca.gosyer.jui.uicore.vm.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import me.tatarka.inject.annotations.Inject
@Inject
actual class DebugOverlayViewModel
@Inject
constructor(
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
actual val maxMemory: String
get() = ""
actual val usedMemoryFlow: MutableStateFlow<String> = MutableStateFlow("")
}
constructor(
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
actual val maxMemory: String
get() = ""
actual val usedMemoryFlow: MutableStateFlow<String> = MutableStateFlow("")
}

View File

@@ -15,8 +15,8 @@ import me.tatarka.inject.annotations.Inject
@Composable
actual fun getServerHostItems(viewModel: @Composable () -> SettingsServerHostViewModel): LazyListScope.() -> Unit = {}
@Inject
actual class SettingsServerHostViewModel
@Inject
constructor(
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper)
constructor(
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper)

View File

@@ -16,36 +16,36 @@ import kotlinx.coroutines.launch
import me.tatarka.inject.annotations.Inject
import kotlin.time.Duration.Companion.milliseconds
@Inject
actual class DebugOverlayViewModel
@Inject
constructor(
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
override val scope = MainScope()
constructor(
contextWrapper: ContextWrapper,
) : ViewModel(contextWrapper) {
override val scope = MainScope()
val runtime: Runtime = Runtime.getRuntime()
actual val maxMemory = runtime.maxMemory().formatSize()
actual val usedMemoryFlow = MutableStateFlow(runtime.usedMemory().formatSize())
val runtime: Runtime = Runtime.getRuntime()
actual val maxMemory = runtime.maxMemory().formatSize()
actual val usedMemoryFlow = MutableStateFlow(runtime.usedMemory().formatSize())
init {
scope.launch {
while (true) {
usedMemoryFlow.value = runtime.usedMemory().formatSize()
delay(100.milliseconds)
}
init {
scope.launch {
while (true) {
usedMemoryFlow.value = runtime.usedMemory().formatSize()
delay(100.milliseconds)
}
}
private fun Long.formatSize(): String {
if (this < 1024) return "$this B"
val z = (63 - java.lang.Long.numberOfLeadingZeros(this)) / 10
return String.format("%.1f %sB", toDouble() / (1L shl z * 10), " KMGTPE"[z])
}
private fun Runtime.usedMemory(): Long = totalMemory() - freeMemory()
override fun onDispose() {
super.onDispose()
scope.cancel()
}
}
private fun Long.formatSize(): String {
if (this < 1024) return "$this B"
val z = (63 - java.lang.Long.numberOfLeadingZeros(this)) / 10
return String.format("%.1f %sB", toDouble() / (1L shl z * 10), " KMGTPE"[z])
}
private fun Runtime.usedMemory(): Long = totalMemory() - freeMemory()
override fun onDispose() {
super.onDispose()
scope.cancel()
}
}