Add Snowmtl (#6195)
* Add Snowmtl * Remove logs * Remove file unused * Fix status * Improve Intercept * Cleanup * Refactoring * Use StaticLayout * Change the min version of the Android API * Fix function name * Fix dialog box * Cleanup * Typo * Use custom font * Refactoring * Use font config * Remove unused transfer data class * Fix font color * Add normal font * Cleanup * Use Color class
This commit is contained in:
parent
827fbace8d
commit
5911343a9a
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<activity
|
||||||
|
android:name=".en.snowmtl.SnowmtlUrlActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:host="snowmtl.ru"
|
||||||
|
android:pathPattern="/comics/..*"
|
||||||
|
android:scheme="https" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
|
@ -0,0 +1,202 @@
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
Binary file not shown.
|
@ -0,0 +1,8 @@
|
||||||
|
ext {
|
||||||
|
extName = 'Snow Machine Translations'
|
||||||
|
extClass = '.Snowmtl'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 7.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
|
@ -0,0 +1,286 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.snowmtl
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.text.LineBreaker
|
||||||
|
import android.os.Build
|
||||||
|
import android.text.Layout
|
||||||
|
import android.text.StaticLayout
|
||||||
|
import android.text.TextPaint
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
// The Interceptor joins the captions and pages of the manga.
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
class ComposedImageInterceptor(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
private val fontFamily: MutableMap<String, Pair<String, Typeface?>> = mutableMapOf(
|
||||||
|
"sub" to Pair<String, Typeface?>("$baseUrl/images/sub.ttf", null),
|
||||||
|
"sfx" to Pair<String, Typeface?>("$baseUrl/images/sfx.ttf", null),
|
||||||
|
"normal" to Pair<String, Typeface?>("$baseUrl/images/normal.ttf", null),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val url = request.url.toString()
|
||||||
|
|
||||||
|
val isPageImageUrl = url.contains("storage.${baseUrl.substringAfterLast("/")}", true)
|
||||||
|
if (isPageImageUrl.not()) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
val translation = request.url.fragment?.parseAs<List<Translation>>()
|
||||||
|
?: throw IOException("Translation not found")
|
||||||
|
|
||||||
|
val imageRequest = request.newBuilder()
|
||||||
|
.url(url)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = chain.proceed(imageRequest)
|
||||||
|
|
||||||
|
if (response.isSuccessful.not()) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAllFont(chain)
|
||||||
|
|
||||||
|
val bitmap = BitmapFactory.decodeStream(response.body.byteStream())!!
|
||||||
|
.copy(Bitmap.Config.ARGB_8888, true)
|
||||||
|
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
|
||||||
|
translation
|
||||||
|
.filter { it.text.isNotBlank() }
|
||||||
|
.forEach { caption ->
|
||||||
|
val textPaint = createTextPaint(selectFontFamily(caption.type))
|
||||||
|
val dialogBox = createDialogBox(caption, textPaint, bitmap)
|
||||||
|
val y = getYAxis(textPaint, caption, dialogBox)
|
||||||
|
canvas.draw(dialogBox, caption, caption.x1, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
val format = when (url.substringAfterLast(".").lowercase()) {
|
||||||
|
"png" -> Bitmap.CompressFormat.PNG
|
||||||
|
"jpeg", "jpg" -> Bitmap.CompressFormat.JPEG
|
||||||
|
else -> Bitmap.CompressFormat.WEBP
|
||||||
|
}
|
||||||
|
|
||||||
|
bitmap.compress(format, 100, output)
|
||||||
|
|
||||||
|
val responseBody = output.toByteArray().toResponseBody(mediaType)
|
||||||
|
|
||||||
|
return response.newBuilder()
|
||||||
|
.body(responseBody)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTextPaint(font: Typeface?): TextPaint {
|
||||||
|
val defaultTextSize = 50.sp // arbitrary
|
||||||
|
return TextPaint().apply {
|
||||||
|
color = Color.BLACK
|
||||||
|
textSize = defaultTextSize
|
||||||
|
font?.let {
|
||||||
|
typeface = it
|
||||||
|
}
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectFontFamily(type: String): Typeface? {
|
||||||
|
if (type in fontFamily) {
|
||||||
|
return fontFamily[type]?.second
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (type) {
|
||||||
|
"inside", "outside" -> fontFamily["sfx"]?.second
|
||||||
|
else -> fontFamily["normal"]?.second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAllFont(chain: Interceptor.Chain) {
|
||||||
|
fontFamily.keys.forEach { key ->
|
||||||
|
val font = fontFamily[key] ?: return@forEach
|
||||||
|
if (font.second != null) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
fontFamily[key] = key to (loadRemoteFont(font.first, chain) ?: loadFont("coming_soon_regular.ttf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads font from the `assets/fonts` directory within the APK
|
||||||
|
*
|
||||||
|
* @param fontName The name of the font to load.
|
||||||
|
* @return A `Typeface` instance of the loaded font or `null` if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* <pre>{@code
|
||||||
|
* val typeface: TypeFace? = loadFont("filename.ttf")
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
private fun loadFont(fontName: String): Typeface? {
|
||||||
|
return try {
|
||||||
|
this::class.java.classLoader!!
|
||||||
|
.getResourceAsStream("assets/fonts/$fontName")
|
||||||
|
.toTypeface(fontName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a remote font and converts it into a usable font object.
|
||||||
|
*
|
||||||
|
* This function makes an HTTP request to download a font from a specified remote URL.
|
||||||
|
* It then converts the response into a usable font object.
|
||||||
|
*/
|
||||||
|
private fun loadRemoteFont(fontUrl: String, chain: Interceptor.Chain): Typeface? {
|
||||||
|
return try {
|
||||||
|
val request = GET(fontUrl, chain.request().headers)
|
||||||
|
val response = client
|
||||||
|
.newCall(request).execute()
|
||||||
|
.takeIf(Response::isSuccessful) ?: return null
|
||||||
|
val fontName = request.url.pathSegments.last()
|
||||||
|
response.body.byteStream().toTypeface(fontName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.toTypeface(fontName: String): Typeface? {
|
||||||
|
val fontFile = File.createTempFile(fontName, fontName.substringAfter("."))
|
||||||
|
this.copyTo(FileOutputStream(fontFile))
|
||||||
|
return Typeface.createFromFile(fontFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust the text to the center of the dialog box when feasible.
|
||||||
|
*/
|
||||||
|
private fun getYAxis(textPaint: TextPaint, caption: Translation, dialogBox: StaticLayout): Float {
|
||||||
|
val fontHeight = textPaint.fontMetrics.let { it.bottom - it.top }
|
||||||
|
|
||||||
|
val dialogBoxLineCount = caption.height / fontHeight
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centers text in y for captions smaller than the dialog box
|
||||||
|
*/
|
||||||
|
return when {
|
||||||
|
dialogBox.lineCount < dialogBoxLineCount -> caption.centerY - dialogBox.lineCount / 2f * fontHeight
|
||||||
|
else -> caption.y1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDialogBox(caption: Translation, textPaint: TextPaint, bitmap: Bitmap): StaticLayout {
|
||||||
|
var dialogBox = createBoxLayout(caption, textPaint)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The best way I've found to adjust the text in the dialog box (Especially in long dialogues)
|
||||||
|
*/
|
||||||
|
while (dialogBox.height > caption.height) {
|
||||||
|
textPaint.textSize -= 0.5f
|
||||||
|
dialogBox = createBoxLayout(caption, textPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use source setup
|
||||||
|
if (caption.isNewApi) {
|
||||||
|
textPaint.color = caption.foregroundColor
|
||||||
|
textPaint.bgColor = caption.backgroundColor
|
||||||
|
textPaint.style = if (caption.isBold) Paint.Style.FILL_AND_STROKE else Paint.Style.FILL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces font color correction if the background color of the dialog box and the font color are too similar.
|
||||||
|
* It's a source configuration problem.
|
||||||
|
*/
|
||||||
|
textPaint.adjustTextColor(caption, bitmap)
|
||||||
|
|
||||||
|
return dialogBox
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBoxLayout(caption: Translation, textPaint: TextPaint) =
|
||||||
|
StaticLayout.Builder.obtain(caption.text, 0, caption.text.length, textPaint, caption.width.toInt()).apply {
|
||||||
|
setAlignment(Layout.Alignment.ALIGN_CENTER)
|
||||||
|
setIncludePad(false)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
setBreakStrategy(LineBreaker.BREAK_STRATEGY_BALANCED)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
// Invert color in black dialog box.
|
||||||
|
private fun TextPaint.adjustTextColor(caption: Translation, bitmap: Bitmap) {
|
||||||
|
val pixelColor = bitmap.getPixel(caption.centerX.toInt(), caption.centerY.toInt())
|
||||||
|
val inverseColor = (Color.WHITE - pixelColor) or Color.BLACK
|
||||||
|
|
||||||
|
val minDistance = 80f // arbitrary
|
||||||
|
if (colorDistance(pixelColor, caption.foregroundColor) > minDistance) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
color = inverseColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> String.parseAs(): T {
|
||||||
|
return json.decodeFromString(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Canvas.draw(layout: StaticLayout, caption: Translation, x: Float, y: Float) {
|
||||||
|
save()
|
||||||
|
translate(x, y)
|
||||||
|
rotate(caption.angle)
|
||||||
|
layout.draw(this)
|
||||||
|
restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Int.sp: Float get() = this * SCALED_DENSITY
|
||||||
|
|
||||||
|
// ============================= Utils ======================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the Euclidean distance between two colors in RGB space.
|
||||||
|
*
|
||||||
|
* This function takes two integer values representing hexadecimal colors,
|
||||||
|
* converts them to their RGB components, and calculates the Euclidean distance
|
||||||
|
* between the two colors. The distance provides a measure of how similar or
|
||||||
|
* different the two colors are.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
private fun colorDistance(colorA: Int, colorB: Int): Double {
|
||||||
|
val a = Color.valueOf(colorA)
|
||||||
|
val b = Color.valueOf(colorB)
|
||||||
|
|
||||||
|
return sqrt(
|
||||||
|
(b.red() - a.red()).toDouble().pow(2) +
|
||||||
|
(b.green() - a.green()).toDouble().pow(2) +
|
||||||
|
(b.blue() - a.blue()).toDouble().pow(2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SCALED_DENSITY = 1.5f // arbitrary
|
||||||
|
val mediaType = "image/png".toMediaType()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.snowmtl
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
|
||||||
|
class SelectionList(displayName: String, private val vals: List<Option>, state: Int = 0) :
|
||||||
|
Filter.Select<String>(displayName, vals.map { it.name }.toTypedArray(), state) {
|
||||||
|
fun selected() = vals[state]
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Option(val name: String = "", val value: String = "", val query: String = "")
|
||||||
|
|
||||||
|
class GenreList(title: String, genres: List<Genre>) :
|
||||||
|
Filter.Group<GenreCheckBox>(title, genres.map { GenreCheckBox(it.name, it.id) })
|
||||||
|
|
||||||
|
class GenreCheckBox(name: String, val id: String = name) : Filter.CheckBox(name)
|
||||||
|
|
||||||
|
class Genre(val name: String, val id: String = name)
|
||||||
|
|
||||||
|
val genreList: List<Genre> = listOf(
|
||||||
|
Genre("Action"),
|
||||||
|
Genre("Adult"),
|
||||||
|
Genre("Adventure"),
|
||||||
|
Genre("Comedy"),
|
||||||
|
Genre("Drama"),
|
||||||
|
Genre("Ecchi"),
|
||||||
|
Genre("Fantasy"),
|
||||||
|
Genre("Gender Bender"),
|
||||||
|
Genre("Harem"),
|
||||||
|
Genre("Historical"),
|
||||||
|
Genre("Horror"),
|
||||||
|
Genre("Josei"),
|
||||||
|
Genre("Lolicon"),
|
||||||
|
Genre("Martial Arts"),
|
||||||
|
Genre("Mature"),
|
||||||
|
Genre("Mecha"),
|
||||||
|
Genre("Mystery"),
|
||||||
|
Genre("Psychological"),
|
||||||
|
Genre("Romance"),
|
||||||
|
Genre("School Life"),
|
||||||
|
Genre("Sci-fi"),
|
||||||
|
Genre("Seinen"),
|
||||||
|
Genre("Shoujo"),
|
||||||
|
Genre("Shoujo Ai"),
|
||||||
|
Genre("Shounen"),
|
||||||
|
Genre("Shounen Ai"),
|
||||||
|
Genre("Slice of Life"),
|
||||||
|
Genre("Smut"),
|
||||||
|
Genre("Sports"),
|
||||||
|
Genre("Supernatural"),
|
||||||
|
Genre("Tragedy"),
|
||||||
|
Genre("Yaoi"),
|
||||||
|
Genre("Yuri"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val sortByList = listOf(
|
||||||
|
Option("All"),
|
||||||
|
Option("Most Views", "views"),
|
||||||
|
Option("Most Recent", "recent"),
|
||||||
|
).map { it.copy(query = "sort_by") }
|
|
@ -0,0 +1,209 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.snowmtl
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
class Snowmtl : ParsedHttpSource() {
|
||||||
|
|
||||||
|
override val name = "Snow Machine Translations"
|
||||||
|
|
||||||
|
override val baseUrl = "https://snowmtl.ru"
|
||||||
|
|
||||||
|
override val lang = "en"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
override val client = network.cloudflareClient.newBuilder()
|
||||||
|
.rateLimit(2)
|
||||||
|
.addInterceptor(ComposedImageInterceptor(baseUrl, super.client))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// ============================== Popular ===============================
|
||||||
|
|
||||||
|
private val popularFilter = FilterList(SelectionList("", listOf(Option(value = "views", query = "sort_by"))))
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", popularFilter)
|
||||||
|
|
||||||
|
override fun popularMangaSelector() = searchMangaSelector()
|
||||||
|
|
||||||
|
override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element)
|
||||||
|
|
||||||
|
override fun popularMangaNextPageSelector() = searchMangaNextPageSelector()
|
||||||
|
|
||||||
|
// =============================== Latest ===============================
|
||||||
|
|
||||||
|
private val latestFilter = FilterList(SelectionList("", listOf(Option(value = "recent", query = "sort_by"))))
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", latestFilter)
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = searchMangaSelector()
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element)
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector()
|
||||||
|
|
||||||
|
// =========================== Search ============================
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val url = "$baseUrl/search".toHttpUrl().newBuilder()
|
||||||
|
.addQueryParameter("page", page.toString())
|
||||||
|
|
||||||
|
if (query.isNotBlank()) {
|
||||||
|
url.addQueryParameter("query", query)
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is SelectionList -> {
|
||||||
|
val selected = filter.selected()
|
||||||
|
if (selected.value.isBlank()) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
url.addQueryParameter(selected.query, selected.value)
|
||||||
|
}
|
||||||
|
is GenreList -> {
|
||||||
|
filter.state.filter(GenreCheckBox::state).forEach { genre ->
|
||||||
|
url.addQueryParameter("genres", genre.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GET(url.build(), headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
if (query.startsWith(PREFIX_SEARCH)) {
|
||||||
|
val slug = query.removePrefix(PREFIX_SEARCH)
|
||||||
|
return fetchMangaDetails(SManga.create().apply { url = "/comics/$slug" }).map { manga ->
|
||||||
|
MangasPage(listOf(manga), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.fetchSearchManga(page, query, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaSelector() = "section h2 + div > div"
|
||||||
|
|
||||||
|
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
|
||||||
|
title = element.selectFirst("h3")!!.text()
|
||||||
|
thumbnail_url = element.selectFirst("img")?.absUrl("src")
|
||||||
|
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaNextPageSelector() = "a[href*=search]:contains(Next)"
|
||||||
|
|
||||||
|
// =========================== Manga Details ============================
|
||||||
|
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||||
|
title = document.selectFirst("h1")!!.text()
|
||||||
|
description = document.selectFirst("p:has(span:contains(Synopsis))")?.ownText()
|
||||||
|
author = document.selectFirst("p:has(span:contains(Author))")?.ownText()
|
||||||
|
genre = document.select("h2:contains(Genres) + div span").joinToString { it.text() }
|
||||||
|
thumbnail_url = document.selectFirst("img.object-cover")?.absUrl("src")
|
||||||
|
document.selectFirst("p:has(span:contains(Status))")?.ownText()?.let {
|
||||||
|
status = when (it.lowercase()) {
|
||||||
|
"ongoing" -> SManga.ONGOING
|
||||||
|
"complete" -> SManga.COMPLETED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUrlWithoutDomain(document.location())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Chapters ==============================
|
||||||
|
override fun chapterListSelector() = "section li"
|
||||||
|
|
||||||
|
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||||
|
element.selectFirst("a")!!.let {
|
||||||
|
name = it.ownText()
|
||||||
|
setUrlWithoutDomain(it.absUrl("href"))
|
||||||
|
}
|
||||||
|
date_upload = parseChapterDate(element.selectFirst("span")?.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Pages ================================
|
||||||
|
|
||||||
|
override fun pageListParse(document: Document): List<Page> {
|
||||||
|
val pages = document.selectFirst("div#json-data")
|
||||||
|
?.ownText()?.parseAs<List<PageDto>>()
|
||||||
|
?: throw Exception("Pages not found")
|
||||||
|
|
||||||
|
return pages.mapIndexed { index, dto ->
|
||||||
|
val imageUrl = when {
|
||||||
|
dto.imageUrl.startsWith("http") -> dto.imageUrl
|
||||||
|
else -> "https://${dto.imageUrl}"
|
||||||
|
}
|
||||||
|
val fragment = json.encodeToString<List<Translation>>(dto.translations)
|
||||||
|
Page(index, imageUrl = "$imageUrl#$fragment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(document: Document): String = ""
|
||||||
|
|
||||||
|
// ============================= Utilities ==============================
|
||||||
|
|
||||||
|
private fun parseChapterDate(date: String?): Long {
|
||||||
|
date ?: return 0
|
||||||
|
return try { dateFormat.parse(date)!!.time } catch (_: Exception) { parseRelativeDate(date) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRelativeDate(date: String): Long {
|
||||||
|
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
date.contains("day", true) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
|
||||||
|
date.contains("hour", true) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
|
||||||
|
date.contains("minute", true) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
|
||||||
|
date.contains("second", true) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
|
||||||
|
date.contains("week", true) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> String.parseAs(): T {
|
||||||
|
return json.decodeFromString(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================== Filters ================================
|
||||||
|
|
||||||
|
override fun getFilterList(): FilterList {
|
||||||
|
val filters = mutableListOf<Filter<*>>(
|
||||||
|
SelectionList("Sort", sortByList),
|
||||||
|
Filter.Separator(),
|
||||||
|
GenreList(title = "Genres", genres = genreList),
|
||||||
|
)
|
||||||
|
|
||||||
|
return FilterList(filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFIX_SEARCH = "id:"
|
||||||
|
private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd MMMM yyyy", Locale.US)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.snowmtl
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonTransformingSerializer
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class PageDto(
|
||||||
|
@SerialName("img_url")
|
||||||
|
val imageUrl: String,
|
||||||
|
@Serializable(with = TranslationsListSerializer::class)
|
||||||
|
val translations: List<Translation> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
class Translation(
|
||||||
|
val x1: Float,
|
||||||
|
val y1: Float,
|
||||||
|
val x2: Float,
|
||||||
|
val y2: Float,
|
||||||
|
val text: String,
|
||||||
|
val angle: Float = 0f,
|
||||||
|
val isBold: Boolean = false,
|
||||||
|
val isNewApi: Boolean = false,
|
||||||
|
val type: String = "sub",
|
||||||
|
private val fbColor: List<Int> = emptyList(),
|
||||||
|
private val bgColor: List<Int> = emptyList(),
|
||||||
|
) {
|
||||||
|
val width get() = x2 - x1
|
||||||
|
val height get() = y2 - y1
|
||||||
|
val centerY get() = (y2 + y1) / 2f
|
||||||
|
val centerX get() = (x2 + x1) / 2f
|
||||||
|
|
||||||
|
val foregroundColor: Int get() {
|
||||||
|
val color = fbColor.takeIf { it.isNotEmpty() }
|
||||||
|
?: return Color.BLACK
|
||||||
|
return Color.rgb(color[0], color[1], color[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
val backgroundColor: Int get() {
|
||||||
|
val color = bgColor.takeIf { it.isNotEmpty() }
|
||||||
|
?: return Color.WHITE
|
||||||
|
return Color.rgb(color[0], color[1], color[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object TranslationsListSerializer :
|
||||||
|
JsonTransformingSerializer<List<Translation>>(ListSerializer(Translation.serializer())) {
|
||||||
|
override fun transformDeserialize(element: JsonElement): JsonElement {
|
||||||
|
return JsonArray(
|
||||||
|
element.jsonArray.map { jsonElement ->
|
||||||
|
val (coordinates, text) = getCoordinatesAndCaption(jsonElement)
|
||||||
|
|
||||||
|
buildJsonObject {
|
||||||
|
put("x1", coordinates[0])
|
||||||
|
put("y1", coordinates[1])
|
||||||
|
put("x2", coordinates[2])
|
||||||
|
put("y2", coordinates[3])
|
||||||
|
put("text", text)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val obj = jsonElement.jsonObject
|
||||||
|
obj["fg_color"]?.let { put("fbColor", it) }
|
||||||
|
obj["bg_color"]?.let { put("bgColor", it) }
|
||||||
|
obj["angle"]?.let { put("angle", it) }
|
||||||
|
obj["type"]?.let { put("type", it) }
|
||||||
|
obj["is_bold"]?.let { put("isBold", it) }
|
||||||
|
put("isNewApi", true)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCoordinatesAndCaption(element: JsonElement): Pair<JsonArray, JsonElement> {
|
||||||
|
return try {
|
||||||
|
val arr = element.jsonArray
|
||||||
|
arr[0].jsonArray to arr[1]
|
||||||
|
} catch (_: Exception) {
|
||||||
|
val obj = element.jsonObject
|
||||||
|
obj["bbox"]!!.jsonArray to obj["text"]!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.snowmtl
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class SnowmtlUrlActivity : Activity() {
|
||||||
|
|
||||||
|
private val tag = javaClass.simpleName
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val pathSegments = intent?.data?.pathSegments
|
||||||
|
if (pathSegments != null && pathSegments.size > 1) {
|
||||||
|
val item = pathSegments[1]
|
||||||
|
val mainIntent = Intent().apply {
|
||||||
|
action = "eu.kanade.tachiyomi.SEARCH"
|
||||||
|
putExtra("query", "${Snowmtl.PREFIX_SEARCH}$item")
|
||||||
|
putExtra("filter", packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startActivity(mainIntent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e(tag, e.toString())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(tag, "could not parse uri from intent $intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
finish()
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue