Compare commits

...

21 Commits

Author SHA1 Message Date
Aria Moradi dfa59a1691 bump version to v0.4.2
CI Publish / Validate Gradle Wrapper (push) Successful in 11s
CI Publish / Build artifacts and release (push) Failing after 17s
2021-05-30 04:04:11 +04:30
Aria Moradi 5023e96301 Implemented Dowloads front-end 2021-05-30 04:01:49 +04:30
Aria Moradi 224c24ee9f a little reminder 2021-05-30 02:21:43 +04:30
Aria Moradi e3b154cf9e Merge branch 'master' of github.com:Suwayomi/Tachidesk 2021-05-29 23:59:29 +04:30
Aria Moradi d249867c4c finishing touches of download backend, done @jipfr's requests 2021-05-29 23:57:22 +04:30
Aria Moradi b56045e984 downloader backend done 2021-05-29 23:05:51 +04:30
Manchewable 3777cc646e Improve continuous horizontal reader (#110)
* differentiate ContinuesHorizontalLTR and ContinuesHorizontalRTL

* fix displaying pages in horizontal viewer

* add scroll handler for horizontal mode

* update curPage when images pass through center of the screen

* add click events to navigate pages

* remove console.log

* fix click mapping for ContinuesHorizontalRTL

* remove disable eslint inline comment

* fix ContinuesHorizontalRTL not updating curPage on scroll

* add ability to click to drag

* add margin in between images
2021-05-29 19:41:59 +04:30
Manchewable aa5a1083d0 fit images to height (#108) 2021-05-28 23:27:31 +04:30
Manchewable 2ae5e0742e reference to img elements directly (#106) 2021-05-28 23:25:04 +04:30
Aria Moradi e5e875c54a closes #100 2021-05-28 20:21:05 +04:30
Aria Moradi 1a99ec76e4 spinner image, closes #77 2021-05-28 19:37:26 +04:30
Manchewable 1b122d1157 Add a Double Page Viewer (#105)
* add double page reader

* implement singleRTL

* add on image load handler

* add retry display time interval

* remove comments

* add double page wrapper

* fix image getting out of bounds

* remove comments

* remove unused styles

* return imageStyle as type CSSProperties

* rename DoublePagedReader to DoublePagedPager
2021-05-28 17:06:55 +04:30
Aria Moradi 77f2f8cc18 add copyright notice to files that miss it 2021-05-28 16:23:26 +04:30
Aria Moradi f0a99980b6 fixed issue with clearing up orphan chapters 2021-05-28 03:46:32 +04:30
Aria Moradi b0d43ffe69 anime filter everywhere
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build artifacts and release (push) Failing after 17s
2021-05-28 03:02:14 +04:30
Aria Moradi 16cb0184a4 fix catalog source imports 2021-05-28 02:53:36 +04:30
Aria Moradi f211a33ea3 bump to v0.4.1 2021-05-28 02:49:01 +04:30
Aria Moradi 440c815189 missed from previous commit 2021-05-28 02:46:19 +04:30
Aria Moradi 25829aacfd new anime library 2021-05-28 02:43:30 +04:30
Aria Moradi 700a739f95 probably fixes http leaks (by @Syer10) 2021-05-27 22:45:44 +04:30
Aria Moradi d9620bec05 fix getManga returning false for inLibrary 2021-05-27 22:30:29 +04:30
63 changed files with 1444 additions and 255 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ sourceSets {
} }
// should be bumped with each stable release // should be bumped with each stable release
val tachideskVersion = "v0.4.0" val tachideskVersion = "v0.4.2"
// counts commit count on master // counts commit count on master
val tachideskRevision = runCatching { val tachideskRevision = runCatching {
@@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.animesource
import eu.kanade.tachiyomi.source.model.AnimesPage import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.animesource.model.AnimesPage
import rx.Observable import rx.Observable
interface AnimeCatalogueSource : AnimeSource { interface AnimeCatalogueSource : AnimeSource {
@@ -30,7 +30,7 @@ interface AnimeCatalogueSource : AnimeSource {
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
fun fetchSearchAnime(page: Int, query: String, filters: FilterList): Observable<AnimesPage> fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage>
/** /**
* Returns an observable containing a page with a list of latest anime updates. * Returns an observable containing a page with a list of latest anime updates.
@@ -42,5 +42,5 @@ interface AnimeCatalogueSource : AnimeSource {
/** /**
* Returns the list of filters for the source. * Returns the list of filters for the source.
*/ */
fun getFilterList(): FilterList fun getFilterList(): AnimeFilterList
} }
@@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.animesource
import eu.kanade.tachiyomi.source.model.SAnime import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.source.model.SEpisode import eu.kanade.tachiyomi.animesource.model.SEpisode
import rx.Observable import rx.Observable
/** /**
@@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.animesource
/** /**
* A factory for creating sources at runtime. * A factory for creating sources at runtime.
@@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.animesource
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.source.model.SAnime import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.source.model.SEpisode import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.source.online.AnimeHttpSource import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import rx.Observable import rx.Observable
open class AnimeSourceManager(private val context: Context) { open class AnimeSourceManager(private val context: Context) {
@@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.animesource
import android.support.v7.preference.PreferenceScreen import android.support.v7.preference.PreferenceScreen
@@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.animesource.model
sealed class AnimeFilter<T>(val name: String, var state: T) {
open class Header(name: String) : AnimeFilter<Any>(name, 0)
open class Separator(name: String = "") : AnimeFilter<Any>(name, 0)
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : AnimeFilter<Int>(name, state)
abstract class Text(name: String, state: String = "") : AnimeFilter<String>(name, state)
abstract class CheckBox(name: String, state: Boolean = false) : AnimeFilter<Boolean>(name, state)
abstract class TriState(name: String, state: Int = STATE_IGNORE) : AnimeFilter<Int>(name, state) {
fun isIgnored() = state == STATE_IGNORE
fun isIncluded() = state == STATE_INCLUDE
fun isExcluded() = state == STATE_EXCLUDE
companion object {
const val STATE_IGNORE = 0
const val STATE_INCLUDE = 1
const val STATE_EXCLUDE = 2
}
}
abstract class Group<V>(name: String, state: List<V>) : AnimeFilter<List<V>>(name, state)
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) :
AnimeFilter<Sort.Selection?>(name, state) {
data class Selection(val index: Int, val ascending: Boolean)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AnimeFilter<*>) return false
return name == other.name && state == other.state
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + (state?.hashCode() ?: 0)
return result
}
}
@@ -0,0 +1,6 @@
package eu.kanade.tachiyomi.animesource.model
data class AnimeFilterList(val list: List<AnimeFilter<*>>) : List<AnimeFilter<*>> by list {
constructor(vararg fs: AnimeFilter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
}
@@ -1,3 +1,3 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.animesource.model
data class AnimesPage(val animes: List<SAnime>, val hasNextPage: Boolean) data class AnimesPage(val animes: List<SAnime>, val hasNextPage: Boolean)
@@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.animesource.model
import java.io.Serializable import java.io.Serializable
@@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.animesource.model
class SAnimeImpl : SAnime { class SAnimeImpl : SAnime {
@@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.animesource.model
import java.io.Serializable import java.io.Serializable
@@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.animesource.model
class SEpisodeImpl : SEpisode { class SEpisodeImpl : SEpisode {
@@ -1,15 +1,15 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.animesource.online
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.source.AnimeCatalogueSource
import eu.kanade.tachiyomi.source.model.AnimesPage
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SAnime
import eu.kanade.tachiyomi.source.model.SEpisode
import okhttp3.Headers import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -118,7 +118,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
override fun fetchSearchAnime(page: Int, query: String, filters: FilterList): Observable<AnimesPage> { override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
return client.newCall(searchAnimeRequest(page, query, filters)) return client.newCall(searchAnimeRequest(page, query, filters))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
@@ -133,7 +133,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
protected abstract fun searchAnimeRequest(page: Int, query: String, filters: FilterList): Request protected abstract fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request
/** /**
* Parses the response from the site and returns a [AnimesPage] object. * Parses the response from the site and returns a [AnimesPage] object.
@@ -380,7 +380,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
/** /**
* Returns the list of filters for the source. * Returns the list of filters for the source.
*/ */
override fun getFilterList() = FilterList() override fun getFilterList() = AnimeFilterList()
companion object { companion object {
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63" const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63"
@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import rx.Observable import rx.Observable
@@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.animesource.online
import eu.kanade.tachiyomi.source.model.AnimesPage import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SAnime
import eu.kanade.tachiyomi.source.model.SEpisode
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
@@ -7,8 +7,8 @@ package suwayomi.anime.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.SAnime
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
@@ -102,7 +102,7 @@ object Anime {
fetchedAnime.description, fetchedAnime.description,
fetchedAnime.genre, fetchedAnime.genre,
AnimeStatus.valueOf(fetchedAnime.status).name, AnimeStatus.valueOf(fetchedAnime.status).name,
false, animeEntry[AnimeTable.inLibrary],
getAnimeSource(animeEntry[AnimeTable.sourceReference]), getAnimeSource(animeEntry[AnimeTable.sourceReference]),
true true
) )
@@ -7,7 +7,7 @@ package suwayomi.anime.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.AnimesPage import eu.kanade.tachiyomi.animesource.model.AnimesPage
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
@@ -7,8 +7,8 @@ package suwayomi.anime.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.SAnime import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.source.model.SEpisode import eu.kanade.tachiyomi.animesource.model.SEpisode
import org.jetbrains.exposed.sql.SortOrder.DESC import org.jetbrains.exposed.sql.SortOrder.DESC
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
@@ -8,11 +8,11 @@ package suwayomi.anime.impl.extension
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.AnimeCatalogueSource
import eu.kanade.tachiyomi.source.AnimeSource
import eu.kanade.tachiyomi.source.AnimeSourceFactory
import mu.KotlinLogging import mu.KotlinLogging
import okhttp3.Request import okhttp3.Request
import okio.buffer import okio.buffer
@@ -13,6 +13,8 @@ import com.google.gson.JsonArray
import com.google.gson.JsonParser import com.google.gson.JsonParser
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.Request import okhttp3.Request
import suwayomi.anime.impl.util.PackageTools.LIB_VERSION_MAX
import suwayomi.anime.impl.util.PackageTools.LIB_VERSION_MIN
import suwayomi.anime.model.dataclass.AnimeExtensionDataClass import suwayomi.anime.model.dataclass.AnimeExtensionDataClass
import suwayomi.tachidesk.impl.util.network.UnzippingInterceptor import suwayomi.tachidesk.impl.util.network.UnzippingInterceptor
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@@ -21,15 +23,12 @@ object ExtensionGithubApi {
const val BASE_URL = "https://raw.githubusercontent.com" const val BASE_URL = "https://raw.githubusercontent.com"
const val REPO_URL_PREFIX = "$BASE_URL/jmir1/tachiyomi-extensions/repo" const val REPO_URL_PREFIX = "$BASE_URL/jmir1/tachiyomi-extensions/repo"
private const val LIB_VERSION_MIN = 1.3
private const val LIB_VERSION_MAX = 1.3
private fun parseResponse(json: JsonArray): List<OnlineExtension> { private fun parseResponse(json: JsonArray): List<OnlineExtension> {
return json return json
.map { it.asJsonObject } .map { it.asJsonObject }
.filter { element -> .filter { element ->
val versionName = element["version"].string val versionName = element["version"].string
val libVersion = versionName.substringBeforeLast('.').toDouble() val libVersion = versionName.substringBeforeLast('.').toInt()
libVersion in LIB_VERSION_MIN..LIB_VERSION_MAX libVersion in LIB_VERSION_MIN..LIB_VERSION_MAX
} }
.map { element -> .map { element ->
@@ -1,5 +1,12 @@
package suwayomi.anime.impl.extension.github package suwayomi.anime.impl.extension.github
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class OnlineExtension( data class OnlineExtension(
val name: String, val name: String,
val pkgName: String, val pkgName: String,
@@ -7,9 +7,9 @@ package suwayomi.anime.impl.util
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.AnimeSource import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.source.AnimeSourceFactory import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
import eu.kanade.tachiyomi.source.online.AnimeHttpSource import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.kodein.di.DI import org.kodein.di.DI
@@ -40,8 +40,8 @@ object PackageTools {
const val METADATA_SOURCE_CLASS = "tachiyomi.animeextension.class" const val METADATA_SOURCE_CLASS = "tachiyomi.animeextension.class"
const val METADATA_SOURCE_FACTORY = "tachiyomi.animeextension.factory" const val METADATA_SOURCE_FACTORY = "tachiyomi.animeextension.factory"
const val METADATA_NSFW = "tachiyomi.animeextension.nsfw" const val METADATA_NSFW = "tachiyomi.animeextension.nsfw"
const val LIB_VERSION_MIN = 1.3 const val LIB_VERSION_MIN = 10
const val LIB_VERSION_MAX = 1.3 const val LIB_VERSION_MAX = 10
private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c" // jmir1's key private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c" // jmir1's key
var trustedSignatures = mutableSetOf<String>() + officialSignature var trustedSignatures = mutableSetOf<String>() + officialSignature
@@ -7,7 +7,7 @@ package suwayomi.anime.model.table
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.SAnime import eu.kanade.tachiyomi.animesource.model.SAnime
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.impl.MangaList.proxyThumbnailUrl import suwayomi.tachidesk.impl.MangaList.proxyThumbnailUrl
@@ -1,6 +1,6 @@
package suwayomi.server.database.migration package suwayomi.server.database.migration
import eu.kanade.tachiyomi.source.model.SAnime import eu.kanade.tachiyomi.animesource.model.SAnime
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
@@ -0,0 +1,24 @@
package suwayomi.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.vendors.currentDialect
import suwayomi.server.database.migration.lib.Migration
@Suppress("ClassName", "unused")
class M0007_ChapterIsDownloaded : Migration() {
/** this migration added IS_DOWNLOADED to CHAPTER */
override fun run() {
with(TransactionManager.current()) {
exec("ALTER TABLE CHAPTER ADD COLUMN IS_DOWNLOADED BOOLEAN DEFAULT FALSE")
commit()
currentDialect.resetCaches()
}
}
}
@@ -0,0 +1,24 @@
package suwayomi.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.vendors.currentDialect
import suwayomi.server.database.migration.lib.Migration
@Suppress("ClassName", "unused")
class M0008_ChapterPageCount : Migration() {
/** this migration added PAGE_COUNT to CHAPTER */
override fun run() {
with(TransactionManager.current()) {
exec("ALTER TABLE CHAPTER ADD COLUMN PAGE_COUNT INT DEFAULT -1")
commit()
currentDialect.resetCaches()
}
}
}
@@ -0,0 +1,25 @@
package suwayomi.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.vendors.currentDialect
import suwayomi.server.database.migration.lib.Migration
@Suppress("ClassName", "unused")
class M0009_ChapterLastReadAt : Migration() {
/** this migration added PAGE_COUNT to CHAPTER */
override fun run() {
with(TransactionManager.current()) {
// BIGINT == Long
exec("ALTER TABLE CHAPTER ADD COLUMN LAST_READ_AT BIGINT DEFAULT 0")
commit()
currentDialect.resetCaches()
}
}
}
@@ -1,5 +1,12 @@
package suwayomi.server.util package suwayomi.server.util
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import mu.KotlinLogging import mu.KotlinLogging
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -33,6 +33,7 @@ import suwayomi.tachidesk.impl.Source.getSourceList
import suwayomi.tachidesk.impl.backup.BackupFlags import suwayomi.tachidesk.impl.backup.BackupFlags
import suwayomi.tachidesk.impl.backup.legacy.LegacyBackupExport.createLegacyBackup import suwayomi.tachidesk.impl.backup.legacy.LegacyBackupExport.createLegacyBackup
import suwayomi.tachidesk.impl.backup.legacy.LegacyBackupImport.restoreLegacyBackup import suwayomi.tachidesk.impl.backup.legacy.LegacyBackupImport.restoreLegacyBackup
import suwayomi.tachidesk.impl.download.DownloadManager
import suwayomi.tachidesk.impl.extension.Extension.getExtensionIcon import suwayomi.tachidesk.impl.extension.Extension.getExtensionIcon
import suwayomi.tachidesk.impl.extension.Extension.installExtension import suwayomi.tachidesk.impl.extension.Extension.installExtension
import suwayomi.tachidesk.impl.extension.Extension.uninstallExtension import suwayomi.tachidesk.impl.extension.Extension.uninstallExtension
@@ -383,15 +384,56 @@ object TachideskAPI {
// Download queue stats // Download queue stats
app.ws("/api/v1/downloads") { ws -> app.ws("/api/v1/downloads") { ws ->
ws.onConnect { ctx -> ws.onConnect { ctx ->
// TODO: send current stat DownloadManager.addClient(ctx)
// TODO: add to downlad subscribers DownloadManager.notifyClient(ctx)
} }
ws.onMessage { ws.onMessage { ctx ->
// TODO: send current stat DownloadManager.handleRequest(ctx)
} }
ws.onClose { ctx -> ws.onClose { ctx ->
// TODO: remove from subscribers DownloadManager.removeClient(ctx)
} }
} }
// Start the downloader
app.get("/api/v1/downloads/start") { ctx ->
DownloadManager.start()
ctx.status(200)
}
// Stop the downloader
app.get("/api/v1/downloads/stop") { ctx ->
DownloadManager.stop()
ctx.status(200)
}
// clear download queue
app.get("/api/v1/downloads/clear") { ctx ->
DownloadManager.clear()
ctx.status(200)
}
// Queue chapter for download
app.get("/api/v1/download/:mangaId/chapter/:chapterIndex") { ctx ->
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val mangaId = ctx.pathParam("mangaId").toInt()
DownloadManager.enqueue(chapterIndex, mangaId)
ctx.status(200)
}
// delete chapter from download queue
app.delete("/api/v1/download/:mangaId/chapter/:chapterIndex") { ctx ->
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val mangaId = ctx.pathParam("mangaId").toInt()
DownloadManager.unqueue(chapterIndex, mangaId)
ctx.status(200)
}
} }
} }
@@ -24,6 +24,7 @@ import suwayomi.tachidesk.model.table.ChapterTable
import suwayomi.tachidesk.model.table.MangaTable import suwayomi.tachidesk.model.table.MangaTable
import suwayomi.tachidesk.model.table.PageTable import suwayomi.tachidesk.model.table.PageTable
import suwayomi.tachidesk.model.table.toDataClass import suwayomi.tachidesk.model.table.toDataClass
import java.time.Instant
object Chapter { object Chapter {
/** get chapter list when showing a manga */ /** get chapter list when showing a manga */
@@ -88,7 +89,7 @@ object Chapter {
// clear any orphaned chapters that are in the db but not in `chapterList` // clear any orphaned chapters that are in the db but not in `chapterList`
val dbChapterCount = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count() } val dbChapterCount = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count() }
if (dbChapterCount > chapterCount) { // we got some clean up due if (dbChapterCount > chapterCount) { // we got some clean up due
val dbChapterList = transaction { ChapterTable.select { ChapterTable.manga eq mangaId } } val dbChapterList = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.toList() }
dbChapterList.forEach { dbChapterList.forEach {
if (it[ChapterTable.chapterIndex] >= chapterList.size || if (it[ChapterTable.chapterIndex] >= chapterList.size ||
@@ -122,9 +123,14 @@ object Chapter {
dbChapter[ChapterTable.isRead], dbChapter[ChapterTable.isRead],
dbChapter[ChapterTable.isBookmarked], dbChapter[ChapterTable.isBookmarked],
dbChapter[ChapterTable.lastPageRead], dbChapter[ChapterTable.lastPageRead],
dbChapter[ChapterTable.lastReadAt],
chapterCount - index, chapterCount - index,
chapterList.size dbChapter[ChapterTable.isDownloaded],
dbChapter[ChapterTable.pageCount],
chapterList.size,
) )
} }
} }
@@ -136,54 +142,68 @@ object Chapter {
(ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId) (ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId)
}.first() }.first()
} }
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val pageList = source.fetchPageList( return if (!chapterEntry[ChapterTable.isDownloaded]) {
SChapter.create().apply { val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
url = chapterEntry[ChapterTable.url] val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
name = chapterEntry[ChapterTable.name]
}
).awaitSingle()
val chapterId = chapterEntry[ChapterTable.id].value val pageList = source.fetchPageList(
val chapterCount = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count() } SChapter.create().apply {
url = chapterEntry[ChapterTable.url]
name = chapterEntry[ChapterTable.name]
}
).awaitSingle()
// update page list for this chapter val chapterId = chapterEntry[ChapterTable.id].value
transaction { val chapterCount = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count() }
pageList.forEach { page ->
val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }.firstOrNull() } // update page list for this chapter
if (pageEntry == null) { transaction {
PageTable.insert { pageList.forEach { page ->
it[index] = page.index val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }.firstOrNull() }
it[url] = page.url if (pageEntry == null) {
it[imageUrl] = page.imageUrl PageTable.insert {
it[chapter] = chapterId it[index] = page.index
} it[url] = page.url
} else { it[imageUrl] = page.imageUrl
PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }) { it[chapter] = chapterId
it[url] = page.url }
it[imageUrl] = page.imageUrl } else {
PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }) {
it[url] = page.url
it[imageUrl] = page.imageUrl
}
} }
} }
} }
val pageCount = pageList.count()
transaction {
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }) {
it[ChapterTable.pageCount] = pageCount
}
}
return ChapterDataClass(
chapterEntry[ChapterTable.url],
chapterEntry[ChapterTable.name],
chapterEntry[ChapterTable.date_upload],
chapterEntry[ChapterTable.chapter_number],
chapterEntry[ChapterTable.scanlator],
mangaId,
chapterEntry[ChapterTable.isRead],
chapterEntry[ChapterTable.isBookmarked],
chapterEntry[ChapterTable.lastPageRead],
chapterEntry[ChapterTable.lastReadAt],
chapterEntry[ChapterTable.chapterIndex],
chapterEntry[ChapterTable.isDownloaded],
pageCount,
chapterCount.toInt()
)
} else {
ChapterTable.toDataClass(chapterEntry)
} }
return ChapterDataClass(
chapterEntry[ChapterTable.url],
chapterEntry[ChapterTable.name],
chapterEntry[ChapterTable.date_upload],
chapterEntry[ChapterTable.chapter_number],
chapterEntry[ChapterTable.scanlator],
mangaId,
chapterEntry[ChapterTable.isRead],
chapterEntry[ChapterTable.isBookmarked],
chapterEntry[ChapterTable.lastPageRead],
chapterEntry[ChapterTable.chapterIndex],
chapterCount.toInt(),
pageList.count()
)
} }
fun modifyChapter(mangaId: Int, chapterIndex: Int, isRead: Boolean?, isBookmarked: Boolean?, markPrevRead: Boolean?, lastPageRead: Int?) { fun modifyChapter(mangaId: Int, chapterIndex: Int, isRead: Boolean?, isBookmarked: Boolean?, markPrevRead: Boolean?, lastPageRead: Int?) {
@@ -198,6 +218,7 @@ object Chapter {
} }
lastPageRead?.also { lastPageRead?.also {
update[ChapterTable.lastPageRead] = it update[ChapterTable.lastPageRead] = it
update[ChapterTable.lastReadAt] = Instant.now().epochSecond
} }
} }
} }
@@ -102,7 +102,7 @@ object Manga {
fetchedManga.description, fetchedManga.description,
fetchedManga.genre, fetchedManga.genre,
MangaStatus.valueOf(fetchedManga.status).name, MangaStatus.valueOf(fetchedManga.status).name,
false, mangaEntry[MangaTable.inLibrary],
getSource(mangaEntry[MangaTable.sourceReference]), getSource(mangaEntry[MangaTable.sourceReference]),
true true
) )
@@ -0,0 +1,129 @@
package suwayomi.tachidesk.impl.download
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import io.javalin.websocket.WsContext
import io.javalin.websocket.WsMessageContext
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.impl.download.model.DownloadChapter
import suwayomi.tachidesk.impl.download.model.DownloadState.Downloading
import suwayomi.tachidesk.impl.download.model.DownloadStatus
import suwayomi.tachidesk.model.table.ChapterTable
import suwayomi.tachidesk.model.table.toDataClass
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
object DownloadManager {
private val clients = ConcurrentHashMap<String, WsContext>()
private val downloadQueue = CopyOnWriteArrayList<DownloadChapter>()
private var downloader: Downloader? = null
fun addClient(ctx: WsContext) {
clients[ctx.sessionId] = ctx
}
fun removeClient(ctx: WsContext) {
clients.remove(ctx.sessionId)
}
fun notifyClient(ctx: WsContext) {
ctx.send(
getStatus()
)
}
fun handleRequest(ctx: WsMessageContext) {
when (ctx.message()) {
"STATUS" -> notifyClient(ctx)
else -> ctx.send(
"""
|Invalid command.
|Supported commands are:
| - STATUS
| sends the current download status
|""".trimMargin()
)
}
}
private fun notifyAllClients() {
val status = getStatus()
clients.forEach {
it.value.send(status)
}
}
private fun getStatus(): DownloadStatus {
return DownloadStatus(
if (downloader == null ||
downloadQueue.none { it.state == Downloading }
) "Stopped" else "Started",
downloadQueue
)
}
fun enqueue(chapterIndex: Int, mangaId: Int) {
if (downloadQueue.none { it.mangaId == mangaId && it.chapterIndex == chapterIndex }) {
downloadQueue.add(
DownloadChapter(
chapterIndex,
mangaId,
chapter = ChapterTable.toDataClass(
transaction {
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }
.first()
}
)
)
)
start()
}
notifyAllClients()
}
fun unqueue(chapterIndex: Int, mangaId: Int) {
downloadQueue.removeIf { it.mangaId == mangaId && it.chapterIndex == chapterIndex }
notifyAllClients()
}
fun start() {
if (downloader != null && !downloader?.isAlive!!) // doesn't exist or is dead
downloader = null
if (downloader == null) {
downloader = Downloader(downloadQueue) { notifyAllClients() }
downloader!!.start()
}
notifyAllClients()
}
fun stop() {
downloader?.let {
synchronized(it.shouldStop) {
it.shouldStop = true
}
}
downloader = null
notifyAllClients()
}
fun clear() {
stop()
downloadQueue.clear()
notifyAllClients()
}
}
enum class DownloaderState(val state: Int) {
Stopped(0),
Running(1),
Paused(2),
}
@@ -1,8 +1,5 @@
package suwayomi.tachidesk.impl.download package suwayomi.tachidesk.impl.download
import org.jetbrains.exposed.sql.ResultRow
import java.util.concurrent.LinkedBlockingQueue
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
* *
@@ -10,19 +7,75 @@ import java.util.concurrent.LinkedBlockingQueue
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class Download( import kotlinx.coroutines.runBlocking
val chapter: ResultRow, import org.jetbrains.exposed.sql.and
) import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.impl.Chapter.getChapter
import suwayomi.tachidesk.impl.Page.getPageImage
import suwayomi.tachidesk.impl.download.model.DownloadChapter
import suwayomi.tachidesk.impl.download.model.DownloadState.Downloading
import suwayomi.tachidesk.impl.download.model.DownloadState.Error
import suwayomi.tachidesk.impl.download.model.DownloadState.Finished
import suwayomi.tachidesk.impl.download.model.DownloadState.Queued
import suwayomi.tachidesk.model.table.ChapterTable
import java.util.concurrent.CopyOnWriteArrayList
private val downloadQueue = LinkedBlockingQueue<Download>() class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter>, val notifier: () -> Unit) : Thread() {
var shouldStop: Boolean = false
class Downloader { class DownloadShouldStopException : Exception()
fun start() { fun step() {
TODO() notifier()
synchronized(shouldStop) {
if (shouldStop) throw DownloadShouldStopException()
}
} }
fun stop() { override fun run() {
TODO() do {
val download = downloadQueue.firstOrNull {
it.state == Queued ||
(it.state == Error && it.tries < 3) // 3 re-tries per download
} ?: break
try {
download.state = Downloading
step()
download.chapter = runBlocking { getChapter(download.chapterIndex, download.mangaId) }
step()
val pageCount = download.chapter!!.pageCount
for (pageNum in 0 until pageCount) {
runBlocking { getPageImage(download.mangaId, download.chapterIndex, pageNum) }
// TODO: retry on error with 2,4,8 seconds of wait
// TODO: download multiple pages at once, possible solution: rx observer's strategy is used in Tachiyomi
// TODO: fine grained download percentage
download.progress = (pageNum + 1).toFloat() / pageCount
step()
}
download.state = Finished
transaction {
ChapterTable.update({ (ChapterTable.manga eq download.mangaId) and (ChapterTable.chapterIndex eq download.chapterIndex) }) {
it[isDownloaded] = true
}
}
step()
downloadQueue.removeIf { it.mangaId == download.mangaId && it.chapterIndex == download.chapterIndex }
step()
} catch (e: DownloadShouldStopException) {
println("Downloader was stopped")
downloadQueue.filter { it.state == Downloading }.forEach { it.state = Queued }
} catch (e: Exception) {
println("Downloader faced an exception")
downloadQueue.filter { it.state == Downloading }.forEach { it.state = Error; it.tries++ }
e.printStackTrace()
} finally {
notifier()
}
} while (!shouldStop)
} }
} }
@@ -0,0 +1,19 @@
package suwayomi.tachidesk.impl.download.model
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import suwayomi.tachidesk.model.dataclass.ChapterDataClass
class DownloadChapter(
val chapterIndex: Int,
val mangaId: Int,
var state: DownloadState = DownloadState.Queued,
var progress: Float = 0f,
var tries: Int = 0,
var chapter: ChapterDataClass? = null,
)
@@ -0,0 +1,15 @@
package suwayomi.tachidesk.impl.download.model
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
enum class DownloadState(val state: Int) {
Queued(0),
Downloading(1),
Finished(2),
Error(3),
}
@@ -0,0 +1,13 @@
package suwayomi.tachidesk.impl.download.model
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class DownloadStatus(
val status: String,
val queue: List<DownloadChapter>,
)
@@ -13,6 +13,8 @@ import com.google.gson.JsonArray
import com.google.gson.JsonParser import com.google.gson.JsonParser
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.Request import okhttp3.Request
import suwayomi.tachidesk.impl.util.PackageTools.LIB_VERSION_MAX
import suwayomi.tachidesk.impl.util.PackageTools.LIB_VERSION_MIN
import suwayomi.tachidesk.impl.util.network.UnzippingInterceptor import suwayomi.tachidesk.impl.util.network.UnzippingInterceptor
import suwayomi.tachidesk.model.dataclass.ExtensionDataClass import suwayomi.tachidesk.model.dataclass.ExtensionDataClass
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@@ -21,9 +23,6 @@ object ExtensionGithubApi {
const val BASE_URL = "https://raw.githubusercontent.com" const val BASE_URL = "https://raw.githubusercontent.com"
const val REPO_URL_PREFIX = "$BASE_URL/tachiyomiorg/tachiyomi-extensions/repo" const val REPO_URL_PREFIX = "$BASE_URL/tachiyomiorg/tachiyomi-extensions/repo"
private const val LIB_VERSION_MIN = 1.2
private const val LIB_VERSION_MAX = 1.2
private fun parseResponse(json: JsonArray): List<OnlineExtension> { private fun parseResponse(json: JsonArray): List<OnlineExtension> {
return json return json
.map { it.asJsonObject } .map { it.asJsonObject }
@@ -1,5 +1,12 @@
package suwayomi.tachidesk.impl.extension.github package suwayomi.tachidesk.impl.extension.github
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class OnlineExtension( data class OnlineExtension(
val name: String, val name: String,
val pkgName: String, val pkgName: String,
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.impl.util.storage
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.closeQuietly
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
@@ -41,20 +42,22 @@ object CachedImageResponse {
val response = fetcher() val response = fetcher()
if (response.code == 200) { if (response.code == 200) {
val fullPath = "$filePath.tmp" val tmpSavePath = "$filePath.tmp"
val saveFile = File(fullPath) val tmpSaveFile = File(tmpSavePath)
response.body!!.source().saveTo(saveFile) response.body!!.source().saveTo(tmpSaveFile)
// find image type // find image type
val imageType = response.headers["content-type"] val imageType = response.headers["content-type"]
?: ImageUtil.findImageType { saveFile.inputStream() }?.mime ?: ImageUtil.findImageType { tmpSaveFile.inputStream() }?.mime
?: "image/jpeg" ?: "image/jpeg"
.substringAfter("image/")
saveFile.renameTo(File("$filePath.$imageType")) val actualSavePath = "$filePath.${imageType.substringAfter("/")}"
return pathToInputStream(fullPath) to imageType tmpSaveFile.renameTo(File(actualSavePath))
return pathToInputStream(actualSavePath) to imageType
} else { } else {
response.closeQuietly()
throw Exception("request error! ${response.code}") throw Exception("request error! ${response.code}")
} }
} }
@@ -24,12 +24,19 @@ data class ChapterDataClass(
/** last read page, zero means not read/no data */ /** last read page, zero means not read/no data */
val lastPageRead: Int, val lastPageRead: Int,
/** last read page, zero means not read/no data */
val lastReadAt: Long,
/** this chapter's index, starts with 1 */ /** this chapter's index, starts with 1 */
val index: Int, val index: Int,
/** is chapter downloaded */
val downloaded: Boolean,
/** used to construct pages in the front-end */
val pageCount: Int = -1,
/** total chapter count, used to calculate if there's a next and prev chapter */ /** total chapter count, used to calculate if there's a next and prev chapter */
val chapterCount: Int? = null, val chapterCount: Int? = null,
/** used to construct pages in the front-end */
val pageCount: Int? = null,
) )
@@ -9,6 +9,8 @@ package suwayomi.tachidesk.model.table
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.model.dataclass.ChapterDataClass import suwayomi.tachidesk.model.dataclass.ChapterDataClass
object ChapterTable : IntIdTable() { object ChapterTable : IntIdTable() {
@@ -21,10 +23,15 @@ object ChapterTable : IntIdTable() {
val isRead = bool("read").default(false) val isRead = bool("read").default(false)
val isBookmarked = bool("bookmark").default(false) val isBookmarked = bool("bookmark").default(false)
val lastPageRead = integer("last_page_read").default(0) val lastPageRead = integer("last_page_read").default(0)
val lastReadAt = long("last_read_at").default(0)
// index is reserved by a function // index is reserved by a function
val chapterIndex = integer("index") val chapterIndex = integer("index")
val isDownloaded = bool("is_downloaded").default(false)
val pageCount = integer("page_count").default(-1)
val manga = reference("manga", MangaTable) val manga = reference("manga", MangaTable)
} }
@@ -39,5 +46,9 @@ fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
chapterEntry[isRead], chapterEntry[isRead],
chapterEntry[isBookmarked], chapterEntry[isBookmarked],
chapterEntry[lastPageRead], chapterEntry[lastPageRead],
chapterEntry[lastReadAt],
chapterEntry[chapterIndex], chapterEntry[chapterIndex],
chapterEntry[isDownloaded],
chapterEntry[pageCount],
transaction { ChapterTable.select { ChapterTable.manga eq chapterEntry[manga].value }.count().toInt() },
) )
+1
View File
@@ -38,6 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^17.0.2", "@types/react": "^17.0.2",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "^17.0.2", "@types/react-dom": "^17.0.2",
"@types/react-lazyload": "^3.1.0", "@types/react-lazyload": "^3.1.0",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
+4
View File
@@ -34,6 +34,7 @@ import SourceAnimes from 'screens/anime/SourceAnimes';
import Reader from 'screens/manga/Reader'; import Reader from 'screens/manga/Reader';
import Player from 'screens/anime/Player'; import Player from 'screens/anime/Player';
import AnimeExtensions from 'screens/anime/AnimeExtensions'; import AnimeExtensions from 'screens/anime/AnimeExtensions';
import DownloadQueue from 'screens/manga/DownloadQueue';
export default function App() { export default function App() {
const [title, setTitle] = useState<string>('Tachidesk'); const [title, setTitle] = useState<string>('Tachidesk');
@@ -125,6 +126,9 @@ export default function App() {
<Route path="/manga/sources"> <Route path="/manga/sources">
<MangaSources /> <MangaSources />
</Route> </Route>
<Route path="/manga/downloads">
<DownloadQueue />
</Route>
<Route path="/manga/:mangaId/chapter/:chapterNum"> <Route path="/manga/:mangaId/chapter/:chapterNum">
<></> <></>
</Route> </Route>
@@ -0,0 +1,67 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useEffect, useState } from 'react';
import CircularProgress from '@material-ui/core/CircularProgress';
interface IProps {
src: string
alt: string
imgRef?: React.RefObject<HTMLImageElement>
spinnerClassName?: string
imgClassName?: string
onImageLoad?: () => void
}
export default function SpinnerImage(props: IProps) {
const {
src, alt, onImageLoad, imgRef, spinnerClassName, imgClassName,
} = props;
const [imageSrc, setImagsrc] = useState<string>('');
useEffect(() => {
const img = new Image();
img.src = src;
img.onload = () => {
setImagsrc(src);
onImageLoad?.();
};
return () => {
img.onload = null;
};
}, [src]);
if (imageSrc.length === 0) {
return (
// <div className={`${classes.image} ${classes.loadingImage}`}>
<div className={spinnerClassName}>
<CircularProgress thickness={5} />
</div>
);
}
return (
<img
className={imgClassName}
ref={imgRef}
src={imageSrc}
alt={alt}
/>
);
}
SpinnerImage.defaultProps = {
spinnerClassName: '',
imgClassName: '',
onImageLoad: () => {},
imgRef: undefined,
};
@@ -14,6 +14,7 @@ import ListItemIcon from '@material-ui/core/ListItemIcon';
import CollectionsBookmarkIcon from '@material-ui/icons/CollectionsBookmark'; import CollectionsBookmarkIcon from '@material-ui/icons/CollectionsBookmark';
import ExploreIcon from '@material-ui/icons/Explore'; import ExploreIcon from '@material-ui/icons/Explore';
import ExtensionIcon from '@material-ui/icons/Extension'; import ExtensionIcon from '@material-ui/icons/Extension';
import GetAppIcon from '@material-ui/icons/GetApp';
import ListItemText from '@material-ui/core/ListItemText'; import ListItemText from '@material-ui/core/ListItemText';
import SettingsIcon from '@material-ui/icons/Settings'; import SettingsIcon from '@material-ui/icons/Settings';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -87,6 +88,14 @@ export default function TemporaryDrawer({ drawerOpen, setDrawerOpen }: IProps) {
<ListItemText primary="Anime Sources" /> <ListItemText primary="Anime Sources" />
</ListItem> </ListItem>
</Link> </Link>
<Link to="/manga/downloads" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="Manga Download Queue">
<ListItemIcon>
<GetAppIcon />
</ListItemIcon>
<ListItemText primary="Manga Download Queue" />
</ListItem>
</Link>
<Link to="/settings" style={{ color: 'inherit', textDecoration: 'none' }}> <Link to="/settings" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="settings"> <ListItem button key="settings">
<ListItemIcon> <ListItemIcon>
@@ -47,12 +47,13 @@ const useStyles = makeStyles((theme) => ({
interface IProps{ interface IProps{
chapter: IChapter chapter: IChapter
triggerChaptersUpdate: () => void triggerChaptersUpdate: () => void
downloadingString: string
} }
export default function ChapterCard(props: IProps) { export default function ChapterCard(props: IProps) {
const classes = useStyles(); const classes = useStyles();
const theme = useTheme(); const theme = useTheme();
const { chapter, triggerChaptersUpdate } = props; const { chapter, triggerChaptersUpdate, downloadingString } = props;
const dateStr = chapter.uploadDate && new Date(chapter.uploadDate).toISOString().slice(0, 10); const dateStr = chapter.uploadDate && new Date(chapter.uploadDate).toISOString().slice(0, 10);
@@ -75,6 +76,11 @@ export default function ChapterCard(props: IProps) {
.then(() => triggerChaptersUpdate()); .then(() => triggerChaptersUpdate());
}; };
const downloadChapter = () => {
client.get(`/api/v1/download/${chapter.mangaId}/chapter/${chapter.index}`);
handleClose();
};
const readChapterColor = theme.palette.type === 'dark' ? '#acacac' : '#b0b0b0'; const readChapterColor = theme.palette.type === 'dark' ? '#acacac' : '#b0b0b0';
return ( return (
<> <>
@@ -101,6 +107,7 @@ export default function ChapterCard(props: IProps) {
{chapter.scanlator} {chapter.scanlator}
{chapter.scanlator && ' '} {chapter.scanlator && ' '}
{dateStr} {dateStr}
{downloadingString}
</Typography> </Typography>
</div> </div>
</div> </div>
@@ -115,7 +122,8 @@ export default function ChapterCard(props: IProps) {
open={Boolean(anchorEl)} open={Boolean(anchorEl)}
onClose={handleClose} onClose={handleClose}
> >
{/* <MenuItem onClick={handleClose}>Download</MenuItem> */} {downloadingString.length === 0
&& <MenuItem onClick={downloadChapter}>Download</MenuItem> }
<MenuItem onClick={() => sendChange('bookmarked', !chapter.bookmarked)}> <MenuItem onClick={() => sendChange('bookmarked', !chapter.bookmarked)}>
{chapter.bookmarked && 'Remove bookmark'} {chapter.bookmarked && 'Remove bookmark'}
{!chapter.bookmarked && 'Bookmark'} {!chapter.bookmarked && 'Bookmark'}
+10 -6
View File
@@ -9,11 +9,11 @@ import React from 'react';
import { makeStyles } from '@material-ui/core/styles'; import { makeStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card'; import Card from '@material-ui/core/Card';
import CardActionArea from '@material-ui/core/CardActionArea'; import CardActionArea from '@material-ui/core/CardActionArea';
import CardMedia from '@material-ui/core/CardMedia';
import Typography from '@material-ui/core/Typography'; import Typography from '@material-ui/core/Typography';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Grid } from '@material-ui/core'; import { Grid } from '@material-ui/core';
import useLocalStorage from 'util/useLocalStorage'; import useLocalStorage from 'util/useLocalStorage';
import SpinnerImage from 'components/SpinnerImage';
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -43,6 +43,11 @@ const useStyles = makeStyles({
height: '100%', height: '100%',
width: '100%', width: '100%',
}, },
spinner: {
minHeight: '400px',
padding: '180px calc(50% - 20px)',
},
}); });
interface IProps { interface IProps {
@@ -63,12 +68,11 @@ const MangaCard = React.forwardRef((props: IProps, ref) => {
<Card className={classes.root} ref={ref}> <Card className={classes.root} ref={ref}>
<CardActionArea> <CardActionArea>
<div className={classes.wrapper}> <div className={classes.wrapper}>
<CardMedia <SpinnerImage
className={classes.image}
component="img"
alt={title} alt={title}
image={serverAddress + thumbnailUrl} src={serverAddress + thumbnailUrl}
title={title} spinnerClassName={classes.spinner}
imgClassName={classes.image}
/> />
<div className={classes.gradient} /> <div className={classes.gradient} />
<Typography className={classes.title} variant="h5" component="h2">{title}</Typography> <Typography className={classes.title} variant="h5" component="h2">{title}</Typography>
@@ -198,7 +198,7 @@ export default function MangaDetails(props: IProps) {
<div className={classes.top}> <div className={classes.top}>
<div className={classes.leftRight}> <div className={classes.leftRight}>
<div className={classes.leftSide}> <div className={classes.leftSide}>
<img src={`${serverAddress}${manga.thumbnailUrl}?x=${Math.random()}`} alt="Manga Thumbnail" /> <img src={`${serverAddress}${manga.thumbnailUrl}`} alt="Manga Thumbnail" />
</div> </div>
<div className={classes.rightSide}> <div className={classes.rightSide}>
<h1> <h1>
@@ -0,0 +1,62 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
const useStyles = (settings: IReaderSettings) => makeStyles({
image: {
display: 'block',
marginBottom: 0,
width: 'auto',
minHeight: '99vh',
height: 'auto',
maxHeight: '99vh',
objectFit: 'contain',
},
page: {
display: 'flex',
flexDirection: settings.readerType === 'DoubleLTR' ? 'row' : 'row-reverse',
justifyContent: 'center',
margin: '0 auto',
width: 'auto',
height: 'auto',
overflowX: 'scroll',
},
});
interface IProps {
index: number
image1src: string
image2src: string
settings: IReaderSettings
}
const DoublePage = React.forwardRef((props: IProps, ref: any) => {
const {
image1src, image2src, index, settings,
} = props;
const classes = useStyles(settings)();
return (
<div ref={ref} className={classes.page}>
<img
className={classes.image}
src={image1src}
alt={`Page #${index}`}
/>
<img
className={classes.image}
src={image2src}
alt={`Page #${index + 1}`}
/>
</div>
);
});
export default DoublePage;
@@ -5,9 +5,37 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import CircularProgress from '@material-ui/core/CircularProgress';
import { makeStyles } from '@material-ui/core/styles'; import { makeStyles } from '@material-ui/core/styles';
import React, { useEffect, useRef, useState } from 'react'; import { CSSProperties } from '@material-ui/core/styles/withStyles';
import React, { useEffect, useRef } from 'react';
import SpinnerImage from 'components/SpinnerImage';
function imageStyle(settings: IReaderSettings): CSSProperties {
if (settings.readerType === 'DoubleLTR'
|| settings.readerType === 'DoubleRTL'
|| settings.readerType === 'ContinuesHorizontalLTR'
|| settings.readerType === 'ContinuesHorizontalRTL') {
return {
display: 'block',
marginLeft: '7px',
marginRight: '7px',
width: 'auto',
minHeight: '99vh',
height: 'auto',
maxHeight: '99vh',
objectFit: 'contain',
pointerEvents: 'none',
};
}
return {
display: 'block',
marginBottom: settings.readerType === 'ContinuesVertical' ? '15px' : 0,
minWidth: '50vw',
width: '100%',
maxWidth: '100%',
};
}
const useStyles = (settings: IReaderSettings) => makeStyles({ const useStyles = (settings: IReaderSettings) => makeStyles({
loading: { loading: {
@@ -22,87 +50,67 @@ const useStyles = (settings: IReaderSettings) => makeStyles({
backgroundColor: '#525252', backgroundColor: '#525252',
marginBottom: 10, marginBottom: 10,
}, },
image: { image: imageStyle(settings),
display: 'block',
marginBottom: settings.readerType === 'ContinuesVertical' ? '15px' : 0,
minWidth: '50vw',
width: '100%',
maxWidth: '100%',
},
}); });
interface IProps { interface IProps {
src: string src: string
index: number index: number
onImageLoad: () => void
setCurPage: React.Dispatch<React.SetStateAction<number>> setCurPage: React.Dispatch<React.SetStateAction<number>>
settings: IReaderSettings settings: IReaderSettings
} }
function LazyImage(props: IProps) { const Page = React.forwardRef((props: IProps, ref: any) => {
const { const {
src, index, setCurPage, settings, src, index, onImageLoad, setCurPage, settings,
} = props; } = props;
const classes = useStyles(settings)(); const classes = useStyles(settings)();
const [imageSrc, setImagsrc] = useState<string>(''); const imgRef = useRef<HTMLImageElement>(null);
const ref = useRef<HTMLImageElement>(null);
const handleScroll = () => { const handleVerticalScroll = () => {
if (ref.current) { if (imgRef.current) {
const rect = ref.current.getBoundingClientRect(); const rect = imgRef.current.getBoundingClientRect();
if (rect.y < 0 && rect.y + rect.height > 0) { if (rect.y < 0 && rect.y + rect.height > 0) {
setCurPage(index); setCurPage(index);
} }
} }
}; };
useEffect(() => { const handleHorizontalScroll = () => {
if (settings.readerType === 'Webtoon' || settings.readerType === 'ContinuesVertical') { if (imgRef.current) {
window.addEventListener('scroll', handleScroll); const rect = imgRef.current.getBoundingClientRect();
if (rect.left <= window.innerWidth / 2 && rect.right > window.innerWidth / 2) {
return () => { setCurPage(index);
window.removeEventListener('scroll', handleScroll); }
}; }
} return () => {}; };
}, [handleScroll]);
useEffect(() => { useEffect(() => {
const img = new Image(); switch (settings.readerType) {
img.src = src; case 'Webtoon':
case 'ContinuesVertical':
img.onload = () => setImagsrc(src); window.addEventListener('scroll', handleVerticalScroll);
}, [src]); return () => window.removeEventListener('scroll', handleVerticalScroll);
case 'ContinuesHorizontalLTR':
if (imageSrc.length === 0) { case 'ContinuesHorizontalRTL':
return ( window.addEventListener('scroll', handleHorizontalScroll);
<div className={`${classes.image} ${classes.loadingImage}`}> return () => window.removeEventListener('scroll', handleHorizontalScroll);
<CircularProgress thickness={5} /> default:
</div> return () => {};
); }
} }, [handleVerticalScroll]);
return (
<img
className={classes.image}
ref={ref}
src={imageSrc}
alt={`Page #${index}`}
/>
);
}
const Page = React.forwardRef((props: IProps, ref: any) => {
const {
src, index, setCurPage, settings,
} = props;
return ( return (
<div ref={ref} style={{ margin: '0 auto' }}> <div ref={ref} style={{ margin: '0 auto' }}>
<LazyImage <SpinnerImage
src={src} src={src}
index={index} onImageLoad={onImageLoad}
setCurPage={setCurPage} alt={`Page #${index}`}
settings={settings} imgRef={imgRef}
spinnerClassName={`${classes.image} ${classes.loadingImage}`}
imgClassName={classes.image}
/> />
</div> </div>
); );
@@ -0,0 +1,212 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { makeStyles } from '@material-ui/core/styles';
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import Page from '../Page';
import DoublePage from '../DoublePage';
const useStyles = (settings: IReaderSettings) => makeStyles({
preload: {
display: 'none',
},
reader: {
display: 'flex',
flexDirection: (settings.readerType === 'DoubleLTR') ? 'row' : 'row-reverse',
justifyContent: 'center',
margin: '0 auto',
width: 'auto',
height: 'auto',
overflowX: 'scroll',
},
});
export default function DoublePagedPager(props: IReaderProps) {
const {
pages, settings, setCurPage, curPage, nextChapter, prevChapter,
} = props;
const classes = useStyles(settings)();
const selfRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLImageElement[]>([]);
const pagesDisplayed = useRef<number>(0);
const pageLoaded = useRef<boolean[]>(Array(pages.length).fill(false));
function setPagesToDisplay() {
pagesDisplayed.current = 0;
if (curPage < pages.length && pagesRef.current[curPage]) {
if (pageLoaded.current[curPage]) {
pagesDisplayed.current = 1;
const imgElem = pagesRef.current[curPage];
const aspectRatio = imgElem.height / imgElem.width;
if (aspectRatio < 1) {
return;
}
}
}
if (curPage + 1 < pages.length && pagesRef.current[curPage + 1]) {
if (pageLoaded.current[curPage + 1]) {
const imgElem = pagesRef.current[curPage + 1];
const aspectRatio = imgElem.height / imgElem.width;
if (aspectRatio < 1) {
return;
}
pagesDisplayed.current = 2;
}
}
}
function displayPages() {
if (pagesDisplayed.current === 2) {
ReactDOM.render(
<DoublePage
key={curPage}
index={curPage}
image1src={pages[curPage].src}
image2src={pages[curPage + 1].src}
settings={settings}
/>,
document.getElementById('display'),
);
} else {
ReactDOM.render(
<Page
key={curPage}
index={curPage}
src={(pagesDisplayed.current === 1) ? pages[curPage].src : ''}
onImageLoad={() => {}}
setCurPage={setCurPage}
settings={settings}
/>,
document.getElementById('display'),
);
}
}
function pagesToGoBack() {
for (let i = 1; i <= 2; i++) {
if (curPage - i > 0 && pagesRef.current[curPage - i]) {
if (pageLoaded.current[curPage - i]) {
const imgElem = pagesRef.current[curPage - i];
const aspectRatio = imgElem.height / imgElem.width;
if (aspectRatio < 1) {
return 1;
}
}
}
}
return 2;
}
function nextPage() {
if (curPage < pages.length - 1) {
const nextCurPage = curPage + pagesDisplayed.current;
setCurPage((nextCurPage >= pages.length) ? pages.length - 1 : nextCurPage);
} else if (settings.loadNextonEnding) {
nextChapter();
}
}
function prevPage() {
if (curPage > 0) {
const nextCurPage = curPage - pagesToGoBack();
setCurPage((nextCurPage < 0) ? 0 : nextCurPage);
} else {
prevChapter();
}
}
function goLeft() {
if (settings.readerType === 'DoubleLTR') {
prevPage();
} else {
nextPage();
}
}
function goRight() {
if (settings.readerType === 'DoubleLTR') {
nextPage();
} else {
prevPage();
}
}
function keyboardControl(e:KeyboardEvent) {
switch (e.code) {
case 'Space':
e.preventDefault();
nextPage();
break;
case 'ArrowRight':
goRight();
break;
case 'ArrowLeft':
goLeft();
break;
default:
break;
}
}
function clickControl(e:MouseEvent) {
if (e.clientX > window.innerWidth / 2) {
goRight();
} else {
goLeft();
}
}
function handleImageLoad(index: number) {
return () => {
pageLoaded.current[index] = true;
};
}
useEffect(() => {
const retryDisplay = setInterval(() => {
const isLastPage = (curPage === pages.length - 1);
if ((!isLastPage && pageLoaded.current[curPage] && pageLoaded.current[curPage + 1])
|| pageLoaded.current[curPage]) {
setPagesToDisplay();
displayPages();
clearInterval(retryDisplay);
}
}, 50);
document.addEventListener('keydown', keyboardControl);
selfRef.current?.addEventListener('click', clickControl);
return () => {
clearInterval(retryDisplay);
document.removeEventListener('keydown', keyboardControl);
selfRef.current?.removeEventListener('click', clickControl);
};
}, [selfRef, curPage, settings.readerType]);
return (
<div ref={selfRef}>
<div id="preload" className={classes.preload}>
{
pages.map((page) => (
<img
ref={(e:HTMLImageElement) => { pagesRef.current[page.index] = e; }}
key={`${page.index}`}
src={page.src}
onLoad={handleImageLoad(page.index)}
alt={`${page.index}`}
/>
))
}
</div>
<div id="display" className={classes.reader} />
</div>
);
}
@@ -6,42 +6,139 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { makeStyles } from '@material-ui/core/styles'; import { makeStyles } from '@material-ui/core/styles';
import React from 'react'; import React, { useEffect, useRef } from 'react';
import Page from '../Page'; import Page from '../Page';
const useStyles = makeStyles({ const useStyles = (settings: IReaderSettings) => makeStyles({
reader: { reader: {
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: (settings.readerType === 'ContinuesHorizontalLTR') ? 'row' : 'row-reverse',
justifyContent: 'center', justifyContent: (settings.readerType === 'ContinuesHorizontalLTR') ? 'flex-start' : 'flex-end',
margin: '0 auto', margin: '0 auto',
width: '100%', width: 'auto',
height: '100vh', height: 'auto',
overflowX: 'scroll', overflowX: 'visible',
userSelect: 'none',
}, },
}); });
interface IProps { export default function HorizontalPager(props: IReaderProps) {
pages: Array<IReaderPage> const {
setCurPage: React.Dispatch<React.SetStateAction<number>> pages, curPage, settings, setCurPage, prevChapter, nextChapter,
settings: IReaderSettings } = props;
}
export default function HorizontalPager(props: IProps) { const classes = useStyles(settings)();
const { pages, settings, setCurPage } = props;
const classes = useStyles(); const selfRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLDivElement[]>([]);
function nextPage() {
if (curPage < pages.length - 1) {
pagesRef.current[curPage + 1]?.scrollIntoView({ inline: 'center' });
setCurPage((page) => page + 1);
} else if (settings.loadNextonEnding) {
nextChapter();
}
}
function prevPage() {
if (curPage > 0) {
pagesRef.current[curPage - 1]?.scrollIntoView({ inline: 'center' });
setCurPage(curPage - 1);
} else if (curPage === 0) {
prevChapter();
}
}
function goLeft() {
if (settings.readerType === 'ContinuesHorizontalLTR') {
prevPage();
} else {
nextPage();
}
}
function goRight() {
if (settings.readerType === 'ContinuesHorizontalLTR') {
nextPage();
} else {
prevPage();
}
}
const mouseXPos = useRef<number>(0);
function dragScreen(e: MouseEvent) {
window.scrollBy(mouseXPos.current - e.pageX, 0);
}
function dragControl(e:MouseEvent) {
mouseXPos.current = e.pageX;
selfRef.current?.addEventListener('mousemove', dragScreen);
}
function removeDragControl() {
selfRef.current?.removeEventListener('mousemove', dragScreen);
}
function clickControl(e:MouseEvent) {
if (e.clientX >= window.innerWidth * 0.85) {
goRight();
} else if (e.clientX <= window.innerWidth * 0.15) {
goLeft();
}
}
const handleLoadNextonEnding = () => {
if (settings.readerType === 'ContinuesHorizontalLTR') {
if (window.scrollX + window.innerWidth >= document.body.scrollWidth) {
nextChapter();
}
} else if (settings.readerType === 'ContinuesHorizontalRTL') {
if (window.scrollX <= window.innerWidth) {
nextChapter();
}
}
};
useEffect(() => {
pagesRef.current[curPage]?.scrollIntoView({ inline: 'center' });
}, [settings.readerType]);
useEffect(() => {
selfRef.current?.addEventListener('mousedown', dragControl);
selfRef.current?.addEventListener('mouseup', removeDragControl);
return () => {
selfRef.current?.removeEventListener('mousedown', dragControl);
selfRef.current?.removeEventListener('mouseup', removeDragControl);
};
}, [selfRef]);
useEffect(() => {
if (settings.loadNextonEnding) {
document.addEventListener('scroll', handleLoadNextonEnding);
}
selfRef.current?.addEventListener('mousedown', clickControl);
return () => {
document.removeEventListener('scroll', handleLoadNextonEnding);
selfRef.current?.removeEventListener('mousedown', clickControl);
};
}, [selfRef, curPage]);
return ( return (
<div className={classes.reader}> <div ref={selfRef} className={classes.reader}>
{ {
pages.map((page) => ( pages.map((page) => (
<Page <Page
key={page.index} key={page.index}
index={page.index} index={page.index}
src={page.src} src={page.src}
onImageLoad={() => {}}
setCurPage={setCurPage} setCurPage={setCurPage}
settings={settings} settings={settings}
ref={(e:HTMLDivElement) => { pagesRef.current[page.index] = e; }}
/> />
)) ))
} }
@@ -38,7 +38,27 @@ export default function PagedReader(props: IReaderProps) {
} }
function prevPage() { function prevPage() {
if (curPage > 0) { setCurPage(curPage - 1); } else if (curPage === 0) { prevChapter(); } if (curPage > 0) {
setCurPage(curPage - 1);
} else {
prevChapter();
}
}
function goLeft() {
if (settings.readerType === 'SingleLTR') {
prevPage();
} else if (settings.readerType === 'SingleRTL') {
nextPage();
}
}
function goRight() {
if (settings.readerType === 'SingleLTR') {
nextPage();
} else if (settings.readerType === 'SingleRTL') {
prevPage();
}
} }
function keyboardControl(e:KeyboardEvent) { function keyboardControl(e:KeyboardEvent) {
@@ -48,10 +68,10 @@ export default function PagedReader(props: IReaderProps) {
nextPage(); nextPage();
break; break;
case 'ArrowRight': case 'ArrowRight':
nextPage(); goRight();
break; break;
case 'ArrowLeft': case 'ArrowLeft':
prevPage(); goLeft();
break; break;
default: default:
break; break;
@@ -60,9 +80,9 @@ export default function PagedReader(props: IReaderProps) {
function clickControl(e:MouseEvent) { function clickControl(e:MouseEvent) {
if (e.clientX > window.innerWidth / 2) { if (e.clientX > window.innerWidth / 2) {
nextPage(); goRight();
} else { } else {
prevPage(); goLeft();
} }
} }
@@ -74,13 +94,14 @@ export default function PagedReader(props: IReaderProps) {
document.removeEventListener('keydown', keyboardControl); document.removeEventListener('keydown', keyboardControl);
selfRef.current?.removeEventListener('click', clickControl); selfRef.current?.removeEventListener('click', clickControl);
}; };
}, [selfRef, curPage]); }, [selfRef, curPage, settings.readerType]);
return ( return (
<div ref={selfRef} className={classes.reader}> <div ref={selfRef} className={classes.reader}>
<Page <Page
key={curPage} key={curPage}
index={curPage} index={curPage}
onImageLoad={() => {}}
src={pages[curPage].src} src={pages[curPage].src}
setCurPage={setCurPage} setCurPage={setCurPage}
settings={settings} settings={settings}
@@ -29,6 +29,10 @@ export default function VerticalReader(props: IReaderProps) {
const selfRef = useRef<HTMLDivElement>(null); const selfRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLDivElement[]>([]); const pagesRef = useRef<HTMLDivElement[]>([]);
useEffect(() => {
pagesRef.current = pagesRef.current.slice(0, pages.length);
}, [pages.length]);
function nextPage() { function nextPage() {
if (curPage < pages.length - 1) { if (curPage < pages.length - 1) {
pagesRef.current[curPage + 1]?.scrollIntoView(); pagesRef.current[curPage + 1]?.scrollIntoView();
@@ -104,7 +108,7 @@ export default function VerticalReader(props: IReaderProps) {
if (initialPage > -1) { if (initialPage > -1) {
pagesRef.current[initialPage].scrollIntoView(); pagesRef.current[initialPage].scrollIntoView();
} }
}, []); }, [pagesRef.current.length]);
return ( return (
<div ref={selfRef} className={classes.reader}> <div ref={selfRef} className={classes.reader}>
@@ -114,6 +118,7 @@ export default function VerticalReader(props: IReaderProps) {
key={page.index} key={page.index}
index={page.index} index={page.index}
src={page.src} src={page.src}
onImageLoad={() => {}}
setCurPage={setCurPage} setCurPage={setCurPage}
settings={settings} settings={settings}
ref={(e:HTMLDivElement) => { pagesRef.current[page.index] = e; }} ref={(e:HTMLDivElement) => { pagesRef.current[page.index] = e; }}
@@ -286,17 +286,25 @@ export default function ReaderNavBar(props: IProps) {
onChange={(e) => setSettingValue('readerType', e.target.value)} onChange={(e) => setSettingValue('readerType', e.target.value)}
> >
<MenuItem value="SingleLTR"> <MenuItem value="SingleLTR">
Left to right Single Page (LTR)
</MenuItem> </MenuItem>
{/* <MenuItem value="SingleRTL"> <MenuItem value="SingleRTL">
Right to left(WIP) Single Page (RTL)
</MenuItem> */} </MenuItem>
{/* <MenuItem value="SingleVertical"> {/* <MenuItem value="SingleVertical">
Vertical(WIP) Vertical(WIP)
</MenuItem> */} </MenuItem> */}
<MenuItem value="DoubleLTR">
Double Page (LTR)
</MenuItem>
<MenuItem value="DoubleRTL">
Double Page (RTL)
</MenuItem>
<MenuItem value="Webtoon"> <MenuItem value="Webtoon">
Webtoon Webtoon
@@ -305,10 +313,14 @@ export default function ReaderNavBar(props: IProps) {
Continues Vertical Continues Vertical
</MenuItem> </MenuItem>
{/* <MenuItem value="ContinuesHorizontal"> <MenuItem value="ContinuesHorizontalLTR">
Horizontal(WIP) Horizontal (LTR)
</MenuItem> */} </MenuItem>
<MenuItem value="ContinuesHorizontalRTL">
Horizontal (RTL)
</MenuItem>
</Select> </Select>
</ListItem> </ListItem>
</List> </List>
@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable react/destructuring-assignment */
/* eslint-disable react/jsx-props-no-spreading */
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import NavbarContext from 'context/NavbarContext';
import React, { useContext, useEffect, useState } from 'react';
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
import PauseIcon from '@material-ui/icons/Pause';
import IconButton from '@material-ui/core/IconButton';
import client from 'util/client';
import {
DragDropContext, Draggable, DraggingStyle, Droppable, DropResult, NotDraggingStyle,
} from 'react-beautiful-dnd';
import { useTheme } from '@material-ui/core/styles';
import { Palette } from '@material-ui/core/styles/createPalette';
import List from '@material-ui/core/List';
import DragHandleIcon from '@material-ui/icons/DragHandle';
import ListItem from '@material-ui/core/ListItem';
import { ListItemIcon } from '@material-ui/core';
import ListItemText from '@material-ui/core/ListItemText';
const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws');
const getItemStyle = (isDragging: boolean,
draggableStyle: DraggingStyle | NotDraggingStyle | undefined, palette: Palette) => ({
// styles we need to apply on draggables
...draggableStyle,
...(isDragging && {
background: palette.type === 'dark' ? '#424242' : 'rgb(235,235,235)',
}),
});
const initialQueue = {
status: 'Stopped',
queue: [],
} as IQueue;
export default function DownloadQueue() {
const [, setWsClient] = useState<WebSocket>();
const [queueState, setQueueState] = useState<IQueue>(initialQueue);
const { queue, status } = queueState;
const theme = useTheme();
const { setTitle, setAction } = useContext(NavbarContext);
const toggleQueueStatus = () => {
if (status === 'Stopped') {
client.get('/api/v1/downloads/start');
} else {
client.get('/api/v1/downloads/stop');
}
};
useEffect(() => {
setTitle('Download Queue');
setAction(() => {
if (status === 'Stopped') {
return (
<IconButton onClick={toggleQueueStatus}>
<PlayArrowIcon />
</IconButton>
);
}
return (
<IconButton onClick={toggleQueueStatus}>
<PauseIcon />
</IconButton>
);
});
}, [status]);
useEffect(() => {
const wsc = new WebSocket(`${baseWebsocketUrl}/api/v1/downloads`);
wsc.onmessage = (e) => {
setQueueState(JSON.parse(e.data));
};
setWsClient(wsc);
}, []);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const onDragEnd = (result: DropResult) => {
};
return (
<>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<List ref={provided.innerRef}>
{queue.map((item, index) => (
<Draggable
key={`${item.mangaId}-${item.chapterIndex}`}
draggableId={`${item.mangaId}-${item.chapterIndex}`}
index={index}
>
{(provided, snapshot) => (
<ListItem
ContainerProps={{ ref: provided.innerRef } as any}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getItemStyle(
snapshot.isDragging,
provided.draggableProps.style,
theme.palette,
)}
ref={provided.innerRef}
>
<ListItemIcon>
<DragHandleIcon />
</ListItemIcon>
<ListItemText
primary={
`${item.chapter.name} | `
+ ` (${item.progress * 100}%)`
+ ` => state: ${item.state}`
}
/>
{/* <IconButton
onClick={() => {
handleEditDialogOpen(index);
}}
>
<EditIcon />
</IconButton>
<IconButton
onClick={() => {
deleteCategory(index);
}}
>
<DeleteIcon />
</IconButton> */}
</ListItem>
)}
</Draggable>
))}
{provided.placeholder}
</List>
)}
</Droppable>
</DragDropContext>
</>
);
}
+45
View File
@@ -41,6 +41,12 @@ const useStyles = makeStyles((theme: Theme) => ({
}, },
})); }));
const baseWebsocketUrl = JSON.parse(window.localStorage.getItem('serverBaseURL')!).replace('http', 'ws');
const initialQueue = {
status: 'Stopped',
queue: [],
} as IQueue;
export default function Manga() { export default function Manga() {
const classes = useStyles(); const classes = useStyles();
@@ -55,10 +61,48 @@ export default function Manga() {
const [noChaptersFound, setNoChaptersFound] = useState(false); const [noChaptersFound, setNoChaptersFound] = useState(false);
const [chapterUpdateTriggerer, setChapterUpdateTriggerer] = useState(0); const [chapterUpdateTriggerer, setChapterUpdateTriggerer] = useState(0);
const [, setWsClient] = useState<WebSocket>();
const [{ queue }, setQueueState] = useState<IQueue>(initialQueue);
function triggerChaptersUpdate() { function triggerChaptersUpdate() {
setChapterUpdateTriggerer(chapterUpdateTriggerer + 1); setChapterUpdateTriggerer(chapterUpdateTriggerer + 1);
} }
useEffect(() => {
const wsc = new WebSocket(`${baseWebsocketUrl}/api/v1/downloads`);
wsc.onmessage = (e) => {
const data = JSON.parse(e.data) as IQueue;
setQueueState(data);
let shouldUpdate = false;
data.queue.forEach((q) => {
if (q.mangaId === manga?.id && q.state === 'Finished') {
shouldUpdate = true;
}
});
if (shouldUpdate) {
triggerChaptersUpdate();
}
};
setWsClient(wsc);
return () => wsc.close();
}, [queue.length]);
const downloadingStringFor = (chapter: IChapter) => {
let rtn = '';
if (chapter.downloaded) {
rtn = ' • Downloaded';
}
queue.forEach((q) => {
if (chapter.index === q.chapterIndex && chapter.mangaId === q.mangaId) {
rtn = ` • Downloading (${q.progress * 100}%)`;
}
});
return rtn;
};
useEffect(() => { useEffect(() => {
if (manga === undefined || !manga.freshData) { if (manga === undefined || !manga.freshData) {
client.get(`/api/v1/manga/${id}/?onlineFetch=${manga !== undefined}`) client.get(`/api/v1/manga/${id}/?onlineFetch=${manga !== undefined}`)
@@ -105,6 +149,7 @@ export default function Manga() {
itemContent={(index:number) => ( itemContent={(index:number) => (
<ChapterCard <ChapterCard
chapter={chapters[index]} chapter={chapters[index]}
downloadingString={downloadingStringFor(chapters[index])}
triggerChaptersUpdate={triggerChaptersUpdate} triggerChaptersUpdate={triggerChaptersUpdate}
/> />
)} )}
+10 -9
View File
@@ -11,7 +11,8 @@ import React, { useContext, useEffect, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom'; import { useHistory, useParams } from 'react-router-dom';
import HorizontalPager from 'components/manga/reader/pager/HorizontalPager'; import HorizontalPager from 'components/manga/reader/pager/HorizontalPager';
import PageNumber from 'components/manga/reader/PageNumber'; import PageNumber from 'components/manga/reader/PageNumber';
import WebtoonPager from 'components/manga/reader/pager/PagedPager'; import PagedPager from 'components/manga/reader/pager/PagedPager';
import DoublePagedPager from 'components/manga/reader/pager/DoublePagedPager';
import VerticalPager from 'components/manga/reader/pager/VerticalPager'; import VerticalPager from 'components/manga/reader/pager/VerticalPager';
import ReaderNavBar, { defaultReaderSettings } from 'components/navbar/ReaderNavBar'; import ReaderNavBar, { defaultReaderSettings } from 'components/navbar/ReaderNavBar';
import NavbarContext from 'context/NavbarContext'; import NavbarContext from 'context/NavbarContext';
@@ -32,21 +33,21 @@ const useStyles = (settings: IReaderSettings) => makeStyles({
const getReaderComponent = (readerType: ReaderType) => { const getReaderComponent = (readerType: ReaderType) => {
switch (readerType) { switch (readerType) {
case 'ContinuesVertical': case 'ContinuesVertical':
return VerticalPager;
break;
case 'Webtoon': case 'Webtoon':
return VerticalPager; return VerticalPager;
break; break;
case 'SingleVertical': case 'SingleVertical':
return WebtoonPager;
break;
case 'SingleRTL': case 'SingleRTL':
return WebtoonPager;
break;
case 'SingleLTR': case 'SingleLTR':
return WebtoonPager; return PagedPager;
break; break;
case 'ContinuesHorizontal': case 'DoubleVertical':
case 'DoubleRTL':
case 'DoubleLTR':
return DoublePagedPager;
break;
case 'ContinuesHorizontalLTR':
case 'ContinuesHorizontalRTL':
return HorizontalPager; return HorizontalPager;
default: default:
return VerticalPager; return VerticalPager;
@@ -1,3 +1,6 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable react/destructuring-assignment */
/* eslint-disable react/jsx-props-no-spreading */
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
* *
@@ -5,9 +8,6 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable react/destructuring-assignment */
/* eslint-disable react/jsx-props-no-spreading */
import React, { useState, useContext, useEffect } from 'react'; import React, { useState, useContext, useEffect } from 'react';
import { import {
List, List,
@@ -16,7 +16,9 @@ import {
ListItemIcon, ListItemIcon,
IconButton, IconButton,
} from '@material-ui/core'; } from '@material-ui/core';
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; import {
DragDropContext, Droppable, Draggable, DropResult, DraggingStyle, NotDraggingStyle,
} from 'react-beautiful-dnd';
import DragHandleIcon from '@material-ui/icons/DragHandle'; import DragHandleIcon from '@material-ui/icons/DragHandle';
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from '@material-ui/icons/Edit';
import { useTheme } from '@material-ui/core/styles'; import { useTheme } from '@material-ui/core/styles';
@@ -31,10 +33,12 @@ import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from '@material-ui/core/DialogTitle';
import Checkbox from '@material-ui/core/Checkbox'; import Checkbox from '@material-ui/core/Checkbox';
import FormControlLabel from '@material-ui/core/FormControlLabel'; import FormControlLabel from '@material-ui/core/FormControlLabel';
import NavbarContext from '../../context/NavbarContext'; import NavbarContext from 'context/NavbarContext';
import client from '../../util/client'; import client from 'util/client';
import { Palette } from '@material-ui/core/styles/createPalette';
const getItemStyle = (isDragging, draggableStyle, palette) => ({ const getItemStyle = (isDragging: boolean,
draggableStyle: DraggingStyle | NotDraggingStyle | undefined, palette: Palette) => ({
// styles we need to apply on draggables // styles we need to apply on draggables
...draggableStyle, ...draggableStyle,
@@ -47,14 +51,14 @@ export default function Categories() {
const { setTitle, setAction } = useContext(NavbarContext); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { setTitle('Categories'); setAction(<></>); }, []); useEffect(() => { setTitle('Categories'); setAction(<></>); }, []);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState<ICategory[]>([]);
const [categoryToEdit, setCategoryToEdit] = useState(-1); // -1 means new category const [categoryToEdit, setCategoryToEdit] = useState<number>(-1); // -1 means new category
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [dialogName, setDialogName] = useState(''); const [dialogName, setDialogName] = useState<string>('');
const [dialogDefault, setDialogDefault] = useState(false); const [dialogDefault, setDialogDefault] = useState<boolean>(false);
const theme = useTheme(); const theme = useTheme();
const [updateTriggerHolder, setUpdateTriggerHolder] = useState(0); // just a hack const [updateTriggerHolder, setUpdateTriggerHolder] = useState<number>(0); // just a hack
const triggerUpdate = () => setUpdateTriggerHolder(updateTriggerHolder + 1); // just a hack const triggerUpdate = () => setUpdateTriggerHolder(updateTriggerHolder + 1); // just a hack
useEffect(() => { useEffect(() => {
@@ -65,12 +69,12 @@ export default function Categories() {
} }
}, [updateTriggerHolder]); }, [updateTriggerHolder]);
const categoryReorder = (list, from, to) => { const categoryReorder = (list: ICategory[], from: number, to: number) => {
const category = list[from]; const category = list[from];
const formData = new FormData(); const formData = new FormData();
formData.append('from', from + 1); formData.append('from', `${from + 1}`);
formData.append('to', to + 1); formData.append('to', `${to + 1}`);
client.post(`/api/v1/category/${category.id}/reorder`, formData) client.post(`/api/v1/category/${category.id}/reorder`, formData)
.finally(() => triggerUpdate()); .finally(() => triggerUpdate());
@@ -81,7 +85,7 @@ export default function Categories() {
return result; return result;
}; };
const onDragEnd = (result) => { const onDragEnd = (result: DropResult) => {
// dropped outside the list? // dropped outside the list?
if (!result.destination) { if (!result.destination) {
return; return;
@@ -105,7 +109,7 @@ export default function Categories() {
setDialogOpen(true); setDialogOpen(true);
}; };
const handleEditDialogOpen = (index) => { const handleEditDialogOpen = (index:number) => {
setDialogName(categories[index].name); setDialogName(categories[index].name);
setDialogDefault(categories[index].default); setDialogDefault(categories[index].default);
setCategoryToEdit(index); setCategoryToEdit(index);
@@ -121,7 +125,7 @@ export default function Categories() {
const formData = new FormData(); const formData = new FormData();
formData.append('name', dialogName); formData.append('name', dialogName);
formData.append('default', dialogDefault); formData.append('default', dialogDefault.toString());
if (categoryToEdit === -1) { if (categoryToEdit === -1) {
client.post('/api/v1/category/', formData) client.post('/api/v1/category/', formData)
@@ -133,7 +137,7 @@ export default function Categories() {
} }
}; };
const deleteCategory = (index) => { const deleteCategory = (index:number) => {
const category = categories[index]; const category = categories[index];
client.delete(`/api/v1/category/${category.id}`) client.delete(`/api/v1/category/${category.id}`)
.finally(() => triggerUpdate()); .finally(() => triggerUpdate());
@@ -153,8 +157,7 @@ export default function Categories() {
> >
{(provided, snapshot) => ( {(provided, snapshot) => (
<ListItem <ListItem
ContainerComponent="li" ContainerProps={{ ref: provided.innerRef } as any}
ContainerProps={{ ref: provided.innerRef }}
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
style={getItemStyle( style={getItemStyle(
+20 -2
View File
@@ -67,6 +67,7 @@ interface IChapter {
index: number index: number
chapterCount: number chapterCount: number
pageCount: number pageCount: number
downloaded: boolean
} }
interface IEpisode { interface IEpisode {
@@ -99,7 +100,7 @@ interface IPartialEpisode {
interface ICategory { interface ICategory {
id: number id: number
order: number order: number
name: String name: string
default: boolean default: boolean
} }
@@ -114,7 +115,11 @@ type ReaderType =
'SingleVertical' | 'SingleVertical' |
'SingleRTL' | 'SingleRTL' |
'SingleLTR' | 'SingleLTR' |
'ContinuesHorizontal'; 'DoubleVertical' |
'DoubleRTL' |
'DoubleLTR' |
'ContinuesHorizontalLTR'|
'ContinuesHorizontalRTL';
interface IReaderSettings{ interface IReaderSettings{
staticNav: boolean staticNav: boolean
@@ -149,3 +154,16 @@ interface IAbout {
github: string github: string
discord: string discord: string
} }
interface IDownloadChapter{
chapterIndex: number
mangaId: number
state: 'Queued' | 'Downloading' | 'Finished' | 'Error'
progress: number
chapter: IChapter
}
interface IQueue {
status: 'Stopped' | 'Started'
queue: IDownloadChapter[]
}
+7
View File
@@ -1891,6 +1891,13 @@
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24"
integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==
"@types/react-beautiful-dnd@^13.0.0":
version "13.0.0"
resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#e60d3d965312fcf1516894af92dc3e9249587db4"
integrity sha512-by80tJ8aTTDXT256Gl+RfLRtFjYbUWOnZuEigJgNsJrSEGxvFe5eY6k3g4VIvf0M/6+xoLgfYWoWonlOo6Wqdg==
dependencies:
"@types/react" "*"
"@types/react-dom@^17.0.2": "@types/react-dom@^17.0.2":
version "17.0.5" version "17.0.5"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.5.tgz#df44eed5b8d9e0b13bb0cd38e0ea6572a1231227" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.5.tgz#df44eed5b8d9e0b13bb0cd38e0ea6572a1231227"