diff --git a/app/src/main/java/com/exapps/anistream/MainActivity.kt b/app/src/main/java/com/exapps/anistream/MainActivity.kt index b8fabd8..bd6a973 100644 --- a/app/src/main/java/com/exapps/anistream/MainActivity.kt +++ b/app/src/main/java/com/exapps/anistream/MainActivity.kt @@ -12,12 +12,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi import com.exapps.anistream.presentation.navigation.AniStreamNavGraph import com.exapps.anistream.presentation.root.RootViewModel import com.exapps.anistream.ui.theme.AniStreamTheme import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint +@UnstableApi class MainActivity : ComponentActivity() { private val rootViewModel: RootViewModel by viewModels() @@ -31,7 +33,10 @@ class MainActivity : ComponentActivity() { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { AniStreamTheme(dynamicColor = rootState.dynamicColors) { Surface(modifier = Modifier.fillMaxSize()) { - AniStreamNavGraph() + AniStreamNavGraph( + needsAnime3rbSession = rootState.needsAnime3rbSession, + onAnime3rbSessionReady = rootViewModel::syncAnime3rbSession, + ) } } } diff --git a/app/src/main/java/com/exapps/anistream/core/network/CloudflareChallengeInterceptor.kt b/app/src/main/java/com/exapps/anistream/core/network/CloudflareChallengeInterceptor.kt index e061dbb..bc61356 100644 --- a/app/src/main/java/com/exapps/anistream/core/network/CloudflareChallengeInterceptor.kt +++ b/app/src/main/java/com/exapps/anistream/core/network/CloudflareChallengeInterceptor.kt @@ -1,7 +1,6 @@ package com.exapps.anistream.core.network import com.exapps.anistream.core.webview.CloudflareChallengeSolver -import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject @@ -21,9 +20,7 @@ class CloudflareChallengeInterceptor @Inject constructor( } response.close() - runBlocking { - solver.ensureClearance(request.url) - } + solver.markChallengeRequired() return chain.proceed( request.newBuilder() diff --git a/app/src/main/java/com/exapps/anistream/core/network/InMemoryCookieJar.kt b/app/src/main/java/com/exapps/anistream/core/network/InMemoryCookieJar.kt index 9fd1c51..067c26a 100644 --- a/app/src/main/java/com/exapps/anistream/core/network/InMemoryCookieJar.kt +++ b/app/src/main/java/com/exapps/anistream/core/network/InMemoryCookieJar.kt @@ -11,6 +11,7 @@ interface MutableCookieStore { fun saveFromResponse(url: HttpUrl, cookies: List) fun loadForRequest(url: HttpUrl): List fun saveFromHeader(url: HttpUrl, cookieHeader: String) + fun hasCookies(url: HttpUrl): Boolean } @Singleton @@ -62,4 +63,6 @@ class InMemoryCookieJar @Inject constructor() : CookieJar, MutableCookieStore { saveFromResponse(url, cookies) } + + override fun hasCookies(url: HttpUrl): Boolean = loadForRequest(url).isNotEmpty() } diff --git a/app/src/main/java/com/exapps/anistream/core/webview/Anime3rbSessionBridge.kt b/app/src/main/java/com/exapps/anistream/core/webview/Anime3rbSessionBridge.kt new file mode 100644 index 0000000..b10a571 --- /dev/null +++ b/app/src/main/java/com/exapps/anistream/core/webview/Anime3rbSessionBridge.kt @@ -0,0 +1,36 @@ +package com.exapps.anistream.core.webview + +import android.webkit.CookieManager +import com.exapps.anistream.core.network.BrowserHeaders +import com.exapps.anistream.core.network.MutableCookieStore +import com.exapps.anistream.core.network.WebSessionStore +import okhttp3.HttpUrl.Companion.toHttpUrl +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class Anime3rbSessionBridge @Inject constructor( + private val cookieStore: MutableCookieStore, + private val sessionStore: WebSessionStore, +) { + fun hasSession(): Boolean = sessionStore.hasFreshSession(BASE_URL.toHttpUrl()) + + fun syncFromWebView(userAgent: String? = null): Boolean { + val url = BASE_URL.toHttpUrl() + val cookieHeader = CookieManager.getInstance().getCookie(BASE_URL).orEmpty() + val resolvedUserAgent = userAgent?.takeIf { it.isNotBlank() } + ?: BrowserHeaders.FALLBACK_USER_AGENT + sessionStore.updateUserAgent(resolvedUserAgent) + + cookieStore.saveFromHeader(url, cookieHeader) + val hasCookies = cookieStore.hasCookies(url) + if (hasCookies) { + sessionStore.markSessionAcquired(url) + } + return hasCookies + } + + companion object { + const val BASE_URL = "https://anime3rb.com/" + } +} diff --git a/app/src/main/java/com/exapps/anistream/core/webview/CloudflareChallengeSolver.kt b/app/src/main/java/com/exapps/anistream/core/webview/CloudflareChallengeSolver.kt index cc5b344..ad5ad7b 100644 --- a/app/src/main/java/com/exapps/anistream/core/webview/CloudflareChallengeSolver.kt +++ b/app/src/main/java/com/exapps/anistream/core/webview/CloudflareChallengeSolver.kt @@ -1,7 +1,5 @@ package com.exapps.anistream.core.webview -import okhttp3.HttpUrl - interface CloudflareChallengeSolver { - suspend fun ensureClearance(url: HttpUrl) + fun markChallengeRequired() } diff --git a/app/src/main/java/com/exapps/anistream/core/webview/CloudflareWebViewInterceptor.kt b/app/src/main/java/com/exapps/anistream/core/webview/CloudflareWebViewInterceptor.kt deleted file mode 100644 index c0e1653..0000000 --- a/app/src/main/java/com/exapps/anistream/core/webview/CloudflareWebViewInterceptor.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.exapps.anistream.core.webview - -import android.content.Context -import android.graphics.Bitmap -import android.os.Handler -import android.os.Looper -import android.webkit.CookieManager -import android.webkit.WebChromeClient -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebSettings -import android.webkit.WebView -import android.webkit.WebViewClient -import com.exapps.anistream.core.common.DispatcherProvider -import com.exapps.anistream.core.network.BrowserHeaders -import com.exapps.anistream.core.network.MutableCookieStore -import com.exapps.anistream.core.network.WebSessionStore -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class CloudflareWebViewInterceptor @Inject constructor( - @ApplicationContext private val appContext: Context, - private val cookieStore: MutableCookieStore, - private val sessionStore: WebSessionStore, - private val dispatchers: DispatcherProvider, -) : CloudflareChallengeSolver { - - private val mutex = Mutex() - - override suspend fun ensureClearance(url: HttpUrl) { - if (sessionStore.hasFreshSession(url)) return - - mutex.withLock { - if (sessionStore.hasFreshSession(url)) return - - withTimeout(45_000) { - withContext(dispatchers.main) { - check(Looper.myLooper() == Looper.getMainLooper()) { - "WebView bootstrap must run on the main thread" - } - - suspendCancellableCoroutine { continuation -> - val cookieManager = CookieManager.getInstance().apply { - setAcceptCookie(true) - } - val webView = WebView(appContext) - val mainHandler = Handler(Looper.getMainLooper()) - val cleanedUp = AtomicBoolean(false) - runCatching { cookieManager.setAcceptThirdPartyCookies(webView, true) } - - fun cleanup() { - if (!cleanedUp.compareAndSet(false, true)) return - mainHandler.post { - runCatching { webView.stopLoading() } - runCatching { webView.loadUrl("about:blank") } - runCatching { webView.clearHistory() } - runCatching { webView.removeAllViews() } - runCatching { webView.destroy() } - } - } - - continuation.invokeOnCancellation { cleanup() } - - webView.settings.apply { - javaScriptEnabled = true - domStorageEnabled = true - cacheMode = WebSettings.LOAD_DEFAULT - loadsImagesAutomatically = false - mediaPlaybackRequiresUserGesture = false - userAgentString = sessionStore.currentUserAgent() - } - webView.isVerticalScrollBarEnabled = false - webView.isHorizontalScrollBarEnabled = false - webView.webChromeClient = WebChromeClient() - webView.webViewClient = object : WebViewClient() { - override fun onPageStarted(view: WebView, pageUrl: String, favicon: Bitmap?) { - val currentUserAgent = view.settings.userAgentString.orEmpty() - if (currentUserAgent.isNotBlank()) { - sessionStore.updateUserAgent(currentUserAgent) - } - } - - override fun onPageFinished(view: WebView, pageUrl: String) { - view.postDelayed({ - if (continuation.isCompleted) return@postDelayed - - val resolvedUrl = pageUrl.toHttpUrlOrNull() ?: url - val cookieHeader = cookieManager.getCookie(pageUrl) - ?: cookieManager.getCookie(url.toString()) - .orEmpty() - val currentUserAgent = view.settings.userAgentString.orEmpty() - if (currentUserAgent.isNotBlank()) { - sessionStore.updateUserAgent(currentUserAgent) - } - - val solved = cookieHeader.contains("cf_clearance") || - (!view.title.orEmpty().contains("Just a moment", ignoreCase = true) && - !view.title.orEmpty().contains("Attention Required", ignoreCase = true)) - - if (!solved) return@postDelayed - - cookieManager.flush() - cookieStore.saveFromHeader(resolvedUrl, cookieHeader) - sessionStore.markSessionAcquired(resolvedUrl) - continuation.resume(Unit) - cleanup() - }, 900) - } - - override fun onReceivedError( - view: WebView, - request: WebResourceRequest, - error: WebResourceError, - ) { - if (!request.isForMainFrame || continuation.isCompleted) return - continuation.resumeWithException(IllegalStateException(error.description.toString())) - cleanup() - } - } - - webView.loadUrl( - url.toString(), - BrowserHeaders.buildDefaultHeaders(sessionStore.currentUserAgent()), - ) - } - } - } - } - } -} diff --git a/app/src/main/java/com/exapps/anistream/core/webview/VisibleCloudflareChallengeSolver.kt b/app/src/main/java/com/exapps/anistream/core/webview/VisibleCloudflareChallengeSolver.kt new file mode 100644 index 0000000..1003237 --- /dev/null +++ b/app/src/main/java/com/exapps/anistream/core/webview/VisibleCloudflareChallengeSolver.kt @@ -0,0 +1,21 @@ +package com.exapps.anistream.core.webview + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VisibleCloudflareChallengeSolver @Inject constructor() : CloudflareChallengeSolver { + private val _challengeRequired = MutableStateFlow(false) + val challengeRequired: StateFlow = _challengeRequired.asStateFlow() + + override fun markChallengeRequired() { + _challengeRequired.value = true + } + + fun markSolved() { + _challengeRequired.value = false + } +} diff --git a/app/src/main/java/com/exapps/anistream/data/download/AnimeDownloadService.kt b/app/src/main/java/com/exapps/anistream/data/download/AnimeDownloadService.kt index 0559a17..1a94c75 100644 --- a/app/src/main/java/com/exapps/anistream/data/download/AnimeDownloadService.kt +++ b/app/src/main/java/com/exapps/anistream/data/download/AnimeDownloadService.kt @@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleService import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi import androidx.media3.transformer.Composition import androidx.media3.transformer.EditedMediaItem import androidx.media3.transformer.ExportException @@ -34,6 +35,7 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @AndroidEntryPoint +@UnstableApi class AnimeDownloadService : LifecycleService() { @Inject @@ -53,6 +55,7 @@ class AnimeDownloadService : LifecycleService() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) when (intent?.action) { ACTION_CANCEL -> { activeTransformer?.cancel() diff --git a/app/src/main/java/com/exapps/anistream/data/preferences/UserPreferencesDataSource.kt b/app/src/main/java/com/exapps/anistream/data/preferences/UserPreferencesDataSource.kt index 9d001c3..5ee1097 100644 --- a/app/src/main/java/com/exapps/anistream/data/preferences/UserPreferencesDataSource.kt +++ b/app/src/main/java/com/exapps/anistream/data/preferences/UserPreferencesDataSource.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.exapps.anistream.domain.model.UserPreferences import dagger.hilt.android.qualifiers.ApplicationContext @@ -27,6 +28,7 @@ class UserPreferencesDataSource @Inject constructor( preferSummary = prefs[Keys.PREFER_SUMMARY] ?: false, cinemaMode = prefs[Keys.CINEMA_MODE] ?: false, dynamicColors = prefs[Keys.DYNAMIC_COLORS] ?: true, + skipIntroSeconds = prefs[Keys.SKIP_INTRO_SECONDS] ?: 85, ) } @@ -46,10 +48,15 @@ class UserPreferencesDataSource @Inject constructor( store.edit { it[Keys.DYNAMIC_COLORS] = enabled } } + suspend fun setSkipIntroSeconds(seconds: Int) { + store.edit { it[Keys.SKIP_INTRO_SECONDS] = seconds.coerceIn(0, 180) } + } + private object Keys { val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next") val PREFER_SUMMARY = booleanPreferencesKey("prefer_summary") val CINEMA_MODE = booleanPreferencesKey("cinema_mode") val DYNAMIC_COLORS = booleanPreferencesKey("dynamic_colors") + val SKIP_INTRO_SECONDS = intPreferencesKey("skip_intro_seconds") } } diff --git a/app/src/main/java/com/exapps/anistream/data/repository/AnimeRepositoryImpl.kt b/app/src/main/java/com/exapps/anistream/data/repository/AnimeRepositoryImpl.kt index 400cc3d..8622b1d 100644 --- a/app/src/main/java/com/exapps/anistream/data/repository/AnimeRepositoryImpl.kt +++ b/app/src/main/java/com/exapps/anistream/data/repository/AnimeRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.exapps.anistream.data.local.WatchlistDao import com.exapps.anistream.data.preferences.UserPreferencesDataSource import com.exapps.anistream.data.scraper.AnimeExtractor import com.exapps.anistream.domain.model.AnimeDetails +import com.exapps.anistream.domain.model.CatalogCategory import com.exapps.anistream.domain.model.CatalogFilters import com.exapps.anistream.domain.model.EpisodeStream import com.exapps.anistream.domain.model.HomeFeed @@ -30,6 +31,10 @@ class AnimeRepositoryImpl @Inject constructor( override suspend fun getHomeFeed(): HomeFeed = extractor.getHomeFeed() + override suspend fun getCatalog(category: CatalogCategory, page: Int, filters: CatalogFilters): PaginatedTitles { + return extractor.getCatalog(category, page, filters) + } + override suspend fun getCatalog(page: Int, filters: CatalogFilters): PaginatedTitles { return extractor.getCatalog(page, filters) } @@ -155,6 +160,10 @@ class AnimeRepositoryImpl @Inject constructor( preferencesDataSource.setDynamicColors(enabled) } + override suspend fun setSkipIntroSeconds(seconds: Int) { + preferencesDataSource.setSkipIntroSeconds(seconds) + } + private fun createEntity( details: AnimeDetails, existing: WatchlistAnimeEntity? = null, diff --git a/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbExtractor.kt b/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbExtractor.kt index 2dbc1d7..bc6813b 100644 --- a/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbExtractor.kt +++ b/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbExtractor.kt @@ -1,9 +1,13 @@ package com.exapps.anistream.data.scraper import com.exapps.anistream.core.common.DispatcherProvider -import com.exapps.anistream.core.webview.CloudflareChallengeSolver +import com.exapps.anistream.core.network.CloudflareChallengeDetector +import com.exapps.anistream.core.webview.Anime3rbSessionBridge +import com.exapps.anistream.core.webview.VisibleCloudflareChallengeSolver import com.exapps.anistream.domain.model.AnimeDetails +import com.exapps.anistream.domain.model.CatalogCategory import com.exapps.anistream.domain.model.CatalogFilters +import com.exapps.anistream.domain.model.CatalogSort import com.exapps.anistream.domain.model.EpisodeStream import com.exapps.anistream.domain.model.HomeFeed import com.exapps.anistream.domain.model.PaginatedTitles @@ -15,6 +19,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import org.jsoup.Jsoup import org.jsoup.nodes.Document +import java.net.URLEncoder import javax.inject.Inject import javax.inject.Singleton @@ -24,7 +29,8 @@ class Anime3rbExtractor @Inject constructor( private val parser: Anime3rbHtmlParser, private val videoStreamResolver: VideoStreamResolver, private val autoFailoverPlaybackResolver: AutoFailoverPlaybackResolver, - private val cloudflareChallengeSolver: CloudflareChallengeSolver, + private val sessionBridge: Anime3rbSessionBridge, + private val challengeSolver: VisibleCloudflareChallengeSolver, private val dispatchers: DispatcherProvider, ) : AnimeExtractor { @@ -32,35 +38,31 @@ class Anime3rbExtractor @Inject constructor( return parser.parseHome(fetchDocument(BASE_URL)) } + override suspend fun getCatalog(category: CatalogCategory, page: Int, filters: CatalogFilters): PaginatedTitles { + val url = BASE_URL.toHttpUrl().newBuilder() + .addPathSegments(category.path) + .addCatalogFilters(page, filters) + .build() + return parser.parseCatalog(fetchDocument(url.toString()), page) + } + override suspend fun getCatalog(page: Int, filters: CatalogFilters): PaginatedTitles { val url = BASE_URL.toHttpUrl().newBuilder() .addPathSegments("titles/list") - .addQueryParameter("sort_by", filters.sort.wireValue) - .addQueryParameter("sort_dir", filters.direction.wireValue) - .apply { filters.season?.let { addQueryParameter("season", it) } } - .apply { filters.year?.let { addQueryParameter("year", it.toString()) } } - .apply { filters.genreSlug?.let { addQueryParameter("genres", it) } } - .apply { filters.status?.let { addQueryParameter("status", it) } } - .apply { filters.ageRating?.let { addQueryParameter("age", it) } } - .apply { if (page > 1) addQueryParameter("page", page.toString()) } + .addCatalogFilters(page, filters) .build() return parser.parseCatalog(fetchDocument(url.toString()), page) } override suspend fun search(query: String, page: Int, filters: CatalogFilters): PaginatedTitles { - val url = BASE_URL.toHttpUrl().newBuilder() - .addPathSegment("search") - .addQueryParameter("q", query) - .addQueryParameter("sort_by", filters.sort.wireValue) - .addQueryParameter("sort_dir", filters.direction.wireValue) - .apply { filters.season?.let { addQueryParameter("season", it) } } - .apply { filters.year?.let { addQueryParameter("year", it.toString()) } } - .apply { filters.genreSlug?.let { addQueryParameter("genres", it) } } - .apply { filters.status?.let { addQueryParameter("status", it) } } - .apply { filters.ageRating?.let { addQueryParameter("age", it) } } - .apply { if (page > 1) addQueryParameter("page", page.toString()) } - .build() - return parser.parseSearch(fetchDocument(url.toString()), page) + val encodedQuery = URLEncoder.encode(query.trim(), Charsets.UTF_8.name()) + val url = buildString { + append(BASE_URL) + append("/search?q=") + append(encodedQuery) + if (page > 1) append("&page=").append(page) + } + return parser.parseSearch(fetchDocument(url), page) } override suspend fun getAnimeDetails(slug: String): AnimeDetails { @@ -110,7 +112,7 @@ class Anime3rbExtractor @Inject constructor( ?: mergedSources.firstOrNull { it.type == StreamType.MKV }?.url return parsed.copy( - playbackUrl = preferredPlayback ?: playbackAttempt.playbackUrl ?: parsed.playbackUrl, + playbackUrl = preferredPlayback ?: parsed.iframeUrl ?: playbackAttempt.playbackUrl ?: parsed.playbackUrl, availableSources = mergedSources, selectedServerId = playbackAttempt.selectedServerId ?: parsed.selectedServerId, attemptedServerIds = playbackAttempt.attemptedServerIds, @@ -119,16 +121,54 @@ class Anime3rbExtractor @Inject constructor( private suspend fun fetchDocument(url: String): Document { return withContext(dispatchers.io) { - val httpUrl = url.toHttpUrl() - cloudflareChallengeSolver.ensureClearance(httpUrl) + sessionBridge.syncFromWebView() val request = Request.Builder().url(url).get().build() client.newCall(request).execute().use { response -> - check(response.isSuccessful) { "Request failed: ${response.code} ${response.message}" } + if (response.code == 403 || CloudflareChallengeDetector.isChallenge(response)) { + challengeSolver.markChallengeRequired() + } + check(response.isSuccessful) { + if (response.code == 403) { + "Anime3rb Cloudflare session expired. Complete the verification screen, then retry." + } else { + "Request failed: ${response.code} ${response.message}" + } + } Jsoup.parse(response.body?.string().orEmpty(), response.request.url.toString()) } } } + private fun okhttp3.HttpUrl.Builder.addCatalogFilters( + page: Int, + filters: CatalogFilters, + includeDefaultSort: Boolean = true, + ): okhttp3.HttpUrl.Builder { + if (includeDefaultSort || filters.sort != CatalogSort.ADDITION_DATE) { + addQueryParameter("sort_by", filters.sort.wireValue) + } + addQueryParameter("sort_dir", filters.direction.wireValue) + filters.season?.let { + addQueryParameter("season", it) + addQueryParameter("release_season", it) + } + filters.year?.let { + addQueryParameter("year", it.toString()) + addQueryParameter("release_year", it.toString()) + } + filters.genreSlug?.let { + addQueryParameter("genres", it) + addQueryParameter("genre", it) + } + filters.status?.let { addQueryParameter("status", it) } + filters.ageRating?.let { + addQueryParameter("age", it) + addQueryParameter("age_ratings", it) + } + if (page > 1) addQueryParameter("page", page.toString()) + return this + } + private companion object { const val BASE_URL = "https://anime3rb.com" } diff --git a/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParser.kt b/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParser.kt index 3c84796..d02c887 100644 --- a/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParser.kt +++ b/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParser.kt @@ -12,6 +12,13 @@ import com.exapps.anistream.domain.model.PaginatedTitles import com.exapps.anistream.domain.model.StreamType import com.exapps.anistream.domain.model.TrailerItem import com.exapps.anistream.domain.model.VideoSource +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.parser.Parser @@ -57,24 +64,40 @@ class Anime3rbHtmlParser @Inject constructor() { } fun parseCatalog(document: Document, page: Int): PaginatedTitles { + val titleCards = document.select("div.titles-list div.title-card") + .map(::parseTitleCard) + val items = titleCards.ifEmpty { parseStructuredDataTitleList(document) } + return PaginatedTitles( - items = document.select("div.titles-list div.title-card").map(::parseTitleCard).distinctBy { it.slug }, + items = items.distinctBy { it.slug }, currentPage = page, - hasNextPage = document.selectFirst("nav[aria-label=Pagination Navigation] button[rel=next]") != null, + hasNextPage = document.hasNextPage(), ) } fun parseSearch(document: Document, page: Int): PaginatedTitles { + val titleCards = document.select("div.titles-list div.title-card") + .map(::parseTitleCard) + val items = titleCards + .ifEmpty { parseStructuredDataTitleList(document) } + .ifEmpty { document.select("a.simple-title-card[href*=/titles/]").map(::parseSimpleTitleCard) } + return PaginatedTitles( - items = document.select("div.titles-list div.title-card").map(::parseTitleCard).distinctBy { it.slug }, + items = items.distinctBy { it.slug }, currentPage = page, - hasNextPage = document.selectFirst("nav[aria-label=Pagination Navigation] button[rel=next]") != null, + hasNextPage = document.hasNextPage(), ) } fun parseDetails(document: Document, slug: String): AnimeDetails { + val structuredTitle = document.structuredDataObjects() + .firstOrNull { it.typeName() in TITLE_SCHEMA_TYPES } + val aggregateRating = structuredTitle?.get("aggregateRating")?.jsonObjectOrNull() val titleHeader = document.selectFirst("h1.text-2xl") - val title = titleHeader?.selectFirst("span[dir=ltr]")?.text().orEmpty() + val title = titleHeader?.selectFirst("span[dir=ltr]")?.text()?.trim() + ?: structuredTitle?.stringValue("name")?.cleanSiteSuffix() + ?: document.selectFirst("meta[property=og:title]")?.attr("content")?.cleanSiteSuffix() + ?: "" val typeLabel = titleHeader ?.select("span") ?.getOrNull(1) @@ -83,7 +106,10 @@ class Anime3rbHtmlParser @Inject constructor() { ?.removeSuffix(")") ?.trim() - val posterUrl = document.selectFirst("section img[alt^=بوستر]")?.bestImageUrl().orEmpty() + val posterUrl = document.selectFirst("section img[alt^=بوستر]")?.bestImageUrl() + ?: structuredTitle?.stringValue("image") + ?: document.selectFirst("meta[property=og:image]")?.attr("content") + ?: "" val contentRoot = titleHeader?.parent()?.parent() ?: document val detailsTable = document.select("table.leading-loose").firstOrNull() val metrics = document.select("div.rounded-lg.border") @@ -110,6 +136,7 @@ class Anime3rbHtmlParser @Inject constructor() { ?.select("h2") ?.eachText() .orEmpty() + .ifEmpty { structuredTitle?.stringListValue("alternateName").orEmpty() } val externalLinks = contentRoot .findLabeledBlock("المصادر") @@ -147,12 +174,23 @@ class Anime3rbHtmlParser @Inject constructor() { studio = detailsTable.valueForLabel("الاستديو:"), author = detailsTable.valueForLabel("المؤلف:"), ageRating = metrics.metricValue("التصنيف العمري"), - score = metrics.metricValue("التقييم"), - episodeCount = metrics.metricValue("الحلقات")?.toIntOrNull(), - synopsis = fullSynopsis, + score = metrics.metricValue("التقييم") + ?: aggregateRating?.stringValue("ratingValue") + ?: aggregateRating?.doubleValue("ratingValue")?.trimTrailingZero(), + ratingCount = aggregateRating?.intValue("ratingCount"), + publishedAt = structuredTitle?.stringValue("datePublished")?.substringBefore("T"), + episodeCount = metrics.metricValue("الحلقات")?.toIntOrNull() + ?: structuredTitle?.intValue("numberOfEpisodes") + ?: episodes.size.takeIf { it > 0 }, + synopsis = fullSynopsis.ifBlank { + structuredTitle?.stringValue("description") + ?: document.selectFirst("meta[name=description]")?.attr("content") + ?: "" + }, summary = summary, alternateTitles = alternateTitles, - genres = contentRoot.select("a[href*=/genre/]").map { it.text().trim() }.distinct(), + genres = contentRoot.select("a[href*=/genre/]").map { it.text().trim() }.distinct() + .ifEmpty { structuredTitle?.stringListValue("genre").orEmpty() }, episodes = episodes, trailers = trailers, externalLinks = externalLinks, @@ -161,6 +199,8 @@ class Anime3rbHtmlParser @Inject constructor() { } fun parseEpisodePage(document: Document, titleSlug: String, episodeNumber: Int): EpisodeStream { + val structuredVideos = document.structuredDataObjects() + .filter { it.typeName() == "VideoObject" } val playerComponent = document.allElements.firstOrNull { element -> element.hasAttr("wire:snapshot") && element.attr("wire:snapshot").contains("video_url") } @@ -169,20 +209,40 @@ class Anime3rbHtmlParser @Inject constructor() { ?.text() ?.substringBefore("الحلقة") ?.trim() + .takeUnless { it.isNullOrBlank() } + ?: document.selectFirst("script[type=application/ld+json]") + ?.data() + ?.substringAfter("\"isPartOf\"", missingDelimiterValue = "") + ?.substringAfter("\"name\":\"", missingDelimiterValue = "") + ?.substringBefore(" - Anime3rb", missingDelimiterValue = "") + ?.cleanSiteSuffix() .orEmpty() val posterUrl = document.selectFirst("div.video-list img[alt^=بوستر]")?.bestImageUrl() ?: document.selectFirst("meta[property=og:image]")?.attr("content") + ?: structuredVideos.firstNotNullOfOrNull { it.stringValue("thumbnailUrl") } ?: "" val snapshot = playerComponent?.attr("wire:snapshot").orEmpty() val decodedSnapshot = Parser.unescapeEntities(snapshot, false) - val directPlayerUrl = decodedSnapshot.livewireValue("video_url") ?: document.selectFirst("iframe")?.absUrl("src") + val directPlayerUrl = decodedSnapshot.livewireValue("video_url") + ?: document.selectFirst("iframe")?.absUrl("src") + ?: structuredVideos.firstNotNullOfOrNull { it.stringValue("embedUrl") }?.htmlDecoded() + val iframeUrl = document.selectFirst("iframe")?.absUrl("src")?.htmlDecoded() + ?: directPlayerUrl?.appendQueryParameterIfMissing("cinema", "760") val activeSourceId = decodedSnapshot.livewireValue("video_source") val csrfToken = document.selectFirst("meta[name=csrf-token]")?.attr("content") - val iframeUrl = document.selectFirst("iframe")?.absUrl("src") val episodeTitle = document.selectFirst("h2.inline.text-lg.font-light")?.text()?.trim() + ?: structuredVideos.firstNotNullOfOrNull { it.stringValue("description") } + ?.substringAfter(" بعنوان ", missingDelimiterValue = "") + ?.substringBefore(" - Anime3rb", missingDelimiterValue = "") + ?.takeIf { it.isNotBlank() } val views = decodedSnapshot.livewireValue("views")?.toLongOrNull() + ?: structuredVideos.firstNotNullOfOrNull { video -> + video["interactionStatistic"] + ?.jsonObjectOrNull() + ?.intValue("userInteractionCount") + }?.toLong() val batchDownloadUrl = document.selectFirst("a[href*=/titles/$titleSlug/download]")?.absUrl("href") val nextEpisodeNumber = document @@ -198,11 +258,20 @@ class Anime3rbHtmlParser @Inject constructor() { VideoSource( id = button.attr("data-video-source").ifBlank { "source-$index" }, label = label, - url = directPlayerUrl.orEmpty(), + url = (iframeUrl ?: directPlayerUrl).orEmpty(), type = StreamType.PLAYER_PAGE, serverId = button.attr("data-video-source").ifBlank { null }, ) } + val structuredSources = structuredVideos.mapIndexedNotNull { index, video -> + val url = video.stringValue("embedUrl")?.htmlDecoded() ?: return@mapIndexedNotNull null + VideoSource( + id = "structured-video-$index", + label = video.stringValue("name")?.cleanSiteSuffix() ?: "Embedded player", + url = url, + type = StreamType.PLAYER_PAGE, + ) + } val downloadLinks = document.select("a[href*=/download/]").mapNotNull { link -> val url = link.absUrl("href") @@ -212,7 +281,7 @@ class Anime3rbHtmlParser @Inject constructor() { qualityLabel = quality.ifBlank { link.text().trim() }, url = url, ) - } + }.filterNot { it.url == batchDownloadUrl } return EpisodeStream( titleSlug = titleSlug, @@ -222,9 +291,10 @@ class Anime3rbHtmlParser @Inject constructor() { episodeTitle = episodeTitle, refererUrl = document.location(), iframeUrl = iframeUrl, - playbackUrl = directPlayerUrl, + playbackUrl = iframeUrl ?: directPlayerUrl, availableSources = when { availableSources.isNotEmpty() -> availableSources + structuredSources.isNotEmpty() -> structuredSources !directPlayerUrl.isNullOrBlank() -> listOf( VideoSource( id = activeSourceId ?: "default", @@ -290,6 +360,52 @@ class Anime3rbHtmlParser @Inject constructor() { ) } + private fun parseSimpleTitleCard(anchor: Element): AnimeCard { + val url = anchor.absUrl("href") + val slug = url.substringAfter("/titles/", missingDelimiterValue = "").substringBefore("/") + val badges = anchor.select("span.badge").eachText() + val rating = badges.firstOrNull { it.contains(Regex("\\d+\\.\\d+")) } + val episodeCount = badges.firstOrNull { it.contains("حلق") } + val seasonLabel = badges.firstOrNull { badge -> badge != rating && badge != episodeCount } + + return AnimeCard( + slug = slug, + title = anchor.selectFirst("h4, h2.title-name")?.text()?.trim() + ?: anchor.selectFirst("img")?.attr("alt")?.removePrefix("بوستر")?.trim() + ?: slug, + posterUrl = anchor.selectFirst("img")?.bestImageUrl().orEmpty(), + detailsUrl = url, + rating = rating?.extractNumber(), + episodeCount = episodeCount?.extractNumber(), + seasonLabel = seasonLabel, + ) + } + + private fun parseStructuredDataTitleList(document: Document): List { + return document.structuredDataObjects() + .filter { it.typeName() == "ItemList" } + .flatMap { itemList -> + itemList["itemListElement"]?.jsonArrayOrNull().orEmpty().mapNotNull { entry -> + val item = entry.jsonObjectOrNull()?.get("item")?.jsonObjectOrNull() + ?: entry.jsonObjectOrNull() + ?: return@mapNotNull null + val url = item.stringValue("url").orEmpty() + val slug = url.substringAfter("/titles/", missingDelimiterValue = "").substringBefore("/") + if (slug.isBlank()) return@mapNotNull null + + AnimeCard( + slug = slug, + title = item.stringValue("name")?.cleanSiteSuffix() ?: slug, + posterUrl = item.stringValue("image").orEmpty(), + detailsUrl = url, + synopsis = item.stringValue("description"), + episodeCount = item.intValue("numberOfEpisodes")?.toString(), + genres = item.stringListValue("genre"), + ) + } + } + } + private fun parseVideoCard(anchor: Element): EpisodeCard { val url = anchor.absUrl("href") val segments = url.substringAfter("https://anime3rb.com").trim('/').split("/") @@ -353,12 +469,84 @@ class Anime3rbHtmlParser @Inject constructor() { } private fun String.livewireValue(field: String): String? { - return Regex("\"$field\":(?:\"(.*?)\"|([0-9]+))") + return Regex("\"${Regex.escape(field)}\"\\s*:\\s*(?:\"((?:\\\\.|[^\"])*)\"|([0-9]+))") .find(this) ?.let { match -> match.groupValues.getOrNull(1).orEmpty().ifBlank { match.groupValues.getOrNull(2).orEmpty() } } ?.replace("\\/", "/") + ?.replace("\\\"", "\"") ?.replace("&", "&") } + + private fun Document.hasNextPage(): Boolean { + return selectFirst("nav[aria-label=Pagination Navigation] button[rel=next]") != null || + selectFirst("button[wire\\:click*=nextPage]:not([disabled])") != null + } + + private fun Document.structuredDataObjects(): List { + return select("script[type=application/ld+json]").flatMap { script -> + val raw = script.data().ifBlank { script.html() }.trim() + runCatching { json.parseToJsonElement(raw).structuredObjects() }.getOrDefault(emptyList()) + } + } + + private fun JsonElement.structuredObjects(): List { + return when (this) { + is JsonArray -> flatMap { it.structuredObjects() } + is JsonObject -> listOf(this) + (get("@graph")?.structuredObjects().orEmpty()) + + (get("video")?.structuredObjects().orEmpty()) + else -> emptyList() + } + } + + private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject + + private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray + + private fun JsonObject.typeName(): String? = stringValue("@type") + + private fun JsonObject.stringValue(key: String): String? { + return this[key]?.jsonPrimitiveOrNull()?.contentOrNull?.takeIf { it.isNotBlank() } + } + + private fun JsonObject.intValue(key: String): Int? { + return this[key]?.jsonPrimitiveOrNull()?.intOrNull + } + + private fun JsonObject.doubleValue(key: String): Double? { + return this[key]?.jsonPrimitiveOrNull()?.contentOrNull?.toDoubleOrNull() + } + + private fun JsonObject.stringListValue(key: String): List { + val element = this[key] ?: return emptyList() + return when (element) { + is JsonArray -> element.mapNotNull { it.jsonPrimitiveOrNull()?.contentOrNull?.takeIf(String::isNotBlank) } + else -> listOfNotNull(element.jsonPrimitiveOrNull()?.contentOrNull?.takeIf(String::isNotBlank)) + } + } + + private fun JsonElement.jsonPrimitiveOrNull() = runCatching { jsonPrimitive }.getOrNull() + + private fun String.cleanSiteSuffix(): String = substringBefore(" - Anime3rb").trim() + + private fun String.htmlDecoded(): String = replace("&", "&") + + private fun Double.trimTrailingZero(): String { + return if (this % 1.0 == 0.0) toInt().toString() else toString() + } + + private fun String.appendQueryParameterIfMissing(name: String, value: String): String { + if (contains("$name=")) return this + val separator = if (contains("?")) "&" else "?" + return "$this$separator$name=$value" + } + + private companion object { + val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + val TITLE_SCHEMA_TYPES = setOf("TVSeries", "Movie", "CreativeWork", "Series") + } } diff --git a/app/src/main/java/com/exapps/anistream/data/scraper/AnimeExtractor.kt b/app/src/main/java/com/exapps/anistream/data/scraper/AnimeExtractor.kt index 8d4a8f1..3e5cc07 100644 --- a/app/src/main/java/com/exapps/anistream/data/scraper/AnimeExtractor.kt +++ b/app/src/main/java/com/exapps/anistream/data/scraper/AnimeExtractor.kt @@ -1,6 +1,7 @@ package com.exapps.anistream.data.scraper import com.exapps.anistream.domain.model.AnimeDetails +import com.exapps.anistream.domain.model.CatalogCategory import com.exapps.anistream.domain.model.CatalogFilters import com.exapps.anistream.domain.model.EpisodeStream import com.exapps.anistream.domain.model.HomeFeed @@ -8,6 +9,7 @@ import com.exapps.anistream.domain.model.PaginatedTitles interface AnimeExtractor { suspend fun getHomeFeed(): HomeFeed + suspend fun getCatalog(category: CatalogCategory, page: Int = 1, filters: CatalogFilters = CatalogFilters()): PaginatedTitles suspend fun getCatalog(page: Int = 1, filters: CatalogFilters = CatalogFilters()): PaginatedTitles suspend fun search(query: String, page: Int = 1, filters: CatalogFilters = CatalogFilters()): PaginatedTitles suspend fun getAnimeDetails(slug: String): AnimeDetails diff --git a/app/src/main/java/com/exapps/anistream/data/scraper/AutoFailoverPlaybackResolver.kt b/app/src/main/java/com/exapps/anistream/data/scraper/AutoFailoverPlaybackResolver.kt index 62400dc..14eb05b 100644 --- a/app/src/main/java/com/exapps/anistream/data/scraper/AutoFailoverPlaybackResolver.kt +++ b/app/src/main/java/com/exapps/anistream/data/scraper/AutoFailoverPlaybackResolver.kt @@ -51,10 +51,11 @@ class AutoFailoverPlaybackResolver @Inject constructor( val attemptedServerIds = mutableListOf() if (orderedServers.isEmpty() && !stream.playbackUrl.isNullOrBlank()) { + val playableSources = resolvePlayableSources(stream.playbackUrl, stream.refererUrl) return@withContext PlaybackAttemptResult( - playbackUrl = stream.playbackUrl, + playbackUrl = playableSources.firstOrNull()?.url ?: stream.playbackUrl, selectedServerId = stream.selectedServerId, - playableSources = resolvePlayableSources(stream.playbackUrl, stream.refererUrl), + playableSources = playableSources, attemptedServerIds = emptyList(), ) } @@ -134,8 +135,10 @@ class AutoFailoverPlaybackResolver @Inject constructor( } private suspend fun resolvePlayableSources(playerUrl: String, refererUrl: String): List { - return videoStreamResolver.resolve(playerUrl = playerUrl, refererUrl = refererUrl) - .filter { it.type == StreamType.HLS || it.type == StreamType.MP4 || it.type == StreamType.MKV } + return runCatching { + videoStreamResolver.resolve(playerUrl = playerUrl, refererUrl = refererUrl) + .filter { it.type == StreamType.HLS || it.type == StreamType.MP4 || it.type == StreamType.MKV } + }.getOrDefault(emptyList()) } private fun extractVideoUrlFromSnapshot(snapshot: String): String? { diff --git a/app/src/main/java/com/exapps/anistream/data/scraper/VideoStreamResolver.kt b/app/src/main/java/com/exapps/anistream/data/scraper/VideoStreamResolver.kt index a45509a..087be36 100644 --- a/app/src/main/java/com/exapps/anistream/data/scraper/VideoStreamResolver.kt +++ b/app/src/main/java/com/exapps/anistream/data/scraper/VideoStreamResolver.kt @@ -2,6 +2,7 @@ package com.exapps.anistream.data.scraper import android.util.Base64 import com.exapps.anistream.core.common.DispatcherProvider +import com.exapps.anistream.core.network.BrowserHeaders import com.exapps.anistream.domain.model.StreamType import com.exapps.anistream.domain.model.VideoSource import com.squareup.duktape.Duktape @@ -23,9 +24,12 @@ class VideoStreamResolver @Inject constructor( val request = Request.Builder() .url(playerUrl) .header("Referer", refererUrl) + .header("Origin", "https://anime3rb.com") + .header("User-Agent", BrowserHeaders.FALLBACK_USER_AGENT) .build() client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "Player request failed: ${response.code} ${response.message}" } val finalUrl = response.request.url.toString() val contentType = response.header("Content-Type").orEmpty() diff --git a/app/src/main/java/com/exapps/anistream/di/AppModule.kt b/app/src/main/java/com/exapps/anistream/di/AppModule.kt index fa68a5b..57eee9e 100644 --- a/app/src/main/java/com/exapps/anistream/di/AppModule.kt +++ b/app/src/main/java/com/exapps/anistream/di/AppModule.kt @@ -18,7 +18,7 @@ import com.exapps.anistream.data.scraper.Anime3rbExtractor import com.exapps.anistream.data.scraper.AnimeExtractor import com.exapps.anistream.domain.repository.AnimeRepository import com.exapps.anistream.core.webview.CloudflareChallengeSolver -import com.exapps.anistream.core.webview.CloudflareWebViewInterceptor +import com.exapps.anistream.core.webview.VisibleCloudflareChallengeSolver import dagger.Binds import dagger.Module import dagger.Provides @@ -39,7 +39,7 @@ abstract class AppBindingsModule { @Binds @Singleton - abstract fun bindCloudflareSolver(impl: CloudflareWebViewInterceptor): CloudflareChallengeSolver + abstract fun bindCloudflareSolver(impl: VisibleCloudflareChallengeSolver): CloudflareChallengeSolver @Binds @Singleton diff --git a/app/src/main/java/com/exapps/anistream/domain/model/AnimeModels.kt b/app/src/main/java/com/exapps/anistream/domain/model/AnimeModels.kt index ba40d31..e566214 100644 --- a/app/src/main/java/com/exapps/anistream/domain/model/AnimeModels.kt +++ b/app/src/main/java/com/exapps/anistream/domain/model/AnimeModels.kt @@ -27,6 +27,27 @@ data class CatalogFilters( val ageRating: String? = null, ) +enum class CatalogCategory(val path: String, val label: String) { + ALL("titles/list", "قائمة الأنمي"), + TV("titles/list/tv", "مسلسلات الأنمي"), + MOVIE("titles/list/movie", "أفلام الأنمي"), + OVA("titles/list/ova", "الأوفا"), + ONA("titles/list/ona", "الأونا"), + SPECIAL("titles/list/special", "الحلقات الخاصة"), + TV_SPECIAL("titles/list/tv-special", "حلقات تلفزيونية خاصة"), + PV("titles/list/pv", "عروض ترويجية"), + CM("titles/list/cm", "إعلانات"), + MUSIC("titles/list/music", "موسيقى"), + OTHER("titles/list/other", "أخرى"), + ; + + companion object { + fun fromPath(path: String): CatalogCategory { + return entries.firstOrNull { it.path == path } ?: ALL + } + } +} + enum class CatalogSort(val wireValue: String) { ADDITION_DATE("addition_date"), NAME("name"), @@ -75,6 +96,8 @@ data class AnimeDetails( val author: String? = null, val ageRating: String? = null, val score: String? = null, + val ratingCount: Int? = null, + val publishedAt: String? = null, val episodeCount: Int? = null, val synopsis: String = "", val summary: String? = null, @@ -123,6 +146,12 @@ data class TrailerItem( val embedUrl: String, ) +@Immutable +data class TrailerRouteArgs( + val title: String, + val embedUrl: String, +) + @Immutable data class EpisodeStream( val titleSlug: String, @@ -174,6 +203,7 @@ data class UserPreferences( val preferSummary: Boolean = false, val cinemaMode: Boolean = false, val dynamicColors: Boolean = true, + val skipIntroSeconds: Int = 85, ) enum class StreamType { diff --git a/app/src/main/java/com/exapps/anistream/domain/repository/AnimeRepository.kt b/app/src/main/java/com/exapps/anistream/domain/repository/AnimeRepository.kt index 5f2d92b..e7d12b4 100644 --- a/app/src/main/java/com/exapps/anistream/domain/repository/AnimeRepository.kt +++ b/app/src/main/java/com/exapps/anistream/domain/repository/AnimeRepository.kt @@ -1,6 +1,7 @@ package com.exapps.anistream.domain.repository import com.exapps.anistream.domain.model.AnimeDetails +import com.exapps.anistream.domain.model.CatalogCategory import com.exapps.anistream.domain.model.CatalogFilters import com.exapps.anistream.domain.model.HomeFeed import com.exapps.anistream.domain.model.PaginatedTitles @@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.Flow interface AnimeRepository { suspend fun getHomeFeed(): HomeFeed + suspend fun getCatalog(category: CatalogCategory, page: Int = 1, filters: CatalogFilters = CatalogFilters()): PaginatedTitles suspend fun getCatalog(page: Int = 1, filters: CatalogFilters = CatalogFilters()): PaginatedTitles suspend fun search(query: String, page: Int = 1, filters: CatalogFilters = CatalogFilters()): PaginatedTitles suspend fun getAnimeDetails(slug: String): AnimeDetails @@ -40,4 +42,5 @@ interface AnimeRepository { suspend fun setCinemaMode(enabled: Boolean) suspend fun setAutoPlayNext(enabled: Boolean) suspend fun setDynamicColors(enabled: Boolean) + suspend fun setSkipIntroSeconds(seconds: Int) } diff --git a/app/src/main/java/com/exapps/anistream/domain/usecase/AnimeUseCases.kt b/app/src/main/java/com/exapps/anistream/domain/usecase/AnimeUseCases.kt index a75e091..c9fb2a3 100644 --- a/app/src/main/java/com/exapps/anistream/domain/usecase/AnimeUseCases.kt +++ b/app/src/main/java/com/exapps/anistream/domain/usecase/AnimeUseCases.kt @@ -1,6 +1,7 @@ package com.exapps.anistream.domain.usecase import com.exapps.anistream.domain.model.AnimeDetails +import com.exapps.anistream.domain.model.CatalogCategory import com.exapps.anistream.domain.model.CatalogFilters import com.exapps.anistream.domain.model.EpisodeStream import com.exapps.anistream.domain.model.HomeFeed @@ -22,6 +23,12 @@ class GetHomeFeedUseCase @Inject constructor( class GetCatalogUseCase @Inject constructor( private val repository: AnimeRepository, ) { + suspend operator fun invoke( + category: CatalogCategory, + page: Int = 1, + filters: CatalogFilters = CatalogFilters(), + ): PaginatedTitles = repository.getCatalog(category, page, filters) + suspend operator fun invoke( page: Int = 1, filters: CatalogFilters = CatalogFilters(), @@ -157,3 +164,9 @@ class SetDynamicColorsUseCase @Inject constructor( ) { suspend operator fun invoke(enabled: Boolean) = repository.setDynamicColors(enabled) } + +class SetSkipIntroSecondsUseCase @Inject constructor( + private val repository: AnimeRepository, +) { + suspend operator fun invoke(seconds: Int) = repository.setSkipIntroSeconds(seconds) +} diff --git a/app/src/main/java/com/exapps/anistream/presentation/catalog/CatalogScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/catalog/CatalogScreen.kt new file mode 100644 index 0000000..8089782 --- /dev/null +++ b/app/src/main/java/com/exapps/anistream/presentation/catalog/CatalogScreen.kt @@ -0,0 +1,164 @@ +package com.exapps.anistream.presentation.catalog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.exapps.anistream.R +import com.exapps.anistream.domain.model.CatalogSort +import com.exapps.anistream.domain.model.SortDirection +import com.exapps.anistream.presentation.components.EmptyStateCard +import com.exapps.anistream.presentation.components.SectionTitle +import com.exapps.anistream.presentation.components.TitleSummaryCard +import com.exapps.anistream.presentation.dashboard.label + +@Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +fun CatalogScreen( + viewModel: CatalogViewModel, + onBack: () -> Unit, + onOpenDetails: (String) -> Unit, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(state.category.label) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(id = R.string.content_back)) + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + SectionTitle( + title = state.category.label, + subtitle = stringResource(id = R.string.catalog_screen_subtitle), + ) + Spacer(modifier = Modifier.height(10.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + CatalogSort.entries.forEach { sort -> + FilterChip( + selected = state.filters.sort == sort, + onClick = { viewModel.onSortSelected(sort) }, + label = { Text(sort.label()) }, + ) + } + FilterChip( + selected = true, + onClick = viewModel::onDirectionToggle, + label = { + Text( + if (state.filters.direction == SortDirection.ASC) { + stringResource(id = R.string.catalog_sort_direction_asc) + } else { + stringResource(id = R.string.catalog_sort_direction_desc) + }, + ) + }, + ) + } + } + + if (state.isLoading) { + item { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator() + Text(stringResource(id = R.string.home_loading)) + } + } + } + } + + if (!state.errorMessage.isNullOrBlank()) { + item { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = state.errorMessage.orEmpty(), + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Button(onClick = viewModel::refresh) { + Text(stringResource(id = R.string.action_retry)) + } + } + } + } + } + + if (!state.isLoading && state.items.isEmpty()) { + item { + EmptyStateCard( + title = stringResource(id = R.string.search_empty_title), + message = stringResource(id = R.string.search_empty), + ) + } + } + + items(state.items, key = { it.slug }) { item -> + TitleSummaryCard(item = item, onClick = { onOpenDetails(item.slug) }) + } + + if (state.hasMore) { + item { + Button( + onClick = viewModel::loadMore, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isLoadingMore) { + CircularProgressIndicator(modifier = Modifier.width(20.dp), strokeWidth = 2.dp) + } else { + Text(stringResource(id = R.string.action_load_more)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/exapps/anistream/presentation/catalog/CatalogViewModel.kt b/app/src/main/java/com/exapps/anistream/presentation/catalog/CatalogViewModel.kt new file mode 100644 index 0000000..3b8588e --- /dev/null +++ b/app/src/main/java/com/exapps/anistream/presentation/catalog/CatalogViewModel.kt @@ -0,0 +1,125 @@ +package com.exapps.anistream.presentation.catalog + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.exapps.anistream.domain.model.AnimeCard +import com.exapps.anistream.domain.model.CatalogCategory +import com.exapps.anistream.domain.model.CatalogFilters +import com.exapps.anistream.domain.model.CatalogSort +import com.exapps.anistream.domain.model.SortDirection +import com.exapps.anistream.domain.usecase.GetCatalogUseCase +import com.exapps.anistream.presentation.navigation.CatalogRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CatalogViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val getCatalogUseCase: GetCatalogUseCase, +) : ViewModel() { + private val route = savedStateHandle.toRoute() + private val category = CatalogCategory.fromPath(route.categoryPath) + + private val _uiState = MutableStateFlow(CatalogUiState(category = category)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + runCatching { + getCatalogUseCase( + category = category, + filters = _uiState.value.filters, + ) + }.onSuccess { page -> + _uiState.update { + it.copy( + isLoading = false, + items = page.items, + page = page.currentPage, + hasMore = page.hasNextPage, + errorMessage = null, + ) + } + }.onFailure { error -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = error.message ?: "تعذر تحميل القائمة.", + ) + } + } + } + } + + fun onSortSelected(sort: CatalogSort) { + _uiState.update { it.copy(filters = it.filters.copy(sort = sort)) } + refresh() + } + + fun onDirectionToggle() { + _uiState.update { + it.copy( + filters = it.filters.copy( + direction = if (it.filters.direction == SortDirection.ASC) SortDirection.DESC else SortDirection.ASC, + ), + ) + } + refresh() + } + + fun loadMore() { + val state = _uiState.value + if (state.isLoadingMore || !state.hasMore) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoadingMore = true) } + runCatching { + getCatalogUseCase( + category = category, + page = state.page + 1, + filters = state.filters, + ) + }.onSuccess { page -> + _uiState.update { + it.copy( + isLoadingMore = false, + items = (it.items + page.items).distinctBy(AnimeCard::slug), + page = page.currentPage, + hasMore = page.hasNextPage, + errorMessage = null, + ) + } + }.onFailure { error -> + _uiState.update { + it.copy( + isLoadingMore = false, + errorMessage = error.message ?: "تعذر تحميل المزيد.", + ) + } + } + } + } +} + +data class CatalogUiState( + val category: CatalogCategory, + val isLoading: Boolean = true, + val isLoadingMore: Boolean = false, + val items: List = emptyList(), + val page: Int = 1, + val hasMore: Boolean = false, + val filters: CatalogFilters = CatalogFilters(), + val errorMessage: String? = null, +) diff --git a/app/src/main/java/com/exapps/anistream/presentation/cloudflare/Anime3rbSessionScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/cloudflare/Anime3rbSessionScreen.kt new file mode 100644 index 0000000..256c07c --- /dev/null +++ b/app/src/main/java/com/exapps/anistream/presentation/cloudflare/Anime3rbSessionScreen.kt @@ -0,0 +1,161 @@ +package com.exapps.anistream.presentation.cloudflare + +import android.graphics.Bitmap +import android.webkit.CookieManager +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.exapps.anistream.R +import com.exapps.anistream.core.webview.Anime3rbSessionBridge + +@Composable +fun Anime3rbSessionScreen( + modifier: Modifier = Modifier, + onSessionReady: (String?) -> Unit, +) { + var isLoading by remember { mutableStateOf(true) } + var status by remember { mutableStateOf(null) } + var canGoBack by remember { mutableStateOf(false) } + var webViewRef by remember { mutableStateOf(null) } + var sessionDelivered by remember { mutableStateOf(false) } + + BackHandler(enabled = canGoBack) { + webViewRef?.goBack() + } + + DisposableEffect(Unit) { + onDispose { + webViewRef?.stopLoading() + } + } + + Column(modifier = modifier.fillMaxSize()) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(id = R.string.cloudflare_title), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Text( + text = status ?: stringResource(id = R.string.cloudflare_message), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + + if (isLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + val cookieManager = CookieManager.getInstance().apply { + setAcceptCookie(true) + } + WebView(context).apply { + webViewRef = this + cookieManager.setAcceptThirdPartyCookies(this, true) + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + cacheMode = WebSettings.LOAD_DEFAULT + loadsImagesAutomatically = true + mediaPlaybackRequiresUserGesture = false + } + + fun tryCompleteSession() { + if (sessionDelivered) return + + val title = title.orEmpty() + val cookieHeader = cookieManager.getCookie(Anime3rbSessionBridge.BASE_URL).orEmpty() + val solved = cookieHeader.contains("cf_clearance") || + ( + cookieHeader.isNotBlank() && + title.isNotBlank() && + !title.contains("Just a moment", ignoreCase = true) && + !title.contains("Attention Required", ignoreCase = true) + ) + + if (!solved) { + status = context.getString(R.string.cloudflare_waiting) + postDelayed({ tryCompleteSession() }, SESSION_SYNC_DELAY_MS) + return + } + + sessionDelivered = true + cookieManager.flush() + status = context.getString(R.string.cloudflare_ready) + onSessionReady(settings.userAgentString) + } + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + sessionDelivered = false + isLoading = true + status = context.getString(R.string.cloudflare_loading) + canGoBack = view.canGoBack() + } + + override fun onPageFinished(view: WebView, url: String) { + isLoading = false + canGoBack = view.canGoBack() + view.postDelayed({ tryCompleteSession() }, SESSION_SYNC_DELAY_MS) + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + if (!request.isForMainFrame) return + isLoading = false + val message = error.description.toString() + status = message.ifBlank { context.getString(R.string.cloudflare_error) } + } + } + loadUrl(Anime3rbSessionBridge.BASE_URL) + } + }, + update = { webViewRef = it }, + ) + } + } +} + +private const val SESSION_SYNC_DELAY_MS = 800L diff --git a/app/src/main/java/com/exapps/anistream/presentation/components/AnimeCards.kt b/app/src/main/java/com/exapps/anistream/presentation/components/AnimeCards.kt index 7fe3f00..6e57629 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/components/AnimeCards.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/components/AnimeCards.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -235,19 +236,102 @@ fun EpisodePosterCard( } @Composable -fun SectionTitle(title: String, subtitle: String? = null) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - if (!subtitle.isNullOrBlank()) { +fun SectionTitle( + title: String, + subtitle: String? = null, + action: (@Composable () -> Unit)? = null, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + action?.invoke() + } +} + +@Composable +fun EmptyStateCard( + title: String, + message: String, + modifier: Modifier = Modifier, + action: (@Composable () -> Unit)? = null, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f)), + ) { + Column( + modifier = Modifier.padding(22.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + action?.invoke() + } + } +} + +@Composable +fun GradientHeroCard( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit = {}, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(32.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + ), + ), + ) + .padding(22.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.86f), + ) + } + content() } } } diff --git a/app/src/main/java/com/exapps/anistream/presentation/dashboard/DashboardScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/dashboard/DashboardScreen.kt index 61d46e1..f7e42f8 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/dashboard/DashboardScreen.kt @@ -39,11 +39,14 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.exapps.anistream.R import com.exapps.anistream.domain.model.AnimeCard +import com.exapps.anistream.domain.model.CatalogCategory import com.exapps.anistream.domain.model.CatalogSort import com.exapps.anistream.domain.model.EpisodeCard import com.exapps.anistream.domain.model.PlaybackHistory import com.exapps.anistream.domain.model.SortDirection +import com.exapps.anistream.presentation.components.EmptyStateCard import com.exapps.anistream.presentation.components.EpisodePosterCard +import com.exapps.anistream.presentation.components.GradientHeroCard import com.exapps.anistream.presentation.components.SectionTitle import com.exapps.anistream.presentation.components.TitlePosterCard import com.exapps.anistream.presentation.components.TitleSummaryCard @@ -54,6 +57,7 @@ fun DashboardScreen( viewModel: DashboardViewModel, onOpenDetails: (String) -> Unit, onOpenEpisode: (String, Int) -> Unit, + onOpenCatalog: (CatalogCategory) -> Unit, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -92,6 +96,30 @@ fun DashboardScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { + item { + SectionTitle( + title = stringResource(id = R.string.catalog_categories_title), + subtitle = stringResource(id = R.string.catalog_categories_subtitle), + ) + Spacer(modifier = Modifier.height(10.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + state.catalogCategories.forEach { category -> + FilterChip( + selected = false, + onClick = { onOpenCatalog(category) }, + label = { Text(category.label) }, + ) + } + } + } + + item { + GradientHeroCard( + title = stringResource(id = R.string.home_hero_title), + subtitle = stringResource(id = R.string.home_hero_subtitle), + ) + } + item { OutlinedTextField( value = state.searchQuery, @@ -180,7 +208,10 @@ fun DashboardScreen( if (!state.isSearching && state.searchResults.isEmpty()) { item { - Text(stringResource(id = R.string.search_empty)) + EmptyStateCard( + title = stringResource(id = R.string.search_empty_title), + message = stringResource(id = R.string.search_empty), + ) } } @@ -203,6 +234,14 @@ fun DashboardScreen( } } } else { + item { + QuickStatsCard( + latestEpisodesCount = state.latestEpisodes.size, + latestTitlesCount = state.latestTitles.size, + catalogCount = state.catalog.size, + ) + } + if (state.continueWatching.isNotEmpty()) { item { SectionTitle( @@ -313,6 +352,45 @@ private fun androidx.compose.foundation.lazy.LazyListScope.titleSection( } } +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun QuickStatsCard( + latestEpisodesCount: Int, + latestTitlesCount: Int, + catalogCount: Int, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.62f)), + shape = RoundedCornerShape(26.dp), + ) { + FlowRow( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + StatPill(label = stringResource(id = R.string.home_stat_latest_episodes), value = latestEpisodesCount.toString()) + StatPill(label = stringResource(id = R.string.home_stat_latest_titles), value = latestTitlesCount.toString()) + StatPill(label = stringResource(id = R.string.home_stat_catalog), value = catalogCount.toString()) + } + } +} + +@Composable +private fun StatPill(label: String, value: String) { + Card( + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(text = value, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary) + Text(text = label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + @Composable private fun HistoryCardCompact(item: PlaybackHistory, onClick: () -> Unit) { Card( @@ -349,7 +427,7 @@ private fun HistoryCardCompact(item: PlaybackHistory, onClick: () -> Unit) { } @Composable -private fun CatalogSort.label(): String { +fun CatalogSort.label(): String { return when (this) { CatalogSort.ADDITION_DATE -> stringResource(id = R.string.catalog_sort_addition_date) CatalogSort.NAME -> stringResource(id = R.string.catalog_sort_name) diff --git a/app/src/main/java/com/exapps/anistream/presentation/dashboard/DashboardViewModel.kt b/app/src/main/java/com/exapps/anistream/presentation/dashboard/DashboardViewModel.kt index ad35bec..5d91434 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/dashboard/DashboardViewModel.kt @@ -3,6 +3,7 @@ package com.exapps.anistream.presentation.dashboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.exapps.anistream.domain.model.AnimeCard +import com.exapps.anistream.domain.model.CatalogCategory import com.exapps.anistream.domain.model.CatalogFilters import com.exapps.anistream.domain.model.CatalogSort import com.exapps.anistream.domain.model.EpisodeCard @@ -105,6 +106,7 @@ class DashboardViewModel @Inject constructor( catalog = (it.catalog + page.items).distinctBy(AnimeCard::slug), catalogPage = page.currentPage, hasMoreCatalog = page.hasNextPage, + errorMessage = null, ) } }.onFailure { error -> @@ -150,6 +152,7 @@ class DashboardViewModel @Inject constructor( isSearching = false, searchPage = result.currentPage, hasMoreSearchResults = result.hasNextPage, + errorMessage = null, ) } }.onFailure { error -> @@ -182,6 +185,7 @@ class DashboardViewModel @Inject constructor( searchResults = (it.searchResults + result.items).distinctBy(AnimeCard::slug), searchPage = result.currentPage, hasMoreSearchResults = result.hasNextPage, + errorMessage = null, ) } }.onFailure { error -> @@ -228,6 +232,7 @@ data class DashboardUiState( val searchResults: List = emptyList(), val searchPage: Int = 1, val hasMoreSearchResults: Boolean = false, + val catalogCategories: List = CatalogCategory.entries, val catalogFilters: CatalogFilters = CatalogFilters(), val errorMessage: String? = null, ) diff --git a/app/src/main/java/com/exapps/anistream/presentation/details/DetailsScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/details/DetailsScreen.kt index fceee95..47ad9a0 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/details/DetailsScreen.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/details/DetailsScreen.kt @@ -22,6 +22,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material.icons.rounded.BookmarkBorder +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Button import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Card @@ -51,7 +53,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.exapps.anistream.R import com.exapps.anistream.domain.model.AnimeDetails +import com.exapps.anistream.domain.model.TrailerItem import com.exapps.anistream.domain.model.WatchStatus +import com.exapps.anistream.presentation.components.EmptyStateCard import com.exapps.anistream.presentation.components.EpisodeRow import com.exapps.anistream.presentation.components.SectionTitle import com.exapps.anistream.presentation.components.TitlePosterCard @@ -64,6 +68,7 @@ fun DetailsScreen( onBack: () -> Unit, onPlayEpisode: (String, Int) -> Unit, onOpenDetails: (String) -> Unit, + onPlayTrailer: (TrailerItem) -> Unit, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val uriHandler = LocalUriHandler.current @@ -128,6 +133,19 @@ fun DetailsScreen( HeaderCard(details = details) } + details.episodes.firstOrNull()?.let { firstEpisode -> + item { + Button( + onClick = { onPlayEpisode(firstEpisode.titleSlug, firstEpisode.episodeNumber) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(imageVector = Icons.Rounded.PlayArrow, contentDescription = null) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(id = R.string.details_start_watching)) + } + } + } + item { WatchStateCard( details = details, @@ -159,7 +177,7 @@ fun DetailsScreen( FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { details.trailers.forEach { trailer -> AssistChip( - onClick = { uriHandler.openUri(trailer.embedUrl) }, + onClick = { onPlayTrailer(trailer) }, label = { Text(trailer.title) }, ) } @@ -174,8 +192,17 @@ fun DetailsScreen( ) } - items(details.episodes, key = { it.episodeUrl }) { episode -> - EpisodeRow(item = episode, onClick = { onPlayEpisode(it.titleSlug, it.episodeNumber) }) + if (details.episodes.isEmpty()) { + item { + EmptyStateCard( + title = stringResource(id = R.string.details_episodes_empty_title), + message = stringResource(id = R.string.details_episodes_empty), + ) + } + } else { + items(details.episodes, key = { it.episodeUrl }) { episode -> + EpisodeRow(item = episode, onClick = { onPlayEpisode(it.titleSlug, it.episodeNumber) }) + } } if (details.relatedTitles.isNotEmpty()) { @@ -338,6 +365,8 @@ private fun MetadataGrid(details: AnimeDetails) { metadataCard(label = stringResource(id = R.string.label_studio), value = details.studio) metadataCard(label = stringResource(id = R.string.label_author), value = details.author) metadataCard(label = stringResource(id = R.string.label_score), value = details.score) + metadataCard(label = stringResource(id = R.string.label_rating_count), value = details.ratingCount?.toString()) + metadataCard(label = stringResource(id = R.string.label_published_at), value = details.publishedAt) metadataCard(label = stringResource(id = R.string.label_episodes), value = details.episodeCount?.toString()) metadataCard(label = stringResource(id = R.string.label_age), value = details.ageRating) } diff --git a/app/src/main/java/com/exapps/anistream/presentation/library/LibraryScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/library/LibraryScreen.kt index c8c38f5..0b9de0e 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/library/LibraryScreen.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/library/LibraryScreen.kt @@ -1,16 +1,21 @@ package com.exapps.anistream.presentation.library import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -28,6 +33,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.exapps.anistream.R import com.exapps.anistream.domain.model.WatchStatus +import com.exapps.anistream.presentation.components.EmptyStateCard +import com.exapps.anistream.presentation.components.GradientHeroCard import com.exapps.anistream.presentation.components.HistoryRowCard import com.exapps.anistream.presentation.components.SectionTitle import com.exapps.anistream.presentation.components.WatchlistRowCard @@ -60,6 +67,19 @@ fun LibraryScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { + item { + GradientHeroCard( + title = stringResource(id = R.string.library_hero_title), + subtitle = stringResource(id = R.string.library_hero_subtitle), + ) + } + item { + LibraryStatsCard( + watchlistCount = state.watchlist.size, + historyCount = state.history.size, + completedCount = state.watchlist.count { it.watchStatus == WatchStatus.COMPLETED }, + ) + } item { TabRow(selectedTabIndex = selectedTab) { Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text(stringResource(id = R.string.library_watchlist_tab)) }) @@ -87,7 +107,12 @@ fun LibraryScreen( } if (filteredWatchlist.isEmpty()) { - item { Text(stringResource(id = R.string.library_watchlist_empty)) } + item { + EmptyStateCard( + title = stringResource(id = R.string.library_watchlist_empty_title), + message = stringResource(id = R.string.library_watchlist_empty), + ) + } } else { items(filteredWatchlist, key = { it.slug }) { item -> WatchlistRowCard(item = item, onClick = { onOpenDetails(item.slug) }) @@ -95,7 +120,12 @@ fun LibraryScreen( } } else { if (state.history.isEmpty()) { - item { Text(stringResource(id = R.string.library_history_empty)) } + item { + EmptyStateCard( + title = stringResource(id = R.string.library_history_empty_title), + message = stringResource(id = R.string.library_history_empty), + ) + } } else { items(state.history, key = { "${it.titleSlug}-${it.episodeNumber}" }) { item -> HistoryRowCard(item = item, onClick = { onOpenEpisode(item.titleSlug, item.episodeNumber) }) @@ -105,3 +135,34 @@ fun LibraryScreen( } } } + +@Composable +private fun LibraryStatsCard( + watchlistCount: Int, + historyCount: Int, + completedCount: Int, +) { + Card( + shape = RoundedCornerShape(26.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LibraryStat(label = stringResource(id = R.string.library_stat_watchlist), value = watchlistCount.toString()) + LibraryStat(label = stringResource(id = R.string.library_stat_history), value = historyCount.toString()) + LibraryStat(label = stringResource(id = R.string.library_stat_completed), value = completedCount.toString()) + } + } +} + +@Composable +private fun LibraryStat(label: String, value: String) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(text = value, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary) + Text(text = label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} diff --git a/app/src/main/java/com/exapps/anistream/presentation/navigation/AniStreamNavGraph.kt b/app/src/main/java/com/exapps/anistream/presentation/navigation/AniStreamNavGraph.kt index 415a932..180512a 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/navigation/AniStreamNavGraph.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/navigation/AniStreamNavGraph.kt @@ -20,17 +20,23 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import androidx.media3.common.util.UnstableApi import com.exapps.anistream.R +import com.exapps.anistream.presentation.catalog.CatalogScreen +import com.exapps.anistream.presentation.catalog.CatalogViewModel import com.exapps.anistream.presentation.dashboard.DashboardScreen import com.exapps.anistream.presentation.dashboard.DashboardViewModel import com.exapps.anistream.presentation.details.DetailsScreen import com.exapps.anistream.presentation.details.DetailsViewModel +import com.exapps.anistream.presentation.cloudflare.Anime3rbSessionScreen import com.exapps.anistream.presentation.library.LibraryScreen import com.exapps.anistream.presentation.library.LibraryViewModel import com.exapps.anistream.presentation.player.PlayerScreen import com.exapps.anistream.presentation.player.PlayerViewModel import com.exapps.anistream.presentation.settings.SettingsScreen import com.exapps.anistream.presentation.settings.SettingsViewModel +import com.exapps.anistream.presentation.trailer.TrailerScreen private enum class RootNavTarget { HOME, @@ -45,7 +51,20 @@ private data class RootDestination( ) @Composable +@UnstableApi fun AniStreamNavGraph() { + AniStreamNavGraph( + needsAnime3rbSession = false, + onAnime3rbSessionReady = {}, + ) +} + +@Composable +@UnstableApi +fun AniStreamNavGraph( + needsAnime3rbSession: Boolean, + onAnime3rbSessionReady: (String?) -> Unit, +) { val navController = rememberNavController() val backStackEntry by navController.currentBackStackEntryAsState() val destination = backStackEntry?.destination @@ -55,13 +74,14 @@ fun AniStreamNavGraph() { RootDestination(RootNavTarget.SETTINGS, R.string.nav_settings) { Icon(Icons.Rounded.Settings, contentDescription = null) }, ) - val showBottomBar = rootDestinations.any { root -> - when (root.target) { - RootNavTarget.HOME -> destination?.hasRoute() == true - RootNavTarget.LIBRARY -> destination?.hasRoute() == true - RootNavTarget.SETTINGS -> destination?.hasRoute() == true + val showBottomBar = !needsAnime3rbSession && + rootDestinations.any { root -> + when (root.target) { + RootNavTarget.HOME -> destination?.hasRoute() == true + RootNavTarget.LIBRARY -> destination?.hasRoute() == true + RootNavTarget.SETTINGS -> destination?.hasRoute() == true + } } - } Scaffold( bottomBar = { @@ -104,67 +124,96 @@ fun AniStreamNavGraph() { } }, ) { innerPadding -> - NavHost( - navController = navController, - startDestination = DashboardRoute, - modifier = Modifier.padding(innerPadding), - ) { - composable { - val viewModel = hiltViewModel() - DashboardScreen( - viewModel = viewModel, - onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) }, - onOpenEpisode = { titleSlug, episodeNumber -> - navController.navigate( - PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), - ) - }, - ) - } + if (needsAnime3rbSession) { + Anime3rbSessionScreen( + modifier = Modifier.padding(innerPadding), + onSessionReady = onAnime3rbSessionReady, + ) + } else { + NavHost( + navController = navController, + startDestination = DashboardRoute, + modifier = Modifier.padding(innerPadding), + ) { + composable { + val viewModel = hiltViewModel() + DashboardScreen( + viewModel = viewModel, + onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) }, + onOpenEpisode = { titleSlug, episodeNumber -> + navController.navigate( + PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), + ) + }, + onOpenCatalog = { category -> navController.navigate(CatalogRoute(categoryPath = category.path)) }, + ) + } - composable { - val viewModel = hiltViewModel() - LibraryScreen( - viewModel = viewModel, - onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) }, - onOpenEpisode = { titleSlug, episodeNumber -> - navController.navigate( - PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), - ) - }, - ) - } + composable { + val viewModel = hiltViewModel() + CatalogScreen( + viewModel = viewModel, + onBack = navController::popBackStack, + onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) }, + ) + } - composable { - val viewModel = hiltViewModel() - SettingsScreen(viewModel = viewModel) - } + composable { + val viewModel = hiltViewModel() + LibraryScreen( + viewModel = viewModel, + onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) }, + onOpenEpisode = { titleSlug, episodeNumber -> + navController.navigate( + PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), + ) + }, + ) + } - composable { - val viewModel = hiltViewModel() - DetailsScreen( - viewModel = viewModel, - onBack = navController::popBackStack, - onPlayEpisode = { titleSlug, episodeNumber -> - navController.navigate( - PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), - ) - }, - onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) }, - ) - } + composable { + val viewModel = hiltViewModel() + SettingsScreen(viewModel = viewModel) + } - composable { - val viewModel = hiltViewModel() - PlayerScreen( - viewModel = viewModel, - onBack = navController::popBackStack, - onOpenEpisode = { titleSlug, episodeNumber -> - navController.navigate( - PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), - ) - }, - ) + composable { + val viewModel = hiltViewModel() + DetailsScreen( + viewModel = viewModel, + onBack = navController::popBackStack, + onPlayEpisode = { titleSlug, episodeNumber -> + navController.navigate( + PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), + ) + }, + onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) }, + onPlayTrailer = { trailer -> + navController.navigate(TrailerRoute(title = trailer.title, embedUrl = trailer.embedUrl)) + }, + ) + } + + composable { + val viewModel = hiltViewModel() + PlayerScreen( + viewModel = viewModel, + onBack = navController::popBackStack, + onOpenEpisode = { titleSlug, episodeNumber -> + navController.navigate( + PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), + ) + }, + ) + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + TrailerScreen( + title = route.title, + embedUrl = route.embedUrl, + onBack = navController::popBackStack, + ) + } } } } diff --git a/app/src/main/java/com/exapps/anistream/presentation/navigation/Routes.kt b/app/src/main/java/com/exapps/anistream/presentation/navigation/Routes.kt index 7c58d58..1ed1b8a 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/navigation/Routes.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/navigation/Routes.kt @@ -11,6 +11,9 @@ object LibraryRoute @Serializable object SettingsRoute +@Serializable +data class CatalogRoute(val categoryPath: String) + @Serializable data class DetailsRoute(val slug: String) @@ -19,3 +22,9 @@ data class PlayerRoute( val titleSlug: String, val episodeNumber: Int, ) + +@Serializable +data class TrailerRoute( + val title: String, + val embedUrl: String, +) diff --git a/app/src/main/java/com/exapps/anistream/presentation/player/PlayerScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/player/PlayerScreen.kt index 3fc73cc..3a25914 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/player/PlayerScreen.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/player/PlayerScreen.kt @@ -1,6 +1,10 @@ package com.exapps.anistream.presentation.player import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -48,6 +52,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout @@ -58,6 +63,7 @@ import com.exapps.anistream.data.download.AnimeDownloadService import com.exapps.anistream.domain.model.StreamType @Composable +@UnstableApi @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) fun PlayerScreen( viewModel: PlayerViewModel, @@ -106,16 +112,17 @@ fun PlayerScreen( else -> { val stream = state.stream!! + val playerHeight = if (state.cinemaMode) 320.dp else 240.dp val playableSources = stream.availableSources.filter { it.type == StreamType.HLS || it.type == StreamType.MP4 || it.type == StreamType.MKV } + val embeddedPlayerUrl = stream.availableSources + .firstOrNull { it.type == StreamType.PLAYER_PAGE && it.url.isNotBlank() } + ?.url + ?: stream.iframeUrl + ?: stream.playbackUrl?.takeUnless { it.contains(".m3u8") || it.contains(".mp4") || it.contains(".mkv") } var selectedUrl by rememberSaveable(stream.titleSlug, stream.episodeNumber, stream.playbackUrl, stream.selectedServerId) { - mutableStateOf( - playableSources.firstOrNull()?.url - ?: stream.playbackUrl?.takeIf { - it.contains(".m3u8") || it.contains(".mp4") || it.contains(".mkv") - }, - ) + mutableStateOf(null) } val context = LocalContext.current @@ -180,21 +187,55 @@ fun PlayerScreen( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), ) { Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (selectedUrl != null) { - AndroidView( - modifier = Modifier - .fillMaxWidth() - .height(240.dp), - factory = { ctx -> - PlayerView(ctx).apply { - layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) - useController = true - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT - player = exoPlayer - } - }, - update = { view -> view.player = exoPlayer }, - ) + when { + selectedUrl != null -> { + AndroidView( + modifier = Modifier + .fillMaxWidth() + .height(playerHeight), + factory = { ctx -> + PlayerView(ctx).apply { + layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) + useController = true + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + player = exoPlayer + } + }, + update = { view -> view.player = exoPlayer }, + ) + } + + !embeddedPlayerUrl.isNullOrBlank() -> { + AndroidView( + modifier = Modifier + .fillMaxWidth() + .height(playerHeight), + factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.cacheMode = WebSettings.LOAD_DEFAULT + webChromeClient = WebChromeClient() + webViewClient = WebViewClient() + loadUrl(embeddedPlayerUrl) + } + }, + update = { webView -> + if (webView.url != embeddedPlayerUrl) { + webView.loadUrl(embeddedPlayerUrl) + } + }, + ) + } + + else -> { + Text( + text = stringResource(id = R.string.player_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Text( @@ -211,13 +252,25 @@ fun PlayerScreen( } if (stream.views != null) { - Text( - text = "${stream.views} ${stringResource(id = R.string.label_views)}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, + AssistChip( + onClick = {}, + label = { Text("${stream.views} ${stringResource(id = R.string.label_views)}") }, ) } + AssistChip( + onClick = {}, + label = { + Text( + if (state.cinemaMode) { + stringResource(id = R.string.player_cinema_mode_on) + } else { + stringResource(id = R.string.player_cinema_mode_off) + }, + ) + }, + ) + if (stream.batchDownloadUrl != null) { AssistChip( onClick = { uriHandler.openUri(stream.batchDownloadUrl) }, @@ -268,12 +321,18 @@ fun PlayerScreen( } } - if (playableSources.isNotEmpty()) { + if (playableSources.isNotEmpty() || !embeddedPlayerUrl.isNullOrBlank()) { item { Text(text = stringResource(id = R.string.player_sources_title), style = MaterialTheme.typography.titleMedium) } item { FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (!embeddedPlayerUrl.isNullOrBlank()) { + AssistChip( + onClick = { selectedUrl = null }, + label = { Text(stringResource(id = R.string.player_source_embedded)) }, + ) + } playableSources.forEach { source -> AssistChip( onClick = { selectedUrl = source.url }, diff --git a/app/src/main/java/com/exapps/anistream/presentation/player/PlayerViewModel.kt b/app/src/main/java/com/exapps/anistream/presentation/player/PlayerViewModel.kt index 597f49f..8fe20bf 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/player/PlayerViewModel.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/player/PlayerViewModel.kt @@ -113,7 +113,13 @@ class PlayerViewModel @Inject constructor( private fun observePreferences() { viewModelScope.launch { observePreferencesUseCase().collect { preferences -> - _uiState.update { it.copy(autoPlayNext = preferences.autoPlayNext) } + _uiState.update { + it.copy( + autoPlayNext = preferences.autoPlayNext, + cinemaMode = preferences.cinemaMode, + skipIntroSeconds = preferences.skipIntroSeconds, + ) + } } } } @@ -124,6 +130,7 @@ data class PlayerUiState( val isRecovering: Boolean = false, val stream: EpisodeStream? = null, val autoPlayNext: Boolean = true, + val cinemaMode: Boolean = false, val skipIntroSeconds: Int = 85, val errorMessage: String? = null, ) diff --git a/app/src/main/java/com/exapps/anistream/presentation/root/RootViewModel.kt b/app/src/main/java/com/exapps/anistream/presentation/root/RootViewModel.kt index 535be0a..1e6d692 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/root/RootViewModel.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/root/RootViewModel.kt @@ -2,11 +2,14 @@ package com.exapps.anistream.presentation.root import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.exapps.anistream.core.webview.Anime3rbSessionBridge +import com.exapps.anistream.core.webview.VisibleCloudflareChallengeSolver import com.exapps.anistream.domain.usecase.ObservePreferencesUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -14,6 +17,8 @@ import javax.inject.Inject @HiltViewModel class RootViewModel @Inject constructor( private val observePreferencesUseCase: ObservePreferencesUseCase, + private val sessionBridge: Anime3rbSessionBridge, + private val challengeSolver: VisibleCloudflareChallengeSolver, ) : ViewModel() { private val _uiState = MutableStateFlow(RootUiState()) @@ -21,13 +26,30 @@ class RootViewModel @Inject constructor( init { viewModelScope.launch { - observePreferencesUseCase().collect { preferences -> - _uiState.update { it.copy(dynamicColors = preferences.dynamicColors) } + combine( + observePreferencesUseCase(), + challengeSolver.challengeRequired, + ) { preferences, challengeRequired -> + RootUiState( + dynamicColors = preferences.dynamicColors, + needsAnime3rbSession = challengeRequired || !sessionBridge.hasSession(), + ) + }.collect { state -> + _uiState.value = state } } } + + fun syncAnime3rbSession(userAgent: String?) { + val hasCookies = sessionBridge.syncFromWebView(userAgent) + if (hasCookies) { + challengeSolver.markSolved() + } + _uiState.update { it.copy(needsAnime3rbSession = !hasCookies) } + } } data class RootUiState( val dynamicColors: Boolean = true, + val needsAnime3rbSession: Boolean = true, ) diff --git a/app/src/main/java/com/exapps/anistream/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/settings/SettingsScreen.kt index a02ef47..232e181 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/settings/SettingsScreen.kt @@ -7,12 +7,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -24,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.exapps.anistream.BuildConfig import com.exapps.anistream.R +import com.exapps.anistream.presentation.components.GradientHeroCard @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -47,9 +51,16 @@ fun SettingsScreen( verticalArrangement = Arrangement.spacedBy(16.dp), ) { item { - Text( - text = stringResource(id = R.string.settings_subtitle), - style = MaterialTheme.typography.bodyLarge, + GradientHeroCard( + title = stringResource(id = R.string.settings_hero_title), + subtitle = stringResource(id = R.string.settings_subtitle), + ) + } + + item { + SkipIntroCard( + seconds = state.preferences.skipIntroSeconds, + onSecondsChange = viewModel::setSkipIntroSeconds, ) } @@ -87,7 +98,7 @@ fun SettingsScreen( } item { - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.55f))) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -112,6 +123,7 @@ fun SettingsScreen( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + Text(stringResource(id = R.string.settings_danger_zone), style = MaterialTheme.typography.titleMedium) Button(onClick = viewModel::clearHistory, modifier = Modifier.fillMaxWidth()) { Text(stringResource(id = R.string.settings_clear_history)) } @@ -132,7 +144,10 @@ private fun SettingToggleCard( checked: Boolean, onCheckedChange: (Boolean) -> Unit, ) { - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Card( + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { Row( modifier = Modifier .fillMaxWidth() @@ -147,3 +162,40 @@ private fun SettingToggleCard( } } } + +@Composable +private fun SkipIntroCard( + seconds: Int, + onSecondsChange: (Int) -> Unit, +) { + Card( + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = stringResource(id = R.string.settings_skip_intro), style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(id = R.string.settings_skip_intro_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AssistChip(onClick = {}, label = { Text(stringResource(id = R.string.settings_skip_intro_value, seconds)) }) + } + Slider( + value = seconds.toFloat(), + onValueChange = { onSecondsChange(it.toInt()) }, + valueRange = 0f..180f, + steps = 11, + ) + } + } +} diff --git a/app/src/main/java/com/exapps/anistream/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/exapps/anistream/presentation/settings/SettingsViewModel.kt index 9d76ead..c939638 100644 --- a/app/src/main/java/com/exapps/anistream/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/exapps/anistream/presentation/settings/SettingsViewModel.kt @@ -10,6 +10,7 @@ import com.exapps.anistream.domain.usecase.SetAutoPlayNextUseCase import com.exapps.anistream.domain.usecase.SetCinemaModeUseCase import com.exapps.anistream.domain.usecase.SetDynamicColorsUseCase import com.exapps.anistream.domain.usecase.SetPreferSummaryUseCase +import com.exapps.anistream.domain.usecase.SetSkipIntroSecondsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -25,6 +26,7 @@ class SettingsViewModel @Inject constructor( private val setPreferSummaryUseCase: SetPreferSummaryUseCase, private val setCinemaModeUseCase: SetCinemaModeUseCase, private val setDynamicColorsUseCase: SetDynamicColorsUseCase, + private val setSkipIntroSecondsUseCase: SetSkipIntroSecondsUseCase, private val clearHistoryUseCase: ClearHistoryUseCase, private val clearWatchlistUseCase: ClearWatchlistUseCase, ) : ViewModel() { @@ -56,6 +58,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { setDynamicColorsUseCase(enabled) } } + fun setSkipIntroSeconds(seconds: Int) { + viewModelScope.launch { setSkipIntroSecondsUseCase(seconds) } + } + fun clearHistory() { viewModelScope.launch { clearHistoryUseCase() } } diff --git a/app/src/main/java/com/exapps/anistream/presentation/trailer/TrailerScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/trailer/TrailerScreen.kt new file mode 100644 index 0000000..72cd50a --- /dev/null +++ b/app/src/main/java/com/exapps/anistream/presentation/trailer/TrailerScreen.kt @@ -0,0 +1,64 @@ +package com.exapps.anistream.presentation.trailer + +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import com.exapps.anistream.R + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun TrailerScreen( + title: String, + embedUrl: String, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title.ifBlank { stringResource(id = R.string.details_trailers) }) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(id = R.string.content_back)) + } + }, + ) + }, + ) { padding -> + AndroidView( + modifier = Modifier + .fillMaxSize() + .padding(padding), + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.cacheMode = WebSettings.LOAD_DEFAULT + webChromeClient = WebChromeClient() + webViewClient = WebViewClient() + loadUrl(embedUrl) + } + }, + update = { webView -> + if (webView.url != embedUrl) { + webView.loadUrl(embedUrl) + } + }, + ) + } +} diff --git a/app/src/main/java/com/exapps/anistream/ui/theme/Color.kt b/app/src/main/java/com/exapps/anistream/ui/theme/Color.kt index 055b649..1a7a8fa 100644 --- a/app/src/main/java/com/exapps/anistream/ui/theme/Color.kt +++ b/app/src/main/java/com/exapps/anistream/ui/theme/Color.kt @@ -5,6 +5,8 @@ import androidx.compose.ui.graphics.Color val AniBlue = Color(0xFF2563EB) val AniBlueDark = Color(0xFF0F172A) val AniCyan = Color(0xFF38BDF8) -val AniSurface = Color(0xFFF8FAFC) -val AniSurfaceDark = Color(0xFF111827) -val AniCardDark = Color(0xFF1F2937) +val AniViolet = Color(0xFF7C3AED) +val AniRose = Color(0xFFFF4D8D) +val AniSurface = Color(0xFFF5F7FB) +val AniSurfaceDark = Color(0xFF080B13) +val AniCardDark = Color(0xFF141A2A) diff --git a/app/src/main/java/com/exapps/anistream/ui/theme/Theme.kt b/app/src/main/java/com/exapps/anistream/ui/theme/Theme.kt index 987551f..86abb0f 100644 --- a/app/src/main/java/com/exapps/anistream/ui/theme/Theme.kt +++ b/app/src/main/java/com/exapps/anistream/ui/theme/Theme.kt @@ -10,22 +10,27 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat private val LightColors = lightColorScheme( - primary = AniBlue, - secondary = AniCyan, + primary = AniViolet, + secondary = AniBlue, + tertiary = AniRose, surface = AniSurface, + surfaceVariant = Color(0xFFE6EAF2), background = AniSurface, ) private val DarkColors = darkColorScheme( primary = AniCyan, - secondary = AniBlue, + secondary = AniViolet, + tertiary = AniRose, surface = AniCardDark, + surfaceVariant = Color(0xFF20283A), background = AniSurfaceDark, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2a99e1..6ce08e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,13 +12,26 @@ مسح تم + تجهيز جلسة Anime3rb + تظهر صفحة الموقع أولاً لتجاوز حماية Cloudflare وحفظ الكوكيز على الجهاز، ثم يبدأ الجلب المباشر. + جارٍ تحميل Anime3rb… + بانتظار انتهاء حماية Cloudflare… + تم تحميل الصفحة. جاري حفظ الجلسة… + تعذر تحميل صفحة Anime3rb. + ابحث في Anime3rb يتم جلب البيانات مباشرة من Anime3rb.com باستخدام محددات تم التحقق منها محلياً. + كل عالم الأنمي في مكان واحد + واجهة جديدة تركّز على الاستكمال السريع، أحدث الحلقات، والبحث المباشر من Anime3rb. جارٍ تحميل الصفحة الرئيسية والفهرس… تعذر تحميل البيانات حالياً. نتائج البحث جارٍ البحث… + لا توجد نتائج لا توجد نتائج مطابقة حالياً. + حلقات جديدة + أعمال مضافة + من الفهرس أكمل المشاهدة استكمال آخر الحلقات التي بدأت مشاهدتها. الأنميات المثبتة @@ -29,6 +42,9 @@ قائمة الأعمال الجديدة مباشرة من Anime3rb. فهرس الأنمي يدعم الترتيب والتحميل التدريجي من الموقع. + قوائم Anime3rb + افتح قوائم الأنمي، الأفلام، OVA، ONA، الحلقات الخاصة والمزيد. + قائمة مباشرة من Anime3rb حسب النوع المحدد. ترتيب الفهرس تاريخ الإضافة الاسم @@ -39,14 +55,22 @@ تحميل المزيد من النتائج المكتبة + مكتبتك الشخصية + تابع قائمتك، سجل المشاهدة، والأعمال المكتملة من مساحة واحدة. قائمتي السجل + القائمة فارغة لم تضف أي عمل إلى قائمتك بعد. + لا يوجد سجل مشاهدة لا توجد حلقات في السجل بعد. تصفية الحالة + في القائمة + في السجل + مكتمل آخر موضع: %1$s الإعدادات + تحكم بتجربة AniStream تخصيص تجربة المشاهدة والتخزين المحلي. التشغيل التلقائي للحلقة التالية ينتقل المشغل إلى الحلقة التالية تلقائياً عند انتهاء الحلقة الحالية. @@ -58,6 +82,10 @@ استخدام ألوان Material You عندما تكون متاحة على الجهاز. مسح السجل مسح القائمة + مدة تخطي المقدمة + تستخدم في زر التخطي داخل المشغل ويمكن ضبطها حسب أغلب مقدمات الأنمي. + %1$d ثانية + إدارة البيانات المحلية هذا التطبيق يعتمد على الكشط المحلي فقط ولا يستخدم أي خادم وسيط أو حسابات خارجية. الإصدار %1$s (%2$d) @@ -69,8 +97,11 @@ وضع القصة الكاملة يتم حفظ هذا التفضيل محلياً عبر DataStore. أسماء أخرى + ابدأ المشاهدة الآن الحلقات تم استخراجها من بنية .video-list و .video-data في Anime3rb. + لا توجد حلقات متاحة + لم يتم العثور على قائمة حلقات لهذا العمل حالياً. أنميات مشابهة مقترحة العروض التشويقية المصادر الخارجية @@ -84,6 +115,8 @@ التقييم الحلقات التصنيف العمري + تاريخ العرض + عدد التقييمات مشاهدة الحلقة %1$d @@ -107,6 +140,8 @@ مشغل مضمّن تخطي المقدمة %1$dث جارٍ إعادة استخراج الرابط وتجربة مصدر بديل… + الوضع السينمائي مفعّل + الوضع السينمائي غير مفعّل تنزيلات AniStream جارٍ تنزيل الحلقة وتحويلها للاستخدام دون اتصال… diff --git a/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt b/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt new file mode 100644 index 0000000..6c6a7e6 --- /dev/null +++ b/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt @@ -0,0 +1,186 @@ +package com.exapps.anistream.data.scraper + +import org.jsoup.Jsoup +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class Anime3rbHtmlParserTest { + private val parser = Anime3rbHtmlParser() + + @Test + fun parseHomeReadsCurrentAnime3rbCards() { + val feed = parser.parseHome(loadSample(HOME_HTML)) + + assertTrue(feed.featuredEpisodes.isNotEmpty()) + assertTrue(feed.latestEpisodes.isNotEmpty()) + assertTrue(feed.latestTitles.isNotEmpty()) + assertEquals("one-piece", feed.featuredEpisodes.first().titleSlug) + assertTrue(feed.latestEpisodes.first().episodeNumber > 0) + assertTrue(feed.latestTitles.first().slug.isNotBlank()) + } + + @Test + fun parseSearchReadsCurrentTitleCards() { + val page = parser.parseSearch(loadSample(TITLE_LIST_HTML), page = 1) + + assertTrue(page.items.size >= 1) + assertEquals("jujutsu-kaisen", page.items.first().slug) + assertEquals("Jujutsu Kaisen", page.items.first().title) + assertEquals("8.52", page.items.first().rating) + } + + @Test + fun parseCatalogDetectsLivewirePagination() { + val page = parser.parseCatalog(loadSample(TITLE_LIST_HTML), page = 1) + + assertEquals(1, page.items.size) + assertTrue(page.hasNextPage) + assertTrue(page.items.first().posterUrl.startsWith("https://images.anime3rb.com/")) + } + + @Test + fun parseDetailsReadsMetadataAndEpisodes() { + val details = parser.parseDetails(loadSample(DETAILS_HTML), "jujutsu-kaisen") + + assertEquals("Jujutsu Kaisen", details.title) + assertEquals("منتهي", details.status) + assertEquals("9.18", details.score) + assertEquals(989, details.ratingCount) + assertEquals("2020-10-03", details.publishedAt) + assertEquals(24, details.episodeCount) + assertTrue(details.synopsis.contains("يوجي إيتادوري")) + assertTrue(details.genres.contains("أكشن")) + assertEquals(2, details.episodes.size) + assertEquals(1, details.episodes.first().episodeNumber) + } + + @Test + fun parseEpisodeReadsLivewireAndDownloadData() { + val stream = parser.parseEpisodePage(loadSample(EPISODE_HTML), "jujutsu-kaisen", 1) + + assertEquals("Jujutsu Kaisen", stream.animeTitle) + assertEquals("ريومين سوكونا", stream.episodeTitle) + assertNotNull(stream.csrfToken) + assertNotNull(stream.livewireSnapshot) + assertEquals("66137468a5d62", stream.selectedServerId) + assertTrue(stream.playbackUrl.orEmpty().startsWith("https://video.vid3rb.com/player/")) + assertTrue(stream.playbackUrl.orEmpty().contains("cinema=760")) + assertFalse(stream.downloadLinks.isEmpty()) + assertEquals(2, stream.nextEpisodeNumber) + } + + private fun loadSample(html: String) = Jsoup.parse( + html, + "https://anime3rb.com/", + ) + + private companion object { + const val HOME_HTML = """ + +
+

الأنميات المثبتة

+ +
+
+

أحدث الحلقات

+ +
+
+

آخر الأنميات المضافة

+ +
+ + """ + + const val TITLE_LIST_HTML = """ + + + + + """ + + const val DETAILS_HTML = """ + + +
بوستر Jujutsu Kaisen
+
+

Jujutsu Kaisen ( مسلسل )

+

التقييم

9.18

+

الحلقات

24

+

التصنيف العمري

عنف و ألفاظ خارجة R - 17+

+
+

يقضي طالب الثانوية يوجي إيتادوري أيامه بلا مبالاة.

+

طالب الثانوية يوجي إيتادوري يعيش بين نادي الغيبيات.

+
+ أكشن +

JJK

+
+ + + +
الحالة:منتهي
إصدار:خريف 2020
+ + + + """ + + const val EPISODE_HTML = """ + + + + +
+ +

Jujutsu Kaisen الحلقة 1

+

ريومين سوكونا

+ + تحميل مباشر [287.94 ميغابايت] + تحميل مجمع +
+ + + """ + } +}