Compare commits

...

10 commits

Author SHA1 Message Date
rebel onion
1fbbe3e77c fix: replace runBlocking with non-blocking async operations
Some checks failed
Build APK and Notify Discord / build (push) Has been cancelled
2025-05-23 08:20:41 -05:00
rebel onion
fd607d98f8 fix: bottom bar padding 2025-05-23 08:02:13 -05:00
Ankit Grai
c3f6d0ecee
fix : dialog key names (#605) 2025-05-22 12:51:18 +05:30
aayush262
5124d6a2d8 fix: discord login 2025-05-22 12:50:06 +05:30
Ankit Grai
e83a0fe7da
fix : long press progress dialog reset not working (#603) 2025-05-19 17:11:24 +05:30
rebel onion
61a8350043 fix: avoid waiting on network for local exts 2025-05-15 01:47:06 -05:00
rebel onion
baffbc845c fix: help bounds check /w custom speeds 2025-05-15 01:16:23 -05:00
rebel onion
afd9f6b884 fix: subtitles not showing 2025-05-15 01:13:47 -05:00
rebel onion
7d0894cd92 chore: bump extension interface 2025-05-14 22:35:50 -05:00
Rishvaish
dec2ed7959
hope for the best
* Update README.md

* To install multiple mangas

users can enter the value required to install as there is an EditText field instead of the Text View

* Issues

1)Installation of many mangas at same time now made to one to increase the installation efficiency
2)Installation order from the latest progresses chapter to the limit index
3)Tried to resolve the app crash bug

* Issues

1)Installation of many mangas at same time now made to one to increase the installation efficiency
2)Installation order from the latest progresses chapter to the limit index
3)Tried to resolve the app crash bug

---------

Co-authored-by: rebel onion <87634197+rebelonion@users.noreply.github.com>
2025-04-23 14:58:42 +05:30
18 changed files with 583 additions and 247 deletions

View file

@ -14,8 +14,6 @@ Dantotsu is an [Anilist](https://anilist.co/) only client.
> **Dantotsu (断トツ; Dan-totsu)** literally means "the best of the best" in Japanese. Try it out for yourself and be the judge!
<a href="https://www.buymeacoffee.com/rebelonion"><img src="https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=&slug=rebelonion&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" /></a>
## Terms of Use
By downloading, installing, or using this application, you agree to:
- Use the application in compliance with all applicable laws

View file

@ -18,8 +18,8 @@ android {
minSdk 21
targetSdk 35
versionCode((System.currentTimeMillis() / 60000).toInteger())
versionName "3.2.1"
versionCode 300200100
versionName "3.2.2"
versionCode versionName.split("\\.").collect { it.toInteger() * 100 }.join("") as Integer
signingConfig signingConfigs.debug
}
@ -48,6 +48,10 @@ android {
manifestPlaceholders.icon_placeholder_round = "@mipmap/ic_launcher_alpha_round"
debuggable System.getenv("CI") == null
isDefault true
debuggable true
jniDebuggable true
minifyEnabled false
shrinkResources false
}
debug {
applicationIdSuffix ".beta"
@ -81,25 +85,26 @@ android {
dependencies {
// FireBase
googleImplementation platform('com.google.firebase:firebase-bom:33.0.0')
googleImplementation 'com.google.firebase:firebase-analytics-ktx:22.0.0'
googleImplementation 'com.google.firebase:firebase-crashlytics-ktx:19.0.0'
googleImplementation platform('com.google.firebase:firebase-bom:33.13.0')
googleImplementation 'com.google.firebase:firebase-analytics-ktx:22.4.0'
googleImplementation 'com.google.firebase:firebase-crashlytics-ktx:19.4.3'
// Core
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.browser:browser:1.8.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.core:core-ktx:1.16.0'
implementation 'androidx.fragment:fragment-ktx:1.8.6'
implementation 'androidx.activity:activity-ktx:1.10.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.multidex:multidex:2.0.1'
implementation "androidx.work:work-runtime-ktx:2.9.0"
implementation "androidx.work:work-runtime-ktx:2.10.1"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.github.Blatzar:NiceHttp:0.4.4'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.webkit:webkit:1.13.0'
implementation "com.anggrayudi:storage:1.5.5"
implementation "androidx.biometric:biometric:1.1.0"
@ -113,7 +118,7 @@ dependencies {
implementation 'jp.wasabeef:glide-transformations:4.3.0'
// Exoplayer
ext.exo_version = '1.5.1'
ext.exo_version = '1.6.1'
implementation "androidx.media3:media3-exoplayer:$exo_version"
implementation "androidx.media3:media3-ui:$exo_version"
implementation "androidx.media3:media3-exoplayer-hls:$exo_version"
@ -133,7 +138,7 @@ dependencies {
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.alexvasilkov:gesture-views:2.8.3'
implementation 'com.github.VipulOG:ebook-reader:0.1.6'
implementation 'androidx.paging:paging-runtime-ktx:3.2.1'
implementation 'androidx.paging:paging-runtime-ktx:3.3.6'
implementation 'com.github.eltos:simpledialogfragments:v3.7'
implementation 'com.github.AAChartModel:AAChartCore-Kotlin:7.2.3'
@ -162,13 +167,13 @@ dependencies {
implementation 'ca.gosyer:voyager-navigator:1.0.0-rc07'
implementation 'com.squareup.logcat:logcat:0.1'
implementation 'uy.kohesive.injekt:injekt-core:1.16.+'
implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.12'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.12'
implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.14'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.14'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps'
implementation 'com.squareup.okio:okio:3.8.0'
implementation 'com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.12'
implementation 'org.jsoup:jsoup:1.16.1'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.3'
implementation 'com.squareup.okio:okio:3.9.1'
implementation 'com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.14'
implementation 'org.jsoup:jsoup:1.18.1'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.7.3'
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
implementation 'com.github.tachiyomiorg:unifile:17bec43'
implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'

View file

@ -113,21 +113,28 @@ class App : MultiDexApplication() {
}
}
CoroutineScope(Dispatchers.IO).launch {
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
animeExtensionManager = Injekt.get()
launch {
animeExtensionManager.findAvailableExtensions()
}
Logger.log("Anime Extensions: ${animeExtensionManager.installedExtensionsFlow.first()}")
AnimeSources.init(animeExtensionManager.installedExtensionsFlow)
}
CoroutineScope(Dispatchers.IO).launch {
scope.launch {
mangaExtensionManager = Injekt.get()
launch {
mangaExtensionManager.findAvailableExtensions()
}
Logger.log("Manga Extensions: ${mangaExtensionManager.installedExtensionsFlow.first()}")
MangaSources.init(mangaExtensionManager.installedExtensionsFlow)
}
CoroutineScope(Dispatchers.IO).launch {
scope.launch {
novelExtensionManager = Injekt.get()
launch {
novelExtensionManager.findAvailableExtensions()
}
Logger.log("Novel Extensions: ${novelExtensionManager.installedExtensionsFlow.first()}")
NovelSources.init(novelExtensionManager.installedExtensionsFlow)
}

View file

@ -11,7 +11,11 @@ import com.lagradost.nicehttp.addGenericDns
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkHelper.Companion.defaultUserAgentProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.ExperimentalSerializationApi
@ -85,12 +89,42 @@ object Mapper : ResponseParser {
}
}
fun <A, B> Collection<A>.asyncMap(f: suspend (A) -> B): List<B> = runBlocking {
map { async { f(it) } }.map { it.await() }
/**
* Performs parallel processing of collection items without blocking threads.
* Each operation runs in its own coroutine on the specified dispatcher.
*
* @param dispatcher The CoroutineDispatcher to use for parallel operations (defaults to IO)
* @param f The suspend function to apply to each item
* @return List of results in the same order as the original collection
*/
suspend fun <A, B> Collection<A>.asyncMap(
dispatcher: CoroutineDispatcher = Dispatchers.IO,
f: suspend (A) -> B
): List<B> = coroutineScope {
map { item ->
async(dispatcher) {
f(item)
}
}.awaitAll()
}
fun <A, B> Collection<A>.asyncMapNotNull(f: suspend (A) -> B?): List<B> = runBlocking {
map { async { f(it) } }.mapNotNull { it.await() }
/**
* Performs parallel processing of collection items without blocking threads,
* filtering out null results.
*
* @param dispatcher The CoroutineDispatcher to use for parallel operations (defaults to IO)
* @param f The suspend function to apply to each item
* @return List of non-null results in the same order as the original collection
*/
suspend fun <A, B> Collection<A>.asyncMapNotNull(
dispatcher: CoroutineDispatcher = Dispatchers.IO,
f: suspend (A) -> B?
): List<B> = coroutineScope {
map { item ->
async(dispatcher) {
f(item)
}
}.mapNotNull { it.await() }
}
fun logError(e: Throwable, post: Boolean = true, snackbar: Boolean = true) {

View file

@ -47,9 +47,9 @@ class Login : AppCompatActivity() {
view.evaluateJavascript(
"""
(function() {
const wreq = (webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken();
return wreq;
})()
const m = []; webpackChunkdiscord_app.push([[""], {}, e => {for (let c in e.c)m.push(e.c[c])}]);
return m.find(n => n?.exports?.default?.getToken !== void 0)?.exports?.default?.getToken();
})()
""".trimIndent()
) { result ->
login(result.trim('"'))

View file

@ -4,6 +4,7 @@ import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.view.GestureDetector
@ -12,6 +13,8 @@ import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.ImageView
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@ -19,8 +22,10 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.text.bold
import androidx.core.text.color
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isVisible
import androidx.core.view.marginBottom
import androidx.core.view.setPadding
import androidx.core.view.updateLayoutParams
import androidx.core.view.updateMargins
import androidx.fragment.app.Fragment
@ -79,6 +84,7 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
@SuppressLint("ClickableViewAccessibility")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
var media: Media = intent.getSerialized("media") ?: mediaSingleton ?: emptyMedia()
val id = intent.getIntExtra("mediaId", -1)
@ -109,6 +115,7 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
// Ui init
initActivity(this)
binding.mediaViewPager.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = navBarHeight
}
@ -132,10 +139,12 @@ class MediaDetailsActivity : AppCompatActivity(), AppBarLayout.OnOffsetChangedLi
val navBarBottomMargin = if (resources.configuration.orientation ==
Configuration.ORIENTATION_LANDSCAPE
) 0 else navBarHeight
navBar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
rightMargin = navBarRightMargin
bottomMargin = navBarBottomMargin
}
binding.mediaBottomBarContainer.setPadding(
navBar.paddingLeft,
navBar.paddingTop,
navBar.paddingRight + navBarRightMargin,
navBar.paddingBottom + navBarBottomMargin
)
binding.mediaBanner.updateLayoutParams { height += statusBarHeight }
binding.mediaBannerNoKen.updateLayoutParams { height += statusBarHeight }
binding.mediaClose.updateLayoutParams<ViewGroup.MarginLayoutParams> { topMargin += statusBarHeight }

View file

@ -1,6 +1,8 @@
package ani.dantotsu.media
import android.content.Context
import androidx.core.net.toFile
import androidx.core.net.toUri
import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.parsers.SubtitleType
@ -21,6 +23,7 @@ class SubtitleDownloader {
suspend fun loadSubtitleType(url: String): SubtitleType =
withContext(Dispatchers.IO) {
return@withContext try {
if (!url.startsWith("file")) {
// Initialize the NetworkHelper instance. Replace this line based on how you usually initialize it
val networkHelper = Injekt.get<NetworkHelper>()
val request = Request.Builder()
@ -34,22 +37,34 @@ class SubtitleDownloader {
val responseBody = response.body.string()
val subtitleType = when {
responseBody.contains("[Script Info]") -> SubtitleType.ASS
responseBody.contains("WEBVTT") -> SubtitleType.VTT
else -> SubtitleType.SRT
}
val subtitleType = getType(responseBody)
subtitleType
} else {
SubtitleType.UNKNOWN
}
} else {
val uri = url.toUri()
val file = uri.toFile()
val fileBody = file.readText()
val subtitleType = getType(fileBody)
subtitleType
}
} catch (e: Exception) {
Logger.log(e)
SubtitleType.UNKNOWN
}
}
private fun getType(content: String): SubtitleType {
return when {
content.contains("[Script Info]") -> SubtitleType.ASS
content.contains("WEBVTT") -> SubtitleType.VTT
content.contains("SRT") -> SubtitleType.SRT
else -> SubtitleType.UNKNOWN
}
}
//actually downloads lol
@Deprecated("handled externally")
suspend fun downloadSubtitle(

View file

@ -2,7 +2,6 @@ package ani.dantotsu.media.anime
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.app.Dialog
import android.app.PictureInPictureParams
import android.app.PictureInPictureUiState
@ -19,7 +18,6 @@ import android.media.AudioManager.AUDIOFOCUS_GAIN
import android.media.AudioManager.AUDIOFOCUS_LOSS
import android.media.AudioManager.AUDIOFOCUS_LOSS_TRANSIENT
import android.media.AudioManager.STREAM_MUSIC
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.CountDownTimer
@ -75,9 +73,7 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.HttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
@ -175,10 +171,10 @@ import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.collections.set
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import androidx.core.net.toUri
@UnstableApi
@SuppressLint("ClickableViewAccessibility")
@ -427,7 +423,8 @@ class ExoplayerView :
false -> 0f
}
val textElevation = PrefManager.getVal<Float>(PrefName.SubBottomMargin) / 50 * resources.displayMetrics.heightPixels
val textElevation =
PrefManager.getVal<Float>(PrefName.SubBottomMargin) / 50 * resources.displayMetrics.heightPixels
textView.translationY = -textElevation
}
@ -1044,7 +1041,8 @@ class ExoplayerView :
}
}
override fun onSingleClick(event: MotionEvent) = if (isSeeking) doubleTap(false, event) else handleController()
override fun onSingleClick(event: MotionEvent) =
if (isSeeking) doubleTap(false, event) else handleController()
},
)
val rewindArea = playerView.findViewById<View>(R.id.exo_rewind_area)
@ -1079,7 +1077,8 @@ class ExoplayerView :
}
}
override fun onSingleClick(event: MotionEvent) = if (isSeeking) doubleTap(true, event) else handleController()
override fun onSingleClick(event: MotionEvent) =
if (isSeeking) doubleTap(true, event) else handleController()
},
)
val forwardArea = playerView.findViewById<View>(R.id.exo_forward_area)
@ -1309,7 +1308,7 @@ class ExoplayerView :
setTitle(R.string.speed)
singleChoiceItems(speedsName, curSpeed) { i ->
PrefManager.setCustomVal("${media.id}_speed", i)
speed = speeds[i]
speed = speeds.getOrNull(i) ?: 1f
curSpeed = i
playbackParameters = PlaybackParameters(speed)
exoPlayer.playbackParameters = playbackParameters
@ -1359,7 +1358,7 @@ class ExoplayerView :
val showProgressDialog =
if (PrefManager.getVal(PrefName.AskIndividualPlayer)) {
PrefManager.getCustomVal(
"${media.id}_ProgressDialog",
"${media.id}_progressDialog",
true,
)
} else {
@ -1381,7 +1380,7 @@ class ExoplayerView :
setCancelable(false)
setPosButton(R.string.yes) {
PrefManager.setCustomVal(
"${media.id}_ProgressDialog",
"${media.id}_progressDialog",
false,
)
PrefManager.setCustomVal(
@ -1392,7 +1391,7 @@ class ExoplayerView :
}
setNegButton(R.string.no) {
PrefManager.setCustomVal(
"${media.id}_ProgressDialog",
"${media.id}_progressDialog",
false,
)
PrefManager.setCustomVal(
@ -1449,7 +1448,8 @@ class ExoplayerView :
else -> mutableListOf()
}
val startTimestamp = Calendar.getInstance()
val durationInSeconds = if (exoPlayer.duration != C.TIME_UNSET) (exoPlayer.duration / 1000).toInt() else 1440
val durationInSeconds =
if (exoPlayer.duration != C.TIME_UNSET) (exoPlayer.duration / 1000).toInt() else 1440
val endTimestamp =
Calendar.getInstance().apply {
@ -1567,7 +1567,11 @@ class ExoplayerView :
subtitle = intent.getSerialized("subtitle")
?: when (
val subLang: String? =
PrefManager.getNullableCustomVal("subLang_${media.id}", null, String::class.java)
PrefManager.getNullableCustomVal(
"subLang_${media.id}",
null,
String::class.java
)
) {
null -> {
when (episode.selectedSubtitle) {
@ -1575,8 +1579,12 @@ class ExoplayerView :
-1 ->
ext.subtitles.find {
it.language.contains(lang, ignoreCase = true) ||
it.language.contains(getLanguageCode(lang), ignoreCase = true)
it.language.contains(
getLanguageCode(lang),
ignoreCase = true
)
}
else -> ext.subtitles.getOrNull(episode.selectedSubtitle!!)
}
}
@ -1597,29 +1605,27 @@ class ExoplayerView :
emptyList<MediaItem.SubtitleConfiguration>().toMutableList()
ext.subtitles.forEach { subtitle ->
val subtitleUrl = if (!hasExtSubtitles) video!!.file.url else subtitle.file.url
// var localFile: String? = null
if (subtitle.type == SubtitleType.UNKNOWN) {
runBlocking {
val type = SubtitleDownloader.loadSubtitleType(subtitleUrl)
val fileUri = Uri.parse(subtitleUrl)
val fileUri = (subtitleUrl).toUri()
sub +=
MediaItem.SubtitleConfiguration
.Builder(fileUri)
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.setMimeType(
when (type) {
SubtitleType.VTT -> MimeTypes.TEXT_SSA
SubtitleType.VTT -> MimeTypes.TEXT_VTT
SubtitleType.ASS -> MimeTypes.TEXT_SSA
SubtitleType.SRT -> MimeTypes.TEXT_SSA
else -> MimeTypes.TEXT_SSA
SubtitleType.SRT -> MimeTypes.APPLICATION_SUBRIP
else -> MimeTypes.TEXT_UNKNOWN
},
).setId("69")
.setLanguage(subtitle.language)
.build()
}
println("sub: $sub")
} else {
val subUri = Uri.parse(subtitleUrl)
val subUri = subtitleUrl.toUri()
sub +=
MediaItem.SubtitleConfiguration
.Builder(subUri)
@ -1649,26 +1655,18 @@ class ExoplayerView :
followRedirects(true)
followSslRedirects(true)
}.build()
val dataSourceFactory =
DataSource.Factory {
val dataSource: HttpDataSource = OkHttpDataSource.Factory(httpClient).createDataSource()
defaultHeaders.forEach {
dataSource.setRequestProperty(it.key, it.value)
val httpDataSourceFactory =
OkHttpDataSource.Factory(httpClient).apply {
setDefaultRequestProperties(defaultHeaders)
video?.file?.headers?.let {
setDefaultRequestProperties(it)
}
video?.file?.headers?.forEach {
dataSource.setRequestProperty(it.key, it.value)
}
dataSource
}
val dafuckDataSourceFactory = DefaultDataSource.Factory(this)
val defaultDataSourceFactory = DefaultDataSource.Factory(this, httpDataSourceFactory)
cacheFactory =
CacheDataSource.Factory().apply {
setCache(VideoCache.getInstance(this@ExoplayerView))
if (ext.server.offline) {
setUpstreamDataSourceFactory(dafuckDataSourceFactory)
} else {
setUpstreamDataSourceFactory(dataSourceFactory)
}
setUpstreamDataSourceFactory(defaultDataSourceFactory)
setCacheWriteDataSinkFactory(null)
}
@ -1719,12 +1717,14 @@ class ExoplayerView :
it.name?.endsWith(".mp4") == true ||
it.name?.endsWith(".mkv") == true ||
it.name?.endsWith(
".${Injekt
".${
Injekt
.get<DownloadAddonManager>()
.extension
?.extension
?.getFileExtension()
?.first ?: "ts"}",
?.first ?: "ts"
}",
) ==
true
}
@ -1940,7 +1940,9 @@ class ExoplayerView :
val currentPosition = exoPlayer.currentPosition
if ((lastSubtitle?.length ?: 0) < 20 || (lastPosition != 0L && currentPosition - lastPosition > 1500)) {
if ((lastSubtitle?.length
?: 0) < 20 || (lastPosition != 0L && currentPosition - lastPosition > 1500)
) {
activeSubtitles.clear()
}
@ -2213,7 +2215,8 @@ class ExoplayerView :
override fun onTick(millisUntilFinished: Long) {
if (new == null) {
skipTimeButton.visibility = View.GONE
exoSkip.isVisible = PrefManager.getVal<Int>(PrefName.SkipTime) > 0
exoSkip.isVisible =
PrefManager.getVal<Int>(PrefName.SkipTime) > 0
disappeared = false
functionstarted = false
cancelTimer()
@ -2222,7 +2225,8 @@ class ExoplayerView :
override fun onFinish() {
skipTimeButton.visibility = View.GONE
exoSkip.isVisible = PrefManager.getVal<Int>(PrefName.SkipTime) > 0
exoSkip.isVisible =
PrefManager.getVal<Int>(PrefName.SkipTime) > 0
disappeared = true
functionstarted = false
cancelTimer()
@ -2484,7 +2488,7 @@ class ExoplayerView :
val videoURL = video?.file?.url ?: return
val subtitleUrl = if (!hasExtSubtitles) video!!.file.url else subtitle!!.file.url
val shareVideo = Intent(Intent.ACTION_VIEW)
shareVideo.setDataAndType(Uri.parse(videoURL), "video/*")
shareVideo.setDataAndType(videoURL.toUri(), "video/*")
shareVideo.setPackage("com.instantbits.cast.webvideo")
if (subtitle != null) shareVideo.putExtra("subtitle", subtitleUrl)
shareVideo.putExtra(
@ -2506,7 +2510,7 @@ class ExoplayerView :
} catch (ex: ActivityNotFoundException) {
val intent = Intent(Intent.ACTION_VIEW)
val uriString = "market://details?id=com.instantbits.cast.webvideo"
intent.data = Uri.parse(uriString)
intent.data = uriString.toUri()
startActivity(intent)
}
}

View file

@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
data class ImageData(
@ -76,7 +77,7 @@ fun saveImage(
uri?.let {
contentResolver.openOutputStream(it)?.use { os ->
bitmap.compress(format, quality, os)
}
} ?: throw FileNotFoundException("Failed to open output stream for URI: $uri")
}
} else {
val directory =
@ -86,12 +87,20 @@ fun saveImage(
}
val file = File(directory, filename)
// Check if the file already exists
if (file.exists()) {
println("File already exists: ${file.absolutePath}")
return
}
FileOutputStream(file).use { outputStream ->
bitmap.compress(format, quality, outputStream)
}
}
} catch (e: FileNotFoundException) {
println("File not found: ${e.message}")
} catch (e: Exception) {
// Handle exception here
println("Exception while saving image: ${e.message}")
}
}

View file

@ -66,6 +66,7 @@ import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
import eu.kanade.tachiyomi.source.ConfigurableSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uy.kohesive.injekt.Injekt
@ -232,25 +233,35 @@ open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
}
fun multiDownload(n: Int) {
// Get last viewed chapter
val selected = media.userProgress
lifecycleScope.launch {
// Get the last viewed chapter
val selected = media.userProgress ?: 0
val chapters = media.manga?.chapters?.values?.toList()
// Filter by selected language
val progressChapterIndex = (chapters?.indexOfFirst {
// Ensure chapters are available in the extensions
if (chapters.isNullOrEmpty() || n < 1) return@launch
// Find the index of the last viewed chapter
val progressChapterIndex = (chapters.indexOfFirst {
MediaNameAdapter.findChapterNumber(it.number)?.toInt() == selected
} ?: 0) + 1
if (progressChapterIndex < 0 || n < 1 || chapters == null) return
// Calculate the end index
val endIndex = minOf(progressChapterIndex + n, chapters.size)
// Make sure there are enough chapters
} + 1).coerceAtLeast(0)
// Calculate the end value for the range of chapters to download
val endIndex = (progressChapterIndex + n).coerceAtMost(chapters.size)
// Get the list of chapters to download
val chaptersToDownload = chapters.subList(progressChapterIndex, endIndex)
// Trigger the download for each chapter sequentially
for (chapter in chaptersToDownload) {
try {
downloadChapterSequentially(chapter)
} catch (e: Exception) {
Toast.makeText(requireContext(), "Failed to download chapter: ${chapter.title}", Toast.LENGTH_SHORT).show()
}
}
Toast.makeText(requireContext(), "All downloads completed!", Toast.LENGTH_SHORT).show()
}
}
private suspend fun downloadChapterSequentially(chapter: MangaChapter) {
withContext(Dispatchers.IO) {
onMangaChapterDownloadClick(chapter)
delay(2000) // A 2-second download
}
}

View file

@ -26,7 +26,6 @@ object AnimeSources : WatchSources() {
)
isInitialized = true
// Update as StateFlow emits new values
fromExtensions.collect { extensions ->
list = sortPinnedAnimeSources(
createParsersFromExtensions(extensions),

View file

@ -226,8 +226,18 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() {
?: return emptyList())
return try {
// TODO(1.6): Remove else block when dropping support for ext lib <1.6
if ((source as AnimeHttpSource).javaClass.declaredMethods.any { it.name == "getHosterList" }){
val hosters = source.getHosterList(sEpisode)
val allVideos = hosters.flatMap { hoster ->
val videos = source.getVideoList(hoster)
videos.map { it.copy(videoTitle = "${hoster.hosterName} - ${it.videoTitle}") }
}
allVideos.map { videoToVideoServer(it) }
} else {
val videos = source.getVideoList(sEpisode)
videos.map { videoToVideoServer(it) }
}
} catch (e: Exception) {
Logger.log("Exception occurred: ${e.message}")
emptyList()
@ -576,7 +586,7 @@ class VideoServerPassthrough(private val videoServer: VideoServer) : VideoExtrac
number,
format!!,
FileUrl(videoUrl, headersMap),
if (aniVideo.totalContentLength == 0L) null else aniVideo.bytesDownloaded.toDouble()
null
)
}
@ -636,7 +646,6 @@ class VideoServerPassthrough(private val videoServer: VideoServer) : VideoExtrac
}
private fun trackToSubtitle(track: Track): Subtitle {
//use Dispatchers.IO to make a HTTP request to determine the subtitle type
var type: SubtitleType?
runBlocking {
type = findSubtitleType(track.url)

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.animesource
import eu.kanade.tachiyomi.animesource.model.Hoster
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
@ -48,6 +49,25 @@ interface AnimeSource {
return fetchEpisodeList(anime).awaitSingle()
}
/**
* Get the list of hoster for an episode. The first hoster in the list should
* be the preferred hoster.
*
* @since extensions-lib 16
* @param episode the episode.
* @return the hosters for the episode.
*/
suspend fun getHosterList(episode: SEpisode): List<Hoster> = throw IllegalStateException("Not used")
/**
* Get the list of videos for a hoster.
*
* @since extensions-lib 16
* @param hoster the hoster.
* @return the videos for the hoster.
*/
suspend fun getVideoList(hoster: Hoster): List<Video> = throw IllegalStateException("Not used")
/**
* Get the list of videos a episode has. Pages should be returned
* in the expected order; the index is ignored.

View file

@ -0,0 +1,81 @@
package eu.kanade.tachiyomi.animesource.model
import eu.kanade.tachiyomi.animesource.model.SerializableVideo.Companion.serialize
import eu.kanade.tachiyomi.animesource.model.SerializableVideo.Companion.toVideoList
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
open class Hoster(
val hosterUrl: String = "",
val hosterName: String = "",
val videoList: List<Video>? = null,
val internalData: String = "",
) {
@Transient
@Volatile
var status: State = State.IDLE
enum class State {
IDLE,
LOADING,
READY,
ERROR,
}
fun copy(
hosterUrl: String = this.hosterUrl,
hosterName: String = this.hosterName,
videoList: List<Video>? = this.videoList,
internalData: String = this.internalData,
): Hoster {
return Hoster(hosterUrl, hosterName, videoList, internalData)
}
companion object {
const val NO_HOSTER_LIST = "no_hoster_list"
fun List<Video>.toHosterList(): List<Hoster> {
return listOf(
Hoster(
hosterUrl = "",
hosterName = NO_HOSTER_LIST,
videoList = this,
),
)
}
}
}
@Serializable
data class SerializableHoster(
val hosterUrl: String = "",
val hosterName: String = "",
val videoList: String? = null,
val internalData: String = "",
) {
companion object {
fun List<Hoster>.serialize(): String =
Json.encodeToString(
this.map { host ->
SerializableHoster(
host.hosterUrl,
host.hosterName,
host.videoList?.serialize(),
host.internalData,
)
},
)
fun String.toHosterList(): List<Hoster> =
Json.decodeFromString<List<SerializableHoster>>(this)
.map { sHost ->
Hoster(
sHost.hosterUrl,
sHost.hosterName,
sHost.videoList?.toVideoList(),
sHost.internalData,
)
}
}
}

View file

@ -1,31 +1,101 @@
package eu.kanade.tachiyomi.animesource.model
import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Headers
import rx.subjects.Subject
import java.io.IOException
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.io.Serializable
@kotlinx.serialization.Serializable
data class Track(val url: String, val lang: String) : Serializable
@kotlinx.serialization.Serializable
enum class ChapterType {
Opening,
Ending,
Recap,
MixedOp,
Other,
}
@kotlinx.serialization.Serializable
data class TimeStamp(
val start: Double,
val end: Double,
val name: String,
val type: ChapterType = ChapterType.Other,
)
open class Video(
val url: String = "",
val quality: String = "",
var videoUrl: String? = null,
headers: Headers? = null,
// "url", "language-label-2", "url2", "language-label-2"
var videoUrl: String = "",
val videoTitle: String = "",
val resolution: Int? = null,
val bitrate: Int? = null,
val headers: Headers? = null,
val preferred: Boolean = false,
val subtitleTracks: List<Track> = emptyList(),
val audioTracks: List<Track> = emptyList(),
) : Serializable, ProgressListener {
val timestamps: List<TimeStamp> = emptyList(),
val internalData: String = "",
val initialized: Boolean = false,
// TODO(1.6): Remove after ext lib bump
val videoPageUrl: String = "",
) {
@Transient
var headers: Headers? = headers
// TODO(1.6): Remove after ext lib bump
@Deprecated("Use videoTitle instead", ReplaceWith("videoTitle"))
val quality: String
get() = videoTitle
// TODO(1.6): Remove after ext lib bump
@Deprecated("Use videoPageUrl instead", ReplaceWith("videoPageUrl"))
val url: String
get() = videoPageUrl
// TODO(1.6): Remove after ext lib bump
constructor(
url: String,
quality: String,
videoUrl: String?,
headers: Headers? = null,
subtitleTracks: List<Track> = emptyList(),
audioTracks: List<Track> = emptyList(),
) : this(
videoPageUrl = url,
videoTitle = quality,
videoUrl = videoUrl ?: "null",
headers = headers,
subtitleTracks = subtitleTracks,
audioTracks = audioTracks,
)
// TODO(1.6): Remove after ext lib bump
constructor(
videoUrl: String = "",
videoTitle: String = "",
resolution: Int? = null,
bitrate: Int? = null,
headers: Headers? = null,
preferred: Boolean = false,
subtitleTracks: List<Track> = emptyList(),
audioTracks: List<Track> = emptyList(),
timestamps: List<TimeStamp> = emptyList(),
internalData: String = "",
) : this(
videoUrl = videoUrl,
videoTitle = videoTitle,
resolution = resolution,
bitrate = bitrate,
headers = headers,
preferred = preferred,
subtitleTracks = subtitleTracks,
audioTracks = audioTracks,
timestamps = timestamps,
internalData = internalData,
videoPageUrl = "",
)
// TODO(1.6): Remove after ext lib bump
@Suppress("UNUSED_PARAMETER")
constructor(
url: String,
@ -38,83 +108,132 @@ open class Video(
@Transient
@Volatile
var status: State = State.QUEUE
@Transient
private val _progressFlow = MutableStateFlow(0)
@Transient
val progressFlow = _progressFlow.asStateFlow()
var progress: Int
get() = _progressFlow.value
set(value) {
_progressFlow.value = value
}
@Transient
@Volatile
var totalBytesDownloaded: Long = 0L
@Transient
@Volatile
var totalContentLength: Long = 0L
@Transient
@Volatile
var bytesDownloaded: Long = 0L
set(value) {
totalBytesDownloaded += if (value < field) {
value
} else {
value - field
}
field = value
}
@Transient
var progressSubject: Subject<State, State>? = null
fun copy(
videoUrl: String = this.videoUrl,
videoTitle: String = this.videoTitle,
resolution: Int? = this.resolution,
bitrate: Int? = this.bitrate,
headers: Headers? = this.headers,
preferred: Boolean = this.preferred,
subtitleTracks: List<Track> = this.subtitleTracks,
audioTracks: List<Track> = this.audioTracks,
timestamps: List<TimeStamp> = this.timestamps,
internalData: String = this.internalData,
): Video {
return Video(
videoUrl = videoUrl,
videoTitle = videoTitle,
resolution = resolution,
bitrate = bitrate,
headers = headers,
preferred = preferred,
subtitleTracks = subtitleTracks,
audioTracks = audioTracks,
timestamps = timestamps,
internalData = internalData,
)
}
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
bytesDownloaded = bytesRead
if (contentLength > totalContentLength) {
totalContentLength = contentLength
}
val newProgress = if (totalContentLength > 0) {
(100 * totalBytesDownloaded / totalContentLength).toInt()
} else {
-1
}
if (progress != newProgress) progress = newProgress
fun copy(
videoUrl: String = this.videoUrl,
videoTitle: String = this.videoTitle,
resolution: Int? = this.resolution,
bitrate: Int? = this.bitrate,
headers: Headers? = this.headers,
preferred: Boolean = this.preferred,
subtitleTracks: List<Track> = this.subtitleTracks,
audioTracks: List<Track> = this.audioTracks,
timestamps: List<TimeStamp> = this.timestamps,
internalData: String = this.internalData,
initialized: Boolean = this.initialized,
videoPageUrl: String = this.videoPageUrl,
): Video {
return Video(
videoUrl = videoUrl,
videoTitle = videoTitle,
resolution = resolution,
bitrate = bitrate,
headers = headers,
preferred = preferred,
subtitleTracks = subtitleTracks,
audioTracks = audioTracks,
timestamps = timestamps,
internalData = internalData,
initialized = initialized,
videoPageUrl = videoPageUrl,
)
}
enum class State {
QUEUE,
LOAD_VIDEO,
DOWNLOAD_IMAGE,
READY,
ERROR,
}
}
@Throws(IOException::class)
private fun writeObject(out: ObjectOutputStream) {
out.defaultWriteObject()
val headersMap: Map<String, List<String>> = headers?.toMultimap() ?: emptyMap()
out.writeObject(headersMap)
}
@kotlinx.serialization.Serializable
data class SerializableVideo(
val videoUrl: String = "",
val videoTitle: String = "",
val resolution: Int? = null,
val bitrate: Int? = null,
val headers: List<Pair<String, String>>? = null,
val preferred: Boolean = false,
val subtitleTracks: List<Track> = emptyList(),
val audioTracks: List<Track> = emptyList(),
val timestamps: List<TimeStamp> = emptyList(),
val internalData: String = "",
val initialized: Boolean = false,
// TODO(1.6): Remove after ext lib bump
val videoPageUrl: String = "",
) {
@Suppress("UNCHECKED_CAST")
@Throws(IOException::class, ClassNotFoundException::class)
private fun readObject(input: ObjectInputStream) {
input.defaultReadObject()
val headersMap = input.readObject() as? Map<String, List<String>>
headers = headersMap?.let { map ->
val builder = Headers.Builder()
for ((key, values) in map) {
for (value in values) {
builder.add(key, value)
}
}
builder.build()
}
companion object {
fun List<Video>.serialize(): String =
Json.encodeToString(
this.map { vid ->
SerializableVideo(
vid.videoUrl,
vid.videoTitle,
vid.resolution,
vid.bitrate,
vid.headers?.toList(),
vid.preferred,
vid.subtitleTracks,
vid.audioTracks,
vid.timestamps,
vid.internalData,
vid.initialized,
vid.videoPageUrl,
)
},
)
fun String.toVideoList(): List<Video> =
Json.decodeFromString<List<SerializableVideo>>(this)
.map { sVid ->
Video(
sVid.videoUrl,
sVid.videoTitle,
sVid.resolution,
sVid.bitrate,
sVid.headers
?.flatMap { it.toList() }
?.let { Headers.headersOf(*it.toTypedArray()) },
sVid.preferred,
sVid.subtitleTracks,
sVid.audioTracks,
sVid.timestamps,
sVid.internalData,
sVid.initialized,
sVid.videoPageUrl,
)
}
}
}

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.extension.api
import ani.dantotsu.asyncMap
import ani.dantotsu.parsers.novel.AvailableNovelSources
import ani.dantotsu.parsers.novel.NovelExtension
import ani.dantotsu.settings.saving.PrefManager
@ -67,7 +68,7 @@ internal class ExtensionGithubApi {
val repos =
PrefManager.getVal<Set<String>>(PrefName.AnimeExtensionRepos).toMutableList()
repos.forEach {
repos.asyncMap {
val repoUrl = if (it.contains("index.min.json")) {
it
} else {
@ -155,7 +156,7 @@ internal class ExtensionGithubApi {
val repos =
PrefManager.getVal<Set<String>>(PrefName.MangaExtensionRepos).toMutableList()
repos.forEach {
repos.asyncMap {
val repoUrl = if (it.contains("index.min.json")) {
it
} else {
@ -207,7 +208,7 @@ internal class ExtensionGithubApi {
val repos =
PrefManager.getVal<Set<String>>(PrefName.NovelExtensionRepos).toMutableList()
repos.forEach {
repos.asyncMap {
val repoUrl = if (it.contains("index.min.json")) {
it
} else {

View file

@ -419,13 +419,19 @@
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<FrameLayout
android:id="@+id/mediaBottomBarContainer"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="0"
android:background="?attr/colorSurface"
android:layout_gravity="center_horizontal|bottom">
<nl.joery.animatedbottombar.AnimatedBottomBar
android:id="@+id/mediaBottomBar"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center_horizontal|bottom"
android:layout_weight="0"
android:background="?attr/colorSurface"
android:padding="0dp"
app:abb_animationInterpolator="@anim/over_shoot"
@ -438,5 +444,6 @@
app:itemTextAppearanceActive="@style/NavBarText"
app:itemTextAppearanceInactive="@style/NavBarText"
app:itemTextColor="@color/tab_layout_icon" />
</FrameLayout>
</LinearLayout>

View file

@ -353,11 +353,18 @@
</LinearLayout>
</LinearLayout>
<FrameLayout
android:id="@+id/mediaBottomBarContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:layout_gravity="center_horizontal|bottom">
<nl.joery.animatedbottombar.AnimatedBottomBar
android:id="@+id/mediaBottomBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|bottom"
android:layout_gravity="center_horizontal|top"
android:background="?attr/colorSurface"
android:padding="0dp"
app:abb_animationInterpolator="@anim/over_shoot"
@ -371,6 +378,7 @@
app:itemTextAppearanceActive="@style/NavBarText"
app:itemTextAppearanceInactive="@style/NavBarText"
app:itemTextColor="@color/tab_layout_icon" />
</FrameLayout>
</LinearLayout>