Implement Downloads GQL and fix url changes requiring restarts.

This commit is contained in:
Syer10
2025-10-14 14:19:47 -04:00
parent bef31a01eb
commit 5b7228c687
41 changed files with 688 additions and 229 deletions

View File

@@ -23,17 +23,12 @@ import ca.gosyer.jui.core.prefs.getAsFlow
import ca.gosyer.jui.domain.base.WebsocketService.Actions
import ca.gosyer.jui.domain.base.WebsocketService.Status
import ca.gosyer.jui.domain.download.model.DownloadState
import ca.gosyer.jui.domain.download.model.DownloadStatus
import ca.gosyer.jui.domain.download.service.DownloadService
import ca.gosyer.jui.domain.download.service.DownloadService.Companion.status
import ca.gosyer.jui.i18n.MR
import com.diamondedge.logging.logging
import dev.icerock.moko.resources.desc.desc
import dev.icerock.moko.resources.format
import io.ktor.client.plugins.websocket.ws
import io.ktor.http.URLProtocol
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -42,13 +37,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.job
import kotlinx.serialization.json.Json
import java.util.regex.Pattern
@@ -154,31 +145,12 @@ class AndroidDownloadService : Service() {
throw CancellationException()
}
runCatching {
client.ws(
host = serverUrl.host,
port = serverUrl.port,
path = serverUrl.encodedPath + "/api/v1/downloads",
request = {
if (serverUrl.protocol == URLProtocol.HTTPS) {
url.protocol = URLProtocol.WSS
}
},
) {
errorConnectionCount = 0
status.value = Status.RUNNING
send(Frame.Text("STATUS"))
incoming.receiveAsFlow()
.filterIsInstance<Frame.Text>()
.map { json.decodeFromString<DownloadStatus>(it.readText()) }
.distinctUntilChanged()
.drop(1)
.mapLatest(::onReceived)
.catch {
log.warn(it) { "Error running downloader" }
appComponent.downloadService
.getSubscription()
.onEach {
onReceived()
}
.collect()
}
}.throwIfCancellation().isFailure.let {
status.value = Status.STARTING
if (it) errorConnectionCount++
@@ -193,13 +165,11 @@ class AndroidDownloadService : Service() {
.launchIn(ioScope)
}
private fun onReceived(status: DownloadStatus) {
DownloadService.downloaderStatus.value = status.status
DownloadService.downloadQueue.value = status.queue
val downloadingChapter = status.queue.lastOrNull { it.state == DownloadState.Downloading }
private fun onReceived() {
val downloadingChapter = DownloadService.downloadQueue.value.lastOrNull { it.state == DownloadState.DOWNLOADING }
if (downloadingChapter != null) {
val notification = with(progressNotificationBuilder) {
val max = downloadingChapter.chapter.pageCount ?: 0
val max = downloadingChapter.chapter.pageCount
val current = (max * downloadingChapter.progress).toInt().coerceIn(0, max)
setProgress(max, current, false)

View File

@@ -39,3 +39,29 @@ mutation EnqueueChapterDownloads($ids: [Int!]!) {
clientMutationId
}
}
subscription DownloadStatusChanged($maxUpdates: Int = 30) {
downloadStatusChanged(input: { maxUpdates: $maxUpdates }) {
initial {
...DownloadFragment
}
updates {
type
download {
...DownloadFragment
}
}
state
omittedUpdates
}
}
query DownloadStatus {
downloadStatus {
state
queue {
...DownloadFragment
}
}
}

View File

@@ -0,0 +1,17 @@
fragment DownloadFragment on DownloadType {
chapter {
id
name
pageCount
}
manga {
id
title
thumbnailUrl
thumbnailUrlLastFetched
}
position
progress
state
tries
}

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data
import ca.gosyer.jui.core.di.AppScope
import ca.gosyer.jui.data.backup.BackupRepositoryImpl
import ca.gosyer.jui.data.category.CategoryRepositoryImpl
import ca.gosyer.jui.data.chapter.ChapterRepositoryImpl
@@ -31,100 +32,125 @@ import ca.gosyer.jui.domain.settings.service.SettingsRepository
import ca.gosyer.jui.domain.source.service.SourceRepository
import ca.gosyer.jui.domain.updates.service.UpdatesRepository
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.annotations.ApolloExperimental
import com.apollographql.apollo.network.ws.GraphQLWsProtocol
import com.apollographql.ktor.ktorClient
import io.ktor.client.HttpClient
import io.ktor.http.URLBuilder
import io.ktor.http.Url
import io.ktor.http.appendPathSegments
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import me.tatarka.inject.annotations.Provides
interface DataComponent : SharedDataComponent {
@Provides
fun apolloClient(
http: Http,
serverPreferences: ServerPreferences,
) = ApolloClient.Builder()
.serverUrl(
URLBuilder(serverPreferences.serverUrl().get())
typealias ApolloAppClient = StateFlow<ApolloClient>
private fun getApolloClient(
httpClient: HttpClient,
serverUrl: Url,
): ApolloClient {
val url = URLBuilder(serverUrl)
.appendPathSegments("api", "graphql")
.buildString(),
)
.ktorClient(http)
.wsProtocol(GraphQLWsProtocol.Factory())
.buildString()
return ApolloClient.Builder()
.serverUrl(url)
.ktorClient(httpClient)
.wsProtocol(GraphQLWsProtocol.Factory(pingIntervalMillis = 30))
.dispatcher(Dispatchers.IO)
.build()
}
interface DataComponent : SharedDataComponent {
@OptIn(ApolloExperimental::class)
@Provides
@AppScope
fun apolloAppClient(
http: Http,
serverPreferences: ServerPreferences,
): ApolloAppClient =
http
.map { getApolloClient(it, serverPreferences.serverUrl().get()) }
.stateIn(
GlobalScope,
SharingStarted.Eagerly,
getApolloClient(http.value, serverPreferences.serverUrl().get()),
)
@Provides
fun settingsRepository(apolloClient: ApolloClient): SettingsRepository = SettingsRepositoryImpl(apolloClient)
fun settingsRepository(apolloAppClient: ApolloAppClient): SettingsRepository = SettingsRepositoryImpl(apolloAppClient)
@Provides
fun categoryRepository(
apolloClient: ApolloClient,
apolloAppClient: ApolloAppClient,
http: Http,
serverPreferences: ServerPreferences,
): CategoryRepository = CategoryRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
): CategoryRepository = CategoryRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides
fun chapterRepository(
apolloClient: ApolloClient,
apolloAppClient: ApolloAppClient,
http: Http,
serverPreferences: ServerPreferences,
): ChapterRepository = ChapterRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
): ChapterRepository = ChapterRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides
fun downloadRepository(
apolloClient: ApolloClient,
apolloAppClient: ApolloAppClient,
http: Http,
serverPreferences: ServerPreferences,
): DownloadRepository = DownloadRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
): DownloadRepository = DownloadRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides
fun extensionRepository(
apolloClient: ApolloClient,
apolloAppClient: ApolloAppClient,
http: Http,
serverPreferences: ServerPreferences,
): ExtensionRepository = ExtensionRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
): ExtensionRepository = ExtensionRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides
fun globalRepository(
apolloClient: ApolloClient,
apolloAppClient: ApolloAppClient,
http: Http,
serverPreferences: ServerPreferences,
): GlobalRepository = GlobalRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
): GlobalRepository = GlobalRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides
fun libraryRepository(
apolloClient: ApolloClient,
apolloAppClient: ApolloAppClient,
http: Http,
serverPreferences: ServerPreferences,
): LibraryRepository = LibraryRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
): LibraryRepository = LibraryRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides
fun mangaRepository(
apolloClient: ApolloClient,
apolloAppClient: ApolloAppClient,
http: Http,
serverPreferences: ServerPreferences,
): MangaRepository = MangaRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
): MangaRepository = MangaRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides
fun sourceRepository(
apolloClient: ApolloClient,
apolloAppClient: ApolloAppClient,
http: Http,
serverPreferences: ServerPreferences,
): SourceRepository = SourceRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
): SourceRepository = SourceRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides
fun updatesRepository(
apolloClient: ApolloClient,
apolloAppClient: ApolloAppClient,
http: Http,
serverPreferences: ServerPreferences,
): UpdatesRepository = UpdatesRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
): UpdatesRepository = UpdatesRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides
fun backupRepository(
apolloClient: ApolloClient,
apolloAppClient: ApolloAppClient,
http: Http,
serverPreferences: ServerPreferences,
): BackupRepository = BackupRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
): BackupRepository = BackupRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
}

View File

@@ -1,6 +1,7 @@
package ca.gosyer.jui.data.backup
import ca.gosyer.jui.core.io.toSource
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.CreateBackupMutation
import ca.gosyer.jui.data.graphql.RestoreBackupMutation
import ca.gosyer.jui.data.graphql.RestoreStatusQuery
@@ -24,10 +25,13 @@ import okio.Source
import okio.buffer
class BackupRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
private val http: Http,
private val serverUrl: Url,
) : BackupRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun validateBackup(source: Source): Flow<BackupValidationResult> =
apolloClient.query(
ValidateBackupQuery(
@@ -89,7 +93,7 @@ class BackupRepositoryImpl(
.toFlow()
.map {
val url = it.dataAssertNoErrors.createBackup.url
val response = http.get(
val response = http.value.get(
Url("$serverUrl$url"),
)
val fileName = response.headers["content-disposition"]!!

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data.category
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.AddMangaToCategoriesMutation
import ca.gosyer.jui.data.graphql.CreateCategoryMutation
import ca.gosyer.jui.data.graphql.DeleteCategoryMutation
@@ -29,10 +30,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class CategoryRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
private val http: Http,
private val serverUrl: Url,
) : CategoryRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getMangaCategories(mangaId: Long): Flow<List<Category>> =
apolloClient.query(
GetMangaCategoriesQuery(mangaId.toInt()),

View File

@@ -1,5 +1,6 @@
package ca.gosyer.jui.data.chapter
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.DeleteDownloadedChapterMutation
import ca.gosyer.jui.data.graphql.DeleteDownloadedChaptersMutation
import ca.gosyer.jui.data.graphql.FetchChapterPagesMutation
@@ -29,10 +30,13 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
class ChapterRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
private val http: Http,
private val serverUrl: Url,
) : ChapterRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getChapter(chapterId: Long): Flow<Chapter> =
apolloClient.query(
GetChapterQuery(chapterId.toInt()),
@@ -164,7 +168,7 @@ class ChapterRepositoryImpl(
): Flow<ByteArray> {
val realUrl = Url("$serverUrl$url")
return flow { emit(http.get(realUrl, block).readRawBytes()) }
return flow { emit(http.value.get(realUrl, block).readRawBytes()) }
}
companion object {

View File

@@ -6,25 +6,44 @@
package ca.gosyer.jui.data.download
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.ClearDownloaderMutation
import ca.gosyer.jui.data.graphql.DequeueChapterDownloadMutation
import ca.gosyer.jui.data.graphql.DownloadStatusChangedSubscription
import ca.gosyer.jui.data.graphql.DownloadStatusQuery
import ca.gosyer.jui.data.graphql.EnqueueChapterDownloadMutation
import ca.gosyer.jui.data.graphql.EnqueueChapterDownloadsMutation
import ca.gosyer.jui.data.graphql.ReorderChapterDownloadMutation
import ca.gosyer.jui.data.graphql.StartDownloaderMutation
import ca.gosyer.jui.data.graphql.StopDownloaderMutation
import ca.gosyer.jui.data.graphql.fragment.DownloadFragment
import ca.gosyer.jui.domain.download.model.DownloadChapter
import ca.gosyer.jui.domain.download.model.DownloadManga
import ca.gosyer.jui.domain.download.model.DownloadQueueItem
import ca.gosyer.jui.domain.download.model.DownloadState
import ca.gosyer.jui.domain.download.model.DownloadStatus
import ca.gosyer.jui.domain.download.model.DownloadUpdate
import ca.gosyer.jui.domain.download.model.DownloadUpdateType
import ca.gosyer.jui.domain.download.model.DownloadUpdates
import ca.gosyer.jui.domain.download.model.DownloaderState
import ca.gosyer.jui.domain.download.service.DownloadRepository
import ca.gosyer.jui.domain.server.Http
import com.apollographql.apollo.ApolloClient
import io.ktor.http.Url
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ca.gosyer.jui.data.graphql.type.DownloadState as GraphQLDownloadState
import ca.gosyer.jui.data.graphql.type.DownloadUpdateType as GraphQLDownloadUpdateType
import ca.gosyer.jui.data.graphql.type.DownloaderState as GraphQLDownloaderState
class DownloadRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
private val http: Http,
private val serverUrl: Url,
) : DownloadRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun startDownloading(): Flow<Unit> =
apolloClient.mutation(
StartDownloaderMutation(),
@@ -90,4 +109,82 @@ class DownloadRepositoryImpl(
.map {
it.dataAssertNoErrors
}
override fun downloadSubscription(): Flow<DownloadUpdates> =
apolloClient.subscription(
DownloadStatusChangedSubscription(),
)
.toFlow()
.map {
val data = it.dataAssertNoErrors.downloadStatusChanged
DownloadUpdates(
data.initial?.map { it.downloadFragment.toDownloadQueueItem() },
data.omittedUpdates,
data.state.toClient(),
data.updates.map {
DownloadUpdate(
when (it.type) {
GraphQLDownloadUpdateType.QUEUED -> DownloadUpdateType.QUEUED
GraphQLDownloadUpdateType.DEQUEUED -> DownloadUpdateType.DEQUEUED
GraphQLDownloadUpdateType.PAUSED -> DownloadUpdateType.PAUSED
GraphQLDownloadUpdateType.STOPPED -> DownloadUpdateType.STOPPED
GraphQLDownloadUpdateType.PROGRESS -> DownloadUpdateType.PROGRESS
GraphQLDownloadUpdateType.FINISHED -> DownloadUpdateType.FINISHED
GraphQLDownloadUpdateType.ERROR -> DownloadUpdateType.ERROR
GraphQLDownloadUpdateType.POSITION -> DownloadUpdateType.POSITION
GraphQLDownloadUpdateType.UNKNOWN__ -> null
},
it.download.downloadFragment.toDownloadQueueItem(),
)
},
)
}
override fun downloadStatus(): Flow<DownloadStatus> =
apolloClient.query(
DownloadStatusQuery(),
)
.toFlow()
.map {
val data = it.dataAssertNoErrors.downloadStatus
DownloadStatus(
data.state.toClient(),
data.queue.map { it.downloadFragment.toDownloadQueueItem() },
)
}
companion object {
fun DownloadFragment.toDownloadQueueItem(): DownloadQueueItem =
DownloadQueueItem(
position = this.position,
progress = this.progress.toFloat(),
state = when (this.state) {
GraphQLDownloadState.QUEUED -> DownloadState.QUEUED
GraphQLDownloadState.DOWNLOADING -> DownloadState.DOWNLOADING
GraphQLDownloadState.FINISHED -> DownloadState.FINISHED
GraphQLDownloadState.ERROR -> DownloadState.ERROR
GraphQLDownloadState.UNKNOWN__ -> DownloadState.ERROR
},
tries = this.tries,
chapter = DownloadChapter(
chapter.id.toLong(),
chapter.name,
chapter.pageCount,
),
manga = DownloadManga(
manga.id.toLong(),
manga.title,
manga.thumbnailUrl,
manga.thumbnailUrlLastFetched ?: 0,
),
)
fun GraphQLDownloaderState.toClient(): DownloaderState =
when (this) {
GraphQLDownloaderState.STARTED -> DownloaderState.STARTED
GraphQLDownloaderState.STOPPED -> DownloaderState.STOPPED
GraphQLDownloaderState.UNKNOWN__ -> DownloaderState.STOPPED
}
}
}

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data.extension
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.FetchExtensionsMutation
import ca.gosyer.jui.data.graphql.InstallExtensionMutation
import ca.gosyer.jui.data.graphql.InstallExternalExtensionMutation
@@ -23,10 +24,13 @@ import kotlinx.coroutines.flow.map
import okio.Source
class ExtensionRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
private val http: Http,
private val serverUrl: Url,
) : ExtensionRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getExtensionList(): Flow<List<Extension>> =
apolloClient.mutation(
FetchExtensionsMutation(),

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data.global
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.GetGlobalMetaQuery
import ca.gosyer.jui.data.graphql.SetGlobalMetaMutation
import ca.gosyer.jui.domain.global.model.GlobalMeta
@@ -17,10 +18,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GlobalRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
private val http: Http,
private val serverUrl: Url,
) : GlobalRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getGlobalMeta(): Flow<GlobalMeta> =
apolloClient.query(
GetGlobalMetaQuery(),

View File

@@ -1,5 +1,6 @@
package ca.gosyer.jui.data.library
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.SetMangaInLibraryMutation
import ca.gosyer.jui.domain.library.service.LibraryRepository
import ca.gosyer.jui.domain.server.Http
@@ -9,10 +10,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class LibraryRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
private val http: Http,
private val serverUrl: Url,
) : LibraryRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
fun setMangaInLibrary(
mangaId: Long,
inLibrary: Boolean,

View File

@@ -1,5 +1,6 @@
package ca.gosyer.jui.data.manga
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.GetMangaLibraryQuery
import ca.gosyer.jui.data.graphql.GetMangaQuery
import ca.gosyer.jui.data.graphql.GetThumbnailUrlQuery
@@ -26,10 +27,13 @@ import ca.gosyer.jui.data.graphql.type.MangaStatus as GqlMangaStatus
import ca.gosyer.jui.data.graphql.type.UpdateStrategy as GqlUpdateStrategy
class MangaRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
private val http: Http,
private val serverUrl: Url,
) : MangaRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getManga(mangaId: Long): Flow<Manga> =
apolloClient.query(
GetMangaQuery(mangaId.toInt()),
@@ -70,7 +74,7 @@ class MangaRepositoryImpl(
.toFlow()
.map {
val data = it.dataAssertNoErrors
http.get(data.manga.thumbnailUrl!!).bodyAsChannel()
http.value.get(data.manga.thumbnailUrl!!).bodyAsChannel()
}
override fun updateMangaMeta(

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data.settings
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.AboutServerQuery
import ca.gosyer.jui.data.graphql.AllSettingsQuery
import ca.gosyer.jui.data.graphql.SetSettingsMutation
@@ -43,8 +44,11 @@ import ca.gosyer.jui.domain.settings.model.WebUIFlavor as DomainWebUIFlavor
import ca.gosyer.jui.domain.settings.model.WebUIInterface as DomainWebUIInterface
class SettingsRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
) : SettingsRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
private fun SettingsTypeFragment.toSettings() =
Settings(
authMode = authMode.toDomain(),

View File

@@ -1,5 +1,6 @@
package ca.gosyer.jui.data.source
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.FetchLatestMangaMutation
import ca.gosyer.jui.data.graphql.FetchPopularMangaMutation
import ca.gosyer.jui.data.graphql.FetchSearchMangaMutation
@@ -33,10 +34,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class SourceRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
private val http: Http,
private val serverUrl: Url,
) : SourceRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getSourceList(): Flow<List<Source>> =
apolloClient.query(
GetSourceListQuery(),

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data.updates
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.chapter.ChapterRepositoryImpl.Companion.toMangaAndChapter
import ca.gosyer.jui.data.graphql.GetChapterUpdatesQuery
import ca.gosyer.jui.data.graphql.UpdateCategoryMutation
@@ -19,10 +20,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class UpdatesRepositoryImpl(
private val apolloClient: ApolloClient,
private val apolloAppClient: ApolloAppClient,
private val http: Http,
private val serverUrl: Url,
) : UpdatesRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getRecentUpdates(pageNum: Int): Flow<Updates> =
apolloClient.query(
GetChapterUpdatesQuery(50, pageNum * 50),

View File

@@ -99,7 +99,7 @@ suspend fun main() {
serverService.initialized
.filter { it == ServerResult.STARTED || it == ServerResult.UNUSED }
.onEach {
appComponent.downloadService.init()
appComponent.downloadService.getSubscription().launchIn(GlobalScope)
appComponent.libraryUpdateService.init()
}
.launchIn(GlobalScope)

View File

@@ -118,11 +118,6 @@ interface SharedDomainComponent : CoreComponent {
val libraryUpdateServiceFactory: LibraryUpdateService
get() = LibraryUpdateService(serverPreferences, http)
@get:AppScope
@get:Provides
val downloadServiceFactory: DownloadService
get() = DownloadService(serverPreferences, http)
@get:AppScope
@get:Provides
val serverListenersFactory: ServerListeners

View File

@@ -45,8 +45,8 @@ abstract class WebsocketService(
fun init() {
errorConnectionCount = 0
job?.cancel()
job = serverUrl
.mapLatest { serverUrl ->
job = client
.mapLatest { client ->
status.value = Status.STARTING
while (true) {
if (errorConnectionCount > 3) {
@@ -54,6 +54,7 @@ abstract class WebsocketService(
throw CancellationException("Finish")
}
runCatching {
val serverUrl = serverUrl.value
client.ws(
host = serverUrl.host,
port = serverUrl.port,

View File

@@ -6,19 +6,24 @@
package ca.gosyer.jui.domain.download.model
import androidx.compose.runtime.Immutable
import ca.gosyer.jui.domain.chapter.model.Chapter
import ca.gosyer.jui.domain.manga.model.Manga
import kotlinx.serialization.Serializable
@Serializable
@Immutable
data class DownloadChapter(
val chapterIndex: Int,
val mangaId: Long,
val chapter: Chapter,
val manga: Manga,
val state: DownloadState = DownloadState.Queued,
val progress: Float = 0f,
val tries: Int = 0,
data class DownloadQueueItem(
val position: Int,
val progress: Float,
val state: DownloadState,
val tries: Int,
val chapter: DownloadChapter,
val manga: DownloadManga
)
data class DownloadChapter(
val id: Long,
val name: String,
val pageCount: Int,
)
data class DownloadManga(
val id: Long,
val title: String,
val thumbnailUrl: String?,
val thumbnailUrlLastFetched: Long = 0,
)

View File

@@ -6,16 +6,9 @@
package ca.gosyer.jui.domain.download.model
import androidx.compose.runtime.Stable
import kotlinx.serialization.Serializable
@Serializable
@Stable
enum class DownloadState(
val state: Int,
) {
Queued(0),
Downloading(1),
Finished(2),
Error(3),
enum class DownloadState {
QUEUED,
DOWNLOADING,
FINISHED,
ERROR
}

View File

@@ -6,12 +6,7 @@
package ca.gosyer.jui.domain.download.model
import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
@Serializable
@Immutable
data class DownloadStatus(
val status: DownloaderStatus,
val queue: List<DownloadChapter>,
val status: DownloaderState,
val queue: List<DownloadQueueItem>,
)

View File

@@ -0,0 +1,17 @@
package ca.gosyer.jui.domain.download.model
data class DownloadUpdate(
val type: DownloadUpdateType? = null,
val download: DownloadQueueItem? = null
)
enum class DownloadUpdateType {
QUEUED,
DEQUEUED,
PAUSED,
STOPPED,
PROGRESS,
FINISHED,
ERROR,
POSITION
}

View File

@@ -0,0 +1,8 @@
package ca.gosyer.jui.domain.download.model
data class DownloadUpdates(
val initial: List<DownloadQueueItem>? = null,
val omittedUpdates: Boolean,
val state: DownloaderState,
val updates: List<DownloadUpdate>? = null
)

View File

@@ -6,12 +6,7 @@
package ca.gosyer.jui.domain.download.model
import androidx.compose.runtime.Stable
import kotlinx.serialization.Serializable
@Serializable
@Stable
enum class DownloaderStatus {
Started,
Stopped,
enum class DownloaderState {
STARTED,
STOPPED
}

View File

@@ -6,6 +6,8 @@
package ca.gosyer.jui.domain.download.service
import ca.gosyer.jui.domain.download.model.DownloadStatus
import ca.gosyer.jui.domain.download.model.DownloadUpdates
import kotlinx.coroutines.flow.Flow
interface DownloadRepository {
@@ -25,4 +27,8 @@ interface DownloadRepository {
): Flow<Unit>
fun batchDownload(chapterIds: List<Long>): Flow<Unit>
fun downloadSubscription(): Flow<DownloadUpdates>
fun downloadStatus(): Flow<DownloadStatus>
}

View File

@@ -7,49 +7,144 @@
package ca.gosyer.jui.domain.download.service
import ca.gosyer.jui.domain.base.WebsocketService
import ca.gosyer.jui.domain.download.model.DownloadChapter
import ca.gosyer.jui.domain.download.model.DownloadStatus
import ca.gosyer.jui.domain.download.model.DownloaderStatus
import ca.gosyer.jui.domain.server.Http
import ca.gosyer.jui.domain.server.service.ServerPreferences
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import ca.gosyer.jui.domain.download.model.DownloadQueueItem
import ca.gosyer.jui.domain.download.model.DownloadUpdateType
import ca.gosyer.jui.domain.download.model.DownloaderState
import com.diamondedge.logging.logging
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import me.tatarka.inject.annotations.Inject
@Inject
class DownloadService(
serverPreferences: ServerPreferences,
client: Http,
) : WebsocketService(serverPreferences, client) {
override val status: MutableStateFlow<Status>
get() = DownloadService.status
private val downloadRepository: DownloadRepository,
) {
private val log = logging()
override val query: String
get() = "/api/v1/downloads"
fun getSubscription(): Flow<Unit> {
return downloadRepository.downloadSubscription()
.onStart {
log.info { "Starting download status subscription" }
status.value = WebsocketService.Status.STARTING
}
.catch { error ->
log.error(error) { "Error in download status subscription" }
status.value = WebsocketService.Status.STOPPED
}
.map { updates ->
status.value = WebsocketService.Status.RUNNING
if (updates.omittedUpdates) {
log.info { "Omitted updates detected, fetching fresh download status" }
fetchDownloadStatus()
return@map
}
if (updates.initial != null) {
downloadQueue.value = updates.initial
}
downloaderStatus.value = updates.state
updates.updates?.forEach { update ->
when (update.type) {
DownloadUpdateType.QUEUED -> {
update.download?.let { download ->
downloadQueue.update {
it.toMutableList().apply {
add(download.position.coerceAtMost(it.size), download)
}
}
}
}
DownloadUpdateType.DEQUEUED -> {
downloadQueue.update {
it.filter { it.chapter.id != update.download?.chapter?.id }
}
}
DownloadUpdateType.PAUSED -> {
downloaderStatus.value = DownloaderState.STOPPED
}
DownloadUpdateType.STOPPED -> {
downloaderStatus.value = DownloaderState.STOPPED
}
DownloadUpdateType.ERROR -> {
update.download?.let { download ->
downloadQueue.update {
it.map { chapter ->
if (chapter.chapter.id == download.chapter.id) {
chapter.copy(state = download.state)
} else {
chapter
}
}
}
}
}
DownloadUpdateType.PROGRESS -> {
update.download?.let { download ->
downloadQueue.update {
it.map { chapter ->
if (chapter.chapter.id == download.chapter.id) {
chapter.copy(progress = download.progress)
} else {
chapter
}
}
}
}
}
DownloadUpdateType.FINISHED -> {
downloadQueue.update {
it.filter { it.chapter.id != update.download?.chapter?.id }
}
}
DownloadUpdateType.POSITION -> {
update.download?.let { download ->
downloadQueue.update {
val index = it.indexOfFirst { it.chapter.id == download.chapter.id }
if (index != -1) {
it.toMutableList().apply {
removeAt(index)
add(download.position.coerceAtMost(it.size), download)
}.toList()
} else it
}
override suspend fun onReceived(frame: Frame.Text) {
val status = json.decodeFromString<DownloadStatus>(frame.readText())
downloaderStatus.value = status.status
}
}
null -> {
// todo Handle null case
}
}
}
}
}
private suspend fun fetchDownloadStatus() {
val status = downloadRepository.downloadStatus().firstOrNull()
if (status != null) {
downloadQueue.value = status.queue
downloaderStatus.value = status.status
}
}
companion object {
val status = MutableStateFlow(Status.STARTING)
val downloadQueue = MutableStateFlow(emptyList<DownloadChapter>())
val downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped)
val status = MutableStateFlow(WebsocketService.Status.STARTING)
val downloadQueue = MutableStateFlow(emptyList<DownloadQueueItem>())
val downloaderStatus = MutableStateFlow(DownloaderState.STOPPED)
fun registerWatch(mangaId: Long) =
downloadQueue
.map {
it.filter { it.mangaId == mangaId }
it.filter { it.manga.id == mangaId }
}
fun registerWatches(mangaIds: Set<Long>) =
downloadQueue
.map {
it.filter { it.mangaId in mangaIds }
it.filter { it.manga.id in mangaIds }
}
}
}

View File

@@ -29,49 +29,64 @@ import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol
import io.ktor.http.Url
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import io.ktor.client.plugins.auth.Auth as AuthPlugin
typealias Http = HttpClient
typealias Http = StateFlow<HttpClient>
expect val Engine: HttpClientEngineFactory<HttpClientEngineConfig>
expect fun HttpClientConfig<HttpClientEngineConfig>.configurePlatform()
fun httpClient(
serverPreferences: ServerPreferences,
json: Json,
): Http =
HttpClient(Engine) {
private fun getHttpClient(
serverUrl: Url,
proxy: Proxy,
proxyHttpHost: String,
proxyHttpPort: Int,
proxySocksHost: String,
proxySocksPort: Int,
auth: Auth,
authUsername: String,
authPassword: String,
json: Json
): HttpClient {
return HttpClient(Engine) {
configurePlatform()
expectSuccess = true
defaultRequest {
url(serverPreferences.serverUrl().get().toString())
url(serverUrl.toString())
}
engine {
proxy = when (serverPreferences.proxy().get()) {
this.proxy = when (proxy) {
Proxy.NO_PROXY -> null
Proxy.HTTP_PROXY -> ProxyBuilder.http(
URLBuilder(
host = serverPreferences.proxyHttpHost().get(),
port = serverPreferences.proxyHttpPort().get(),
host = proxyHttpHost,
port = proxyHttpPort,
).build(),
)
Proxy.SOCKS_PROXY -> ProxyBuilder.socks(
serverPreferences.proxySocksHost().get(),
serverPreferences.proxySocksPort().get(),
proxySocksHost,
proxySocksPort,
)
}
}
when (serverPreferences.auth().get()) {
when (auth) {
Auth.NONE -> Unit
Auth.BASIC -> AuthPlugin {
@@ -81,8 +96,8 @@ fun httpClient(
}
credentials {
BasicAuthCredentials(
serverPreferences.authUsername().get(),
serverPreferences.authPassword().get(),
authUsername,
authPassword,
)
}
}
@@ -92,8 +107,8 @@ fun httpClient(
digest {
credentials {
DigestAuthCredentials(
serverPreferences.authUsername().get(),
serverPreferences.authPassword().get(),
authUsername,
authPassword,
)
}
}
@@ -123,3 +138,48 @@ fun httpClient(
}
}
}
}
@OptIn(DelicateCoroutinesApi::class)
fun httpClient(
serverPreferences: ServerPreferences,
json: Json,
): Http = combine(
serverPreferences.serverUrl().stateIn(GlobalScope),
serverPreferences.proxy().stateIn(GlobalScope),
serverPreferences.proxyHttpHost().stateIn(GlobalScope),
serverPreferences.proxyHttpPort().stateIn(GlobalScope),
serverPreferences.proxySocksHost().stateIn(GlobalScope),
serverPreferences.proxySocksPort().stateIn(GlobalScope),
serverPreferences.auth().stateIn(GlobalScope),
serverPreferences.authUsername().stateIn(GlobalScope),
serverPreferences.authPassword().stateIn(GlobalScope),
) {
getHttpClient(
it[0] as Url,
it[1] as Proxy,
it[2] as String,
it[3] as Int,
it[4] as String,
it[5] as Int,
it[6] as Auth,
it[7] as String,
it[8] as String,
json,
)
}.stateIn(
GlobalScope,
SharingStarted.Eagerly,
getHttpClient(
serverPreferences.serverUrl().get(),
serverPreferences.proxy().get(),
serverPreferences.proxyHttpHost().get(),
serverPreferences.proxyHttpPort().get(),
serverPreferences.proxySocksHost().get(),
serverPreferences.proxySocksPort().get(),
serverPreferences.auth().get(),
serverPreferences.authUsername().get(),
serverPreferences.authPassword().get(),
json,
)
)

View File

@@ -39,7 +39,7 @@ class UpdateChecker(
fun asFlow(manualFetch: Boolean) =
flow {
if (!manualFetch && !updatePreferences.enabled().get()) return@flow
val latestRelease = client.get(
val latestRelease = client.value.get(
"https://api.github.com/repos/$GITHUB_REPO/releases/latest",
).body<GithubRelease>()

View File

@@ -33,7 +33,7 @@ actual fun ComponentRegistryBuilder.register(
contextWrapper: ContextWrapper,
http: Http,
) {
setupDefaultComponents(contextWrapper, httpClient = { http })
setupDefaultComponents(contextWrapper, httpClient = { http.value })
}
actual fun DiskCacheBuilder.configure(

View File

@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.dp
import ca.gosyer.jui.domain.chapter.interactor.DeleteChapterDownload
import ca.gosyer.jui.domain.chapter.model.Chapter
import ca.gosyer.jui.domain.download.interactor.StopChapterDownload
import ca.gosyer.jui.domain.download.model.DownloadChapter
import ca.gosyer.jui.domain.download.model.DownloadQueueItem
import ca.gosyer.jui.domain.download.model.DownloadState
import ca.gosyer.jui.domain.manga.model.Manga
import ca.gosyer.jui.i18n.MR
@@ -61,12 +61,12 @@ data class ChapterDownloadItem(
)
val downloadState = _downloadState.asStateFlow()
private val _downloadChapterFlow: MutableStateFlow<DownloadChapter?> = MutableStateFlow(null)
private val _downloadChapterFlow: MutableStateFlow<DownloadQueueItem?> = MutableStateFlow(null)
val downloadChapterFlow = _downloadChapterFlow.asStateFlow()
fun updateFrom(downloadingChapters: List<DownloadChapter>) {
fun updateFrom(downloadingChapters: List<DownloadQueueItem>) {
val downloadingChapter = downloadingChapters.find {
it.chapterIndex == chapter.index && it.mangaId == chapter.mangaId
it.chapter.id == chapter.id
}
if (downloadingChapter != null && downloadState.value != ChapterDownloadState.Downloading) {
_downloadState.value = ChapterDownloadState.Downloading
@@ -155,11 +155,11 @@ private fun DownloadIconButton(onClick: () -> Unit) {
@Composable
private fun DownloadingIconButton(
downloadChapter: DownloadChapter?,
downloadChapter: DownloadQueueItem?,
onClick: () -> Unit,
) {
DropdownIconButton(
downloadChapter?.mangaId to downloadChapter?.chapterIndex,
downloadChapter?.chapter?.id,
{
DropdownMenuItem(onClick = onClick) {
Text(stringResource(MR.strings.action_cancel))
@@ -167,7 +167,7 @@ private fun DownloadingIconButton(
},
) {
when (downloadChapter?.state) {
null, DownloadState.Queued -> CircularProgressIndicator(
null, DownloadState.QUEUED -> CircularProgressIndicator(
Modifier
.size(26.dp)
.padding(2.dp),
@@ -175,7 +175,7 @@ private fun DownloadingIconButton(
2.dp,
)
DownloadState.Downloading -> if (downloadChapter.progress != 0.0F) {
DownloadState.DOWNLOADING -> if (downloadChapter.progress != 0.0F) {
val animatedProgress by animateFloatAsState(
targetValue = downloadChapter.progress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
@@ -206,7 +206,7 @@ private fun DownloadingIconButton(
)
}
DownloadState.Error -> Surface(shape = CircleShape, color = LocalContentColor.current) {
DownloadState.ERROR -> Surface(shape = CircleShape, color = LocalContentColor.current) {
Icon(
Icons.Rounded.Error,
null,
@@ -217,7 +217,7 @@ private fun DownloadingIconButton(
)
}
DownloadState.Finished -> Surface(shape = CircleShape, color = LocalContentColor.current) {
DownloadState.FINISHED -> Surface(shape = CircleShape, color = LocalContentColor.current) {
Icon(
Icons.Rounded.Check,
null,

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.ui.base.image
import ca.gosyer.jui.domain.download.model.DownloadManga
import ca.gosyer.jui.domain.extension.model.Extension
import ca.gosyer.jui.domain.manga.model.Manga
import ca.gosyer.jui.domain.server.Http
@@ -42,10 +43,13 @@ class ImageLoaderProvider(
fun get(imageCache: ImageCache): ImageLoader =
ImageLoader {
components {
add(KtorUrlFetcher.Factory { http.value })
register(context, http)
add(MokoResourceFetcher.Factory())
add(MangaCoverMapper())
add(MangaCoverKeyer())
add(DownloadMangaCoverMapper())
add(DownloadMangaCoverKeyer())
add(ExtensionIconMapper())
add(ExtensionIconKeyer())
add(SourceIconMapper())
@@ -77,7 +81,28 @@ class ImageLoaderProvider(
options: Options,
): String? {
if (data !is Manga) return null
return "${data.sourceId}-${data.thumbnailUrl}-${data.thumbnailUrlLastFetched}"
return "${data.id}-${data.thumbnailUrl}-${data.thumbnailUrlLastFetched}"
}
}
inner class DownloadMangaCoverMapper : Mapper<Url> {
override fun map(
data: Any,
options: Options,
): Url? {
if (data !is DownloadManga) return null
if (data.thumbnailUrl.isNullOrBlank()) return null
return Url(serverUrl.value.toString() + data.thumbnailUrl)
}
}
class DownloadMangaCoverKeyer : Keyer {
override fun key(
data: Any,
options: Options,
): String? {
if (data !is DownloadManga) return null
return "${data.id}-${data.thumbnailUrl}-${data.thumbnailUrlLastFetched}"
}
}

View File

@@ -0,0 +1,84 @@
package ca.gosyer.jui.ui.base.image
import com.seiko.imageloader.component.fetcher.FetchResult
import com.seiko.imageloader.component.fetcher.Fetcher
import com.seiko.imageloader.model.ImageSource
import com.seiko.imageloader.model.ImageSourceFrom
import com.seiko.imageloader.model.KtorRequestData
import com.seiko.imageloader.model.extraData
import com.seiko.imageloader.model.mimeType
import com.seiko.imageloader.model.toImageSource
import com.seiko.imageloader.option.Options
import io.ktor.client.HttpClient
import io.ktor.client.request.headers
import io.ktor.client.request.prepareRequest
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpMethod
import io.ktor.http.Url
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readRemaining
import kotlinx.io.readByteArray
import okio.Buffer
class KtorUrlFetcher private constructor(
private val httpUrl: Url,
private val httpClient: () -> HttpClient,
private val ktorRequestData: KtorRequestData?,
) : Fetcher {
override suspend fun fetch(): FetchResult {
return httpClient().prepareRequest {
url(httpUrl)
method = ktorRequestData?.method ?: HttpMethod.Get
ktorRequestData?.headers?.let {
headers {
it.forEach { (key, value) ->
append(key, value)
}
}
}
}.execute { response ->
if (!response.status.isSuccess()) {
throw KtorUrlRequestException("code:${response.status.value}, ${response.status.description}")
}
FetchResult.OfSource(
imageSource = channelToImageSource(response.bodyAsChannel()),
imageSourceFrom = ImageSourceFrom.Network,
extra = extraData {
mimeType(response.contentType()?.toString())
},
)
}
}
class Factory(
private val httpClient: () -> HttpClient,
) : Fetcher.Factory {
override fun create(data: Any, options: Options): Fetcher? {
if (data !is Url) return null
return KtorUrlFetcher(
httpUrl = data,
httpClient = httpClient,
ktorRequestData = options.extra["KEY_KTOR_REQUEST_DATA"] as? KtorRequestData,
)
}
}
}
private suspend fun channelToImageSource(channel: ByteReadChannel): ImageSource {
val buffer = Buffer()
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(2048)
while (!packet.exhausted()) {
val bytes = packet.readByteArray()
buffer.write(bytes)
}
}
return buffer.toImageSource()
}
private class KtorUrlRequestException(msg: String) : RuntimeException(msg)

View File

@@ -7,13 +7,13 @@
package ca.gosyer.jui.ui.downloads
import ca.gosyer.jui.domain.base.WebsocketService.Actions
import ca.gosyer.jui.domain.chapter.model.Chapter
import ca.gosyer.jui.domain.download.interactor.ClearDownloadQueue
import ca.gosyer.jui.domain.download.interactor.QueueChapterDownload
import ca.gosyer.jui.domain.download.interactor.ReorderChapterDownload
import ca.gosyer.jui.domain.download.interactor.StartDownloading
import ca.gosyer.jui.domain.download.interactor.StopChapterDownload
import ca.gosyer.jui.domain.download.interactor.StopDownloading
import ca.gosyer.jui.domain.download.model.DownloadChapter
import ca.gosyer.jui.domain.download.service.DownloadService
import ca.gosyer.jui.uicore.vm.ContextWrapper
import ca.gosyer.jui.uicore.vm.ViewModel
@@ -69,35 +69,35 @@ class DownloadsScreenViewModel(
scope.launch { clearDownloadQueue.await(onError = { toast(it.message.orEmpty()) }) }
}
fun stopDownload(chapter: Chapter) {
fun stopDownload(chapter: DownloadChapter) {
scope.launch { stopChapterDownload.await(chapter.id, onError = { toast(it.message.orEmpty()) }) }
}
fun moveUp(chapter: Chapter) {
fun moveUp(chapter: DownloadChapter) {
scope.launch {
val index =
downloadQueue.value.indexOfFirst { it.mangaId == chapter.mangaId && it.chapterIndex == chapter.index }
downloadQueue.value.indexOfFirst { it.chapter.id == chapter.id }
if (index == -1 || index <= 0) return@launch
reorderChapterDownload.await(chapter.id, index - 1, onError = { toast(it.message.orEmpty()) })
}
}
fun moveDown(chapter: Chapter) {
fun moveDown(chapter: DownloadChapter) {
scope.launch {
val index =
downloadQueue.value.indexOfFirst { it.mangaId == chapter.mangaId && it.chapterIndex == chapter.index }
downloadQueue.value.indexOfFirst { it.chapter.id == chapter.id }
if (index == -1 || index >= downloadQueue.value.lastIndex) return@launch
reorderChapterDownload.await(chapter.id, index + 1, onError = { toast(it.message.orEmpty()) })
}
}
fun moveToTop(chapter: Chapter) {
fun moveToTop(chapter: DownloadChapter) {
scope.launch {
reorderChapterDownload.await(chapter.id, 0, onError = { toast(it.message.orEmpty()) })
}
}
fun moveToBottom(chapter: Chapter) {
fun moveToBottom(chapter: DownloadChapter) {
scope.launch {
reorderChapterDownload.await(
chapter.id,

View File

@@ -48,9 +48,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ca.gosyer.jui.domain.chapter.model.Chapter
import ca.gosyer.jui.domain.download.model.DownloadChapter
import ca.gosyer.jui.domain.download.model.DownloaderStatus
import ca.gosyer.jui.domain.download.model.DownloadQueueItem
import ca.gosyer.jui.domain.download.model.DownloaderState
import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.ui.base.navigation.ActionItem
import ca.gosyer.jui.ui.base.navigation.Toolbar
@@ -71,17 +71,17 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
fun DownloadsScreenContent(
downloadQueue: ImmutableList<DownloadChapter>,
downloadStatus: DownloaderStatus,
downloadQueue: ImmutableList<DownloadQueueItem>,
downloadStatus: DownloaderState,
startDownloading: () -> Unit,
pauseDownloading: () -> Unit,
clearQueue: () -> Unit,
onMangaClick: (Long) -> Unit,
stopDownload: (Chapter) -> Unit,
moveDownloadUp: (Chapter) -> Unit,
moveDownloadDown: (Chapter) -> Unit,
moveDownloadToTop: (Chapter) -> Unit,
moveDownloadToBottom: (Chapter) -> Unit,
stopDownload: (DownloadChapter) -> Unit,
moveDownloadUp: (DownloadChapter) -> Unit,
moveDownloadDown: (DownloadChapter) -> Unit,
moveDownloadToTop: (DownloadChapter) -> Unit,
moveDownloadToBottom: (DownloadChapter) -> Unit,
) {
Scaffold(
modifier = Modifier.windowInsetsPadding(
@@ -114,11 +114,11 @@ fun DownloadsScreenContent(
),
).asPaddingValues(),
) {
items(downloadQueue, key = { "${it.mangaId}-${it.chapterIndex}" }) {
items(downloadQueue, key = { it.chapter.id }) {
DownloadsItem(
modifier = Modifier.animateItem(),
item = it,
onClickCover = { onMangaClick(it.mangaId) },
onClickCover = { onMangaClick(it.manga.id) },
onClickCancel = stopDownload,
onClickMoveUp = moveDownloadUp,
onClickMoveDown = moveDownloadDown,
@@ -147,13 +147,13 @@ fun DownloadsScreenContent(
@Composable
fun DownloadsItem(
modifier: Modifier = Modifier,
item: DownloadChapter,
item: DownloadQueueItem,
onClickCover: () -> Unit,
onClickCancel: (Chapter) -> Unit,
onClickMoveUp: (Chapter) -> Unit,
onClickMoveDown: (Chapter) -> Unit,
onClickMoveToTop: (Chapter) -> Unit,
onClickMoveToBottom: (Chapter) -> Unit,
onClickCancel: (DownloadChapter) -> Unit,
onClickMoveUp: (DownloadChapter) -> Unit,
onClickMoveDown: (DownloadChapter) -> Unit,
onClickMoveToTop: (DownloadChapter) -> Unit,
onClickMoveToBottom: (DownloadChapter) -> Unit,
) {
MangaListItem(
modifier = modifier
@@ -180,8 +180,8 @@ fun DownloadsItem(
text = item.manga.title,
fontWeight = FontWeight.SemiBold,
)
val progress = if (item.chapter.pageCount != null && item.chapter.pageCount != -1) {
" - " + "${(item.chapter.pageCount!! * item.progress).toInt()}/${item.chapter.pageCount}"
val progress = if (item.chapter.pageCount > 0) {
" - " + "${(item.chapter.pageCount * item.progress).toInt()}/${item.chapter.pageCount}"
} else {
""
}
@@ -200,7 +200,7 @@ fun DownloadsItem(
)
}
DropdownIconButton(
item.mangaId to item.chapterIndex,
item.chapter.id,
{
DropdownMenuItem(onClick = { onClickCancel(item.chapter) }) {
Text(stringResource(MR.strings.action_cancel))
@@ -231,13 +231,13 @@ fun DownloadsItem(
@Stable
@Composable
private fun getActionItems(
downloadStatus: DownloaderStatus,
downloadStatus: DownloaderState,
startDownloading: () -> Unit,
pauseDownloading: () -> Unit,
clearQueue: () -> Unit,
): ImmutableList<ActionItem> =
listOf(
if (downloadStatus == DownloaderStatus.Started) {
if (downloadStatus == DownloaderState.STARTED) {
ActionItem(
stringResource(MR.strings.action_pause),
Icons.Rounded.Pause,

View File

@@ -22,7 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import ca.gosyer.jui.domain.base.WebsocketService
import ca.gosyer.jui.domain.download.model.DownloaderStatus
import ca.gosyer.jui.domain.download.model.DownloaderState
import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.ui.base.LocalViewModels
import ca.gosyer.jui.uicore.resources.stringResource
@@ -43,7 +43,7 @@ fun DownloadsExtraInfo() {
WebsocketService.Status.RUNNING -> {
if (list.isNotEmpty()) {
val remainingDownloads = stringResource(MR.strings.downloads_remaining, list.size)
if (downloaderStatus == DownloaderStatus.Stopped) {
if (downloaderStatus == DownloaderState.STOPPED) {
stringResource(MR.strings.downloads_paused) + "" + remainingDownloads
} else {
remainingDownloads

View File

@@ -96,7 +96,7 @@ class UpdatesScreenViewModel(
}
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.onEach { (mangaIds, queue) ->
val chapters = queue.filter { it.mangaId in mangaIds }
val chapters = queue.filter { it.manga.id in mangaIds }
updates.value.filterIsInstance<UpdatesUI.Item>().forEach {
it.chapterDownloadItem.updateFrom(chapters)
}

View File

@@ -24,7 +24,7 @@ actual fun ComponentRegistryBuilder.register(
contextWrapper: ContextWrapper,
http: Http,
) {
setupDefaultComponents(httpClient = { http })
setupDefaultComponents(httpClient = { http.value })
}
actual fun DiskCacheBuilder.configure(

View File

@@ -9,11 +9,13 @@ package ca.gosyer.jui.ui.downloads
import ca.gosyer.jui.domain.base.WebsocketService
import ca.gosyer.jui.domain.download.service.DownloadService
import ca.gosyer.jui.uicore.vm.ContextWrapper
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
internal actual fun startDownloadService(
contextWrapper: ContextWrapper,
downloadService: DownloadService,
actions: WebsocketService.Actions,
) {
downloadService.init()
downloadService.getSubscription().launchIn(GlobalScope)
}

View File

@@ -28,7 +28,7 @@ suspend fun imageFromUrl(
url: String,
block: HttpRequestBuilder.() -> Unit,
): ImageBitmap =
client.get(url) {
client.value.get(url) {
expectSuccess = true
block()
}.toImageBitmap()

View File

@@ -29,7 +29,7 @@ actual fun ComponentRegistryBuilder.register(
contextWrapper: ContextWrapper,
http: Http,
) {
setupDefaultComponents(httpClient = { http })
setupDefaultComponents(httpClient = { http.value })
}
actual fun DiskCacheBuilder.configure(

View File

@@ -9,11 +9,13 @@ package ca.gosyer.jui.ui.downloads
import ca.gosyer.jui.domain.base.WebsocketService
import ca.gosyer.jui.domain.download.service.DownloadService
import ca.gosyer.jui.uicore.vm.ContextWrapper
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
internal actual fun startDownloadService(
contextWrapper: ContextWrapper,
downloadService: DownloadService,
actions: WebsocketService.Actions,
) {
downloadService.init()
downloadService.getSubscription().launchIn(GlobalScope)
}