From 3091b8245d95855dfbcceb536868a0682f5a8d95 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:05:53 +0000 Subject: [PATCH 1/5] Redesign AniStream UI and player settings --- .../java/com/exapps/anistream/MainActivity.kt | 2 + .../data/download/AnimeDownloadService.kt | 3 + .../preferences/UserPreferencesDataSource.kt | 7 ++ .../data/repository/AnimeRepositoryImpl.kt | 4 + .../anistream/domain/model/AnimeModels.kt | 1 + .../domain/repository/AnimeRepository.kt | 1 + .../anistream/domain/usecase/AnimeUseCases.kt | 6 + .../presentation/components/AnimeCards.kt | 106 ++++++++++++++++-- .../presentation/dashboard/DashboardScreen.kt | 61 +++++++++- .../dashboard/DashboardViewModel.kt | 3 + .../presentation/details/DetailsScreen.kt | 29 ++++- .../presentation/library/LibraryScreen.kt | 67 ++++++++++- .../navigation/AniStreamNavGraph.kt | 2 + .../presentation/player/PlayerScreen.kt | 25 ++++- .../presentation/player/PlayerViewModel.kt | 9 +- .../presentation/settings/SettingsScreen.kt | 62 +++++++++- .../settings/SettingsViewModel.kt | 6 + .../com/exapps/anistream/ui/theme/Color.kt | 8 +- .../com/exapps/anistream/ui/theme/Theme.kt | 11 +- app/src/main/res/values/strings.xml | 23 ++++ 20 files changed, 402 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/exapps/anistream/MainActivity.kt b/app/src/main/java/com/exapps/anistream/MainActivity.kt index b8fabd8..e66c0cd 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() 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..d1b1ce0 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 @@ -155,6 +155,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/domain/model/AnimeModels.kt b/app/src/main/java/com/exapps/anistream/domain/model/AnimeModels.kt index ba40d31..75bff2e 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 @@ -174,6 +174,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..1e7c9b6 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 @@ -40,4 +40,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..b90dfa5 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 @@ -157,3 +157,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/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..a0db6a3 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 @@ -43,7 +43,9 @@ 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 @@ -92,6 +94,13 @@ fun DashboardScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { + 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 +189,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 +215,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 +333,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( 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..5bba430 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 @@ -105,6 +105,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 +151,7 @@ class DashboardViewModel @Inject constructor( isSearching = false, searchPage = result.currentPage, hasMoreSearchResults = result.hasNextPage, + errorMessage = null, ) } }.onFailure { error -> @@ -182,6 +184,7 @@ class DashboardViewModel @Inject constructor( searchResults = (it.searchResults + result.items).distinctBy(AnimeCard::slug), searchPage = result.currentPage, hasMoreSearchResults = result.hasNextPage, + errorMessage = null, ) } }.onFailure { error -> 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..966da87 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 @@ -52,6 +54,7 @@ import coil.compose.AsyncImage import com.exapps.anistream.R import com.exapps.anistream.domain.model.AnimeDetails 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 @@ -128,6 +131,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, @@ -174,8 +190,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()) { 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..1a3a8d8 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,6 +20,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.media3.common.util.UnstableApi import com.exapps.anistream.R import com.exapps.anistream.presentation.dashboard.DashboardScreen import com.exapps.anistream.presentation.dashboard.DashboardViewModel @@ -45,6 +46,7 @@ private data class RootDestination( ) @Composable +@UnstableApi fun AniStreamNavGraph() { val navController = rememberNavController() val backStackEntry by navController.currentBackStackEntryAsState() 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..056228c 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 @@ -48,6 +48,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 +59,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,6 +108,7 @@ 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 } @@ -184,7 +187,7 @@ fun PlayerScreen( AndroidView( modifier = Modifier .fillMaxWidth() - .height(240.dp), + .height(playerHeight), factory = { ctx -> PlayerView(ctx).apply { layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) @@ -211,13 +214,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) }, 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/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/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..8462290 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,11 +14,17 @@ ابحث في Anime3rb يتم جلب البيانات مباشرة من Anime3rb.com باستخدام محددات تم التحقق منها محلياً. + كل عالم الأنمي في مكان واحد + واجهة جديدة تركّز على الاستكمال السريع، أحدث الحلقات، والبحث المباشر من Anime3rb. جارٍ تحميل الصفحة الرئيسية والفهرس… تعذر تحميل البيانات حالياً. نتائج البحث جارٍ البحث… + لا توجد نتائج لا توجد نتائج مطابقة حالياً. + حلقات جديدة + أعمال مضافة + من الفهرس أكمل المشاهدة استكمال آخر الحلقات التي بدأت مشاهدتها. الأنميات المثبتة @@ -39,14 +45,22 @@ تحميل المزيد من النتائج المكتبة + مكتبتك الشخصية + تابع قائمتك، سجل المشاهدة، والأعمال المكتملة من مساحة واحدة. قائمتي السجل + القائمة فارغة لم تضف أي عمل إلى قائمتك بعد. + لا يوجد سجل مشاهدة لا توجد حلقات في السجل بعد. تصفية الحالة + في القائمة + في السجل + مكتمل آخر موضع: %1$s الإعدادات + تحكم بتجربة AniStream تخصيص تجربة المشاهدة والتخزين المحلي. التشغيل التلقائي للحلقة التالية ينتقل المشغل إلى الحلقة التالية تلقائياً عند انتهاء الحلقة الحالية. @@ -58,6 +72,10 @@ استخدام ألوان Material You عندما تكون متاحة على الجهاز. مسح السجل مسح القائمة + مدة تخطي المقدمة + تستخدم في زر التخطي داخل المشغل ويمكن ضبطها حسب أغلب مقدمات الأنمي. + %1$d ثانية + إدارة البيانات المحلية هذا التطبيق يعتمد على الكشط المحلي فقط ولا يستخدم أي خادم وسيط أو حسابات خارجية. الإصدار %1$s (%2$d) @@ -69,8 +87,11 @@ وضع القصة الكاملة يتم حفظ هذا التفضيل محلياً عبر DataStore. أسماء أخرى + ابدأ المشاهدة الآن الحلقات تم استخراجها من بنية .video-list و .video-data في Anime3rb. + لا توجد حلقات متاحة + لم يتم العثور على قائمة حلقات لهذا العمل حالياً. أنميات مشابهة مقترحة العروض التشويقية المصادر الخارجية @@ -107,6 +128,8 @@ مشغل مضمّن تخطي المقدمة %1$dث جارٍ إعادة استخراج الرابط وتجربة مصدر بديل… + الوضع السينمائي مفعّل + الوضع السينمائي غير مفعّل تنزيلات AniStream جارٍ تنزيل الحلقة وتحويلها للاستخدام دون اتصال… From 05e071a3e89456d21284a5bc90af86af3b6e1940 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:56:42 +0000 Subject: [PATCH 2/5] Fix Anime3rb scraper parsing --- .../network/CloudflareChallengeInterceptor.kt | 2 +- .../core/webview/CloudflareChallengeSolver.kt | 2 +- .../webview/CloudflareWebViewInterceptor.kt | 69 ++++--- .../data/scraper/Anime3rbExtractor.kt | 17 +- .../data/scraper/Anime3rbHtmlParser.kt | 191 ++++++++++++++++-- .../data/scraper/Anime3rbHtmlParserTest.kt | 182 +++++++++++++++++ 6 files changed, 419 insertions(+), 44 deletions(-) create mode 100644 app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt 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..1eca7de 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 @@ -22,7 +22,7 @@ class CloudflareChallengeInterceptor @Inject constructor( response.close() runBlocking { - solver.ensureClearance(request.url) + solver.ensureClearance(request.url, forceRefresh = true) } return chain.proceed( 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..ae881f3 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 @@ -3,5 +3,5 @@ package com.exapps.anistream.core.webview import okhttp3.HttpUrl interface CloudflareChallengeSolver { - suspend fun ensureClearance(url: HttpUrl) + suspend fun ensureClearance(url: HttpUrl, forceRefresh: Boolean = false) } 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 index c0e1653..7ea8f91 100644 --- a/app/src/main/java/com/exapps/anistream/core/webview/CloudflareWebViewInterceptor.kt +++ b/app/src/main/java/com/exapps/anistream/core/webview/CloudflareWebViewInterceptor.kt @@ -39,11 +39,11 @@ class CloudflareWebViewInterceptor @Inject constructor( private val mutex = Mutex() - override suspend fun ensureClearance(url: HttpUrl) { - if (sessionStore.hasFreshSession(url)) return + override suspend fun ensureClearance(url: HttpUrl, forceRefresh: Boolean) { + if (!forceRefresh && sessionStore.hasFreshSession(url)) return mutex.withLock { - if (sessionStore.hasFreshSession(url)) return + if (!forceRefresh && sessionStore.hasFreshSession(url)) return withTimeout(45_000) { withContext(dispatchers.main) { @@ -58,6 +58,7 @@ class CloudflareWebViewInterceptor @Inject constructor( val webView = WebView(appContext) val mainHandler = Handler(Looper.getMainLooper()) val cleanedUp = AtomicBoolean(false) + val pollingStarted = AtomicBoolean(false) runCatching { cookieManager.setAcceptThirdPartyCookies(webView, true) } fun cleanup() { @@ -84,6 +85,38 @@ class CloudflareWebViewInterceptor @Inject constructor( webView.isVerticalScrollBarEnabled = false webView.isHorizontalScrollBarEnabled = false webView.webChromeClient = WebChromeClient() + fun tryComplete(pageUrl: String) { + if (continuation.isCompleted) return + + val resolvedUrl = pageUrl.toHttpUrlOrNull() ?: url + val cookieHeader = cookieManager.getCookie(pageUrl) + ?: cookieManager.getCookie(url.toString()) + .orEmpty() + val currentUserAgent = webView.settings.userAgentString.orEmpty() + if (currentUserAgent.isNotBlank()) { + sessionStore.updateUserAgent(currentUserAgent) + } + + val title = webView.title.orEmpty() + val solved = cookieHeader.contains("cf_clearance") || + ( + title.isNotBlank() && + !title.contains("Just a moment", ignoreCase = true) && + !title.contains("Attention Required", ignoreCase = true) + ) + + if (!solved) { + mainHandler.postDelayed({ tryComplete(pageUrl) }, SOLVE_POLL_INTERVAL_MS) + return + } + + cookieManager.flush() + cookieStore.saveFromHeader(resolvedUrl, cookieHeader) + sessionStore.markSessionAcquired(resolvedUrl) + continuation.resume(Unit) + cleanup() + } + webView.webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView, pageUrl: String, favicon: Bitmap?) { val currentUserAgent = view.settings.userAgentString.orEmpty() @@ -93,30 +126,8 @@ class CloudflareWebViewInterceptor @Inject constructor( } 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) + if (!pollingStarted.compareAndSet(false, true)) return + view.postDelayed({ tryComplete(pageUrl) }, SOLVE_POLL_INTERVAL_MS) } override fun onReceivedError( @@ -139,4 +150,8 @@ class CloudflareWebViewInterceptor @Inject constructor( } } } + + private companion object { + const val SOLVE_POLL_INTERVAL_MS = 1_000L + } } 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..0e79605 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 @@ -121,10 +121,21 @@ class Anime3rbExtractor @Inject constructor( return withContext(dispatchers.io) { val httpUrl = url.toHttpUrl() cloudflareChallengeSolver.ensureClearance(httpUrl) - val request = Request.Builder().url(url).get().build() + var request = Request.Builder().url(url).get().build() client.newCall(request).execute().use { response -> - check(response.isSuccessful) { "Request failed: ${response.code} ${response.message}" } - Jsoup.parse(response.body?.string().orEmpty(), response.request.url.toString()) + if (response.isSuccessful) { + return@withContext Jsoup.parse(response.body?.string().orEmpty(), response.request.url.toString()) + } + + response.close() + cloudflareChallengeSolver.ensureClearance(httpUrl, forceRefresh = true) + request = request.newBuilder().build() + client.newCall(request).execute().use { retryResponse -> + check(retryResponse.isSuccessful) { + "Request failed: ${retryResponse.code} ${retryResponse.message}" + } + Jsoup.parse(retryResponse.body?.string().orEmpty(), retryResponse.request.url.toString()) + } } } } 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..28a2008 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,39 @@ 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 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 +105,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") @@ -148,11 +173,18 @@ class Anime3rbHtmlParser @Inject constructor() { author = detailsTable.valueForLabel("المؤلف:"), ageRating = metrics.metricValue("التصنيف العمري"), score = metrics.metricValue("التقييم"), - episodeCount = metrics.metricValue("الحلقات")?.toIntOrNull(), - synopsis = fullSynopsis, + 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 +193,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 +203,39 @@ 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 activeSourceId = decodedSnapshot.livewireValue("video_source") val csrfToken = document.selectFirst("meta[name=csrf-token]")?.attr("content") - val iframeUrl = document.selectFirst("iframe")?.absUrl("src") + val iframeUrl = document.selectFirst("iframe")?.absUrl("src") ?: directPlayerUrl 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 @@ -203,6 +256,15 @@ class Anime3rbHtmlParser @Inject constructor() { 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") @@ -225,6 +287,7 @@ class Anime3rbHtmlParser @Inject constructor() { playbackUrl = directPlayerUrl, availableSources = when { availableSources.isNotEmpty() -> availableSources + structuredSources.isNotEmpty() -> structuredSources !directPlayerUrl.isNullOrBlank() -> listOf( VideoSource( id = activeSourceId ?: "default", @@ -290,6 +353,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 +462,70 @@ 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.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 companion object { + val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + val TITLE_SCHEMA_TYPES = setOf("TVSeries", "Movie", "CreativeWork", "Series") + } } 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..9961cb6 --- /dev/null +++ b/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt @@ -0,0 +1,182 @@ +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(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/")) + 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 ميغابايت] + تحميل مجمع +
+ + + """ + } +} From 6099c05a1264db93700b78722f0b17c1e164c8cd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:13:17 +0000 Subject: [PATCH 3/5] Use visible WebView for Anime3rb Cloudflare session --- .../java/com/exapps/anistream/MainActivity.kt | 5 +- .../network/CloudflareChallengeInterceptor.kt | 5 +- .../core/network/InMemoryCookieJar.kt | 3 + .../core/webview/Anime3rbSessionBridge.kt | 36 ++++ .../core/webview/CloudflareChallengeSolver.kt | 4 +- .../webview/CloudflareWebViewInterceptor.kt | 157 ----------------- .../VisibleCloudflareChallengeSolver.kt | 21 +++ .../data/scraper/Anime3rbExtractor.kt | 21 +-- .../java/com/exapps/anistream/di/AppModule.kt | 4 +- .../cloudflare/Anime3rbSessionScreen.kt | 161 ++++++++++++++++++ .../navigation/AniStreamNavGraph.kt | 147 +++++++++------- .../presentation/root/RootViewModel.kt | 26 ++- app/src/main/res/values/strings.xml | 7 + 13 files changed, 347 insertions(+), 250 deletions(-) create mode 100644 app/src/main/java/com/exapps/anistream/core/webview/Anime3rbSessionBridge.kt delete mode 100644 app/src/main/java/com/exapps/anistream/core/webview/CloudflareWebViewInterceptor.kt create mode 100644 app/src/main/java/com/exapps/anistream/core/webview/VisibleCloudflareChallengeSolver.kt create mode 100644 app/src/main/java/com/exapps/anistream/presentation/cloudflare/Anime3rbSessionScreen.kt diff --git a/app/src/main/java/com/exapps/anistream/MainActivity.kt b/app/src/main/java/com/exapps/anistream/MainActivity.kt index e66c0cd..bd6a973 100644 --- a/app/src/main/java/com/exapps/anistream/MainActivity.kt +++ b/app/src/main/java/com/exapps/anistream/MainActivity.kt @@ -33,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 1eca7de..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, forceRefresh = true) - } + 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 ae881f3..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, forceRefresh: Boolean = false) + 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 7ea8f91..0000000 --- a/app/src/main/java/com/exapps/anistream/core/webview/CloudflareWebViewInterceptor.kt +++ /dev/null @@ -1,157 +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, forceRefresh: Boolean) { - if (!forceRefresh && sessionStore.hasFreshSession(url)) return - - mutex.withLock { - if (!forceRefresh && 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) - val pollingStarted = 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() - fun tryComplete(pageUrl: String) { - if (continuation.isCompleted) return - - val resolvedUrl = pageUrl.toHttpUrlOrNull() ?: url - val cookieHeader = cookieManager.getCookie(pageUrl) - ?: cookieManager.getCookie(url.toString()) - .orEmpty() - val currentUserAgent = webView.settings.userAgentString.orEmpty() - if (currentUserAgent.isNotBlank()) { - sessionStore.updateUserAgent(currentUserAgent) - } - - val title = webView.title.orEmpty() - val solved = cookieHeader.contains("cf_clearance") || - ( - title.isNotBlank() && - !title.contains("Just a moment", ignoreCase = true) && - !title.contains("Attention Required", ignoreCase = true) - ) - - if (!solved) { - mainHandler.postDelayed({ tryComplete(pageUrl) }, SOLVE_POLL_INTERVAL_MS) - return - } - - cookieManager.flush() - cookieStore.saveFromHeader(resolvedUrl, cookieHeader) - sessionStore.markSessionAcquired(resolvedUrl) - continuation.resume(Unit) - cleanup() - } - - 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) { - if (!pollingStarted.compareAndSet(false, true)) return - view.postDelayed({ tryComplete(pageUrl) }, SOLVE_POLL_INTERVAL_MS) - } - - 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()), - ) - } - } - } - } - } - - private companion object { - const val SOLVE_POLL_INTERVAL_MS = 1_000L - } -} 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/scraper/Anime3rbExtractor.kt b/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbExtractor.kt index 0e79605..0ae6b19 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,7 +1,6 @@ package com.exapps.anistream.data.scraper import com.exapps.anistream.core.common.DispatcherProvider -import com.exapps.anistream.core.webview.CloudflareChallengeSolver import com.exapps.anistream.domain.model.AnimeDetails import com.exapps.anistream.domain.model.CatalogFilters import com.exapps.anistream.domain.model.EpisodeStream @@ -24,7 +23,6 @@ class Anime3rbExtractor @Inject constructor( private val parser: Anime3rbHtmlParser, private val videoStreamResolver: VideoStreamResolver, private val autoFailoverPlaybackResolver: AutoFailoverPlaybackResolver, - private val cloudflareChallengeSolver: CloudflareChallengeSolver, private val dispatchers: DispatcherProvider, ) : AnimeExtractor { @@ -119,23 +117,10 @@ class Anime3rbExtractor @Inject constructor( private suspend fun fetchDocument(url: String): Document { return withContext(dispatchers.io) { - val httpUrl = url.toHttpUrl() - cloudflareChallengeSolver.ensureClearance(httpUrl) - var request = Request.Builder().url(url).get().build() + val request = Request.Builder().url(url).get().build() client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - return@withContext Jsoup.parse(response.body?.string().orEmpty(), response.request.url.toString()) - } - - response.close() - cloudflareChallengeSolver.ensureClearance(httpUrl, forceRefresh = true) - request = request.newBuilder().build() - client.newCall(request).execute().use { retryResponse -> - check(retryResponse.isSuccessful) { - "Request failed: ${retryResponse.code} ${retryResponse.message}" - } - Jsoup.parse(retryResponse.body?.string().orEmpty(), retryResponse.request.url.toString()) - } + check(response.isSuccessful) { "Request failed: ${response.code} ${response.message}" } + Jsoup.parse(response.body?.string().orEmpty(), response.request.url.toString()) } } } 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/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/navigation/AniStreamNavGraph.kt b/app/src/main/java/com/exapps/anistream/presentation/navigation/AniStreamNavGraph.kt index 1a3a8d8..ec7bf82 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 @@ -26,6 +26,7 @@ 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 @@ -48,6 +49,18 @@ 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 @@ -57,13 +70,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 = { @@ -106,67 +120,74 @@ 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), + ) + }, + ) + } - 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() + LibraryScreen( + viewModel = viewModel, + onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) }, + onOpenEpisode = { titleSlug, episodeNumber -> + navController.navigate( + PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), + ) + }, + ) + } - composable { - val viewModel = hiltViewModel() - SettingsScreen(viewModel = viewModel) - } + composable { + val viewModel = hiltViewModel() + SettingsScreen(viewModel = viewModel) + } - 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() + 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() - PlayerScreen( - viewModel = viewModel, - onBack = navController::popBackStack, - onOpenEpisode = { titleSlug, episodeNumber -> - navController.navigate( - PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), - ) - }, - ) + composable { + val viewModel = hiltViewModel() + PlayerScreen( + viewModel = viewModel, + onBack = navController::popBackStack, + onOpenEpisode = { titleSlug, episodeNumber -> + navController.navigate( + PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), + ) + }, + ) + } } } } 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8462290..2175da8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,6 +12,13 @@ مسح تم + تجهيز جلسة Anime3rb + تظهر صفحة الموقع أولاً لتجاوز حماية Cloudflare وحفظ الكوكيز على الجهاز، ثم يبدأ الجلب المباشر. + جارٍ تحميل Anime3rb… + بانتظار انتهاء حماية Cloudflare… + تم تحميل الصفحة. جاري حفظ الجلسة… + تعذر تحميل صفحة Anime3rb. + ابحث في Anime3rb يتم جلب البيانات مباشرة من Anime3rb.com باستخدام محددات تم التحقق منها محلياً. كل عالم الأنمي في مكان واحد From d8ecde2b5c73b3d73caa6705e6c6a22b806ce614 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:33:27 +0000 Subject: [PATCH 4/5] Fix Anime3rb search player and catalogs --- .../data/repository/AnimeRepositoryImpl.kt | 5 + .../data/scraper/Anime3rbExtractor.kt | 59 +++++-- .../data/scraper/Anime3rbHtmlParser.kt | 18 +- .../anistream/data/scraper/AnimeExtractor.kt | 2 + .../scraper/AutoFailoverPlaybackResolver.kt | 11 +- .../data/scraper/VideoStreamResolver.kt | 4 + .../anistream/domain/model/AnimeModels.kt | 23 +++ .../domain/repository/AnimeRepository.kt | 2 + .../anistream/domain/usecase/AnimeUseCases.kt | 7 + .../presentation/catalog/CatalogScreen.kt | 164 ++++++++++++++++++ .../presentation/catalog/CatalogViewModel.kt | 125 +++++++++++++ .../presentation/dashboard/DashboardScreen.kt | 21 ++- .../dashboard/DashboardViewModel.kt | 2 + .../presentation/details/DetailsScreen.kt | 2 + .../navigation/AniStreamNavGraph.kt | 12 ++ .../presentation/navigation/Routes.kt | 3 + .../presentation/player/PlayerScreen.kt | 81 +++++++-- app/src/main/res/values/strings.xml | 5 + .../data/scraper/Anime3rbHtmlParserTest.kt | 3 + 19 files changed, 510 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/exapps/anistream/presentation/catalog/CatalogScreen.kt create mode 100644 app/src/main/java/com/exapps/anistream/presentation/catalog/CatalogViewModel.kt 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 d1b1ce0..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) } 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 0ae6b19..2fab2d6 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 @@ -2,7 +2,9 @@ package com.exapps.anistream.data.scraper import com.exapps.anistream.core.common.DispatcherProvider 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 @@ -30,17 +32,18 @@ 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) } @@ -49,14 +52,8 @@ class Anime3rbExtractor @Inject constructor( 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()) } + .addQueryParameter("deep", "1") + .addCatalogFilters(page, filters, includeDefaultSort = false) .build() return parser.parseSearch(fetchDocument(url.toString()), page) } @@ -125,6 +122,36 @@ class Anime3rbExtractor @Inject constructor( } } + 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 28a2008..3cd5cd5 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 @@ -92,6 +92,7 @@ class Anime3rbHtmlParser @Inject constructor() { 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()?.trim() ?: structuredTitle?.stringValue("name")?.cleanSiteSuffix() @@ -135,6 +136,7 @@ class Anime3rbHtmlParser @Inject constructor() { ?.select("h2") ?.eachText() .orEmpty() + .ifEmpty { structuredTitle?.stringListValue("alternateName").orEmpty() } val externalLinks = contentRoot .findLabeledBlock("المصادر") @@ -172,7 +174,11 @@ class Anime3rbHtmlParser @Inject constructor() { studio = detailsTable.valueForLabel("الاستديو:"), author = detailsTable.valueForLabel("المؤلف:"), ageRating = metrics.metricValue("التصنيف العمري"), - score = metrics.metricValue("التقييم"), + 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 }, @@ -274,7 +280,7 @@ class Anime3rbHtmlParser @Inject constructor() { qualityLabel = quality.ifBlank { link.text().trim() }, url = url, ) - } + }.filterNot { it.url == batchDownloadUrl } return EpisodeStream( titleSlug = titleSlug, @@ -507,6 +513,10 @@ class Anime3rbHtmlParser @Inject constructor() { 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) { @@ -521,6 +531,10 @@ class Anime3rbHtmlParser @Inject constructor() { private fun String.htmlDecoded(): String = replace("&", "&") + private fun Double.trimTrailingZero(): String { + return if (this % 1.0 == 0.0) toInt().toString() else toString() + } + private companion object { val json = Json { ignoreUnknownKeys = true 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/domain/model/AnimeModels.kt b/app/src/main/java/com/exapps/anistream/domain/model/AnimeModels.kt index 75bff2e..3e444b0 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, 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 1e7c9b6..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 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 b90dfa5..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(), 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/dashboard/DashboardScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/dashboard/DashboardScreen.kt index a0db6a3..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,6 +39,7 @@ 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 @@ -56,6 +57,7 @@ fun DashboardScreen( viewModel: DashboardViewModel, onOpenDetails: (String) -> Unit, onOpenEpisode: (String, Int) -> Unit, + onOpenCatalog: (CatalogCategory) -> Unit, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -94,6 +96,23 @@ 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), @@ -408,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 5bba430..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 @@ -231,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 966da87..b0ed5a6 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 @@ -363,6 +363,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/navigation/AniStreamNavGraph.kt b/app/src/main/java/com/exapps/anistream/presentation/navigation/AniStreamNavGraph.kt index ec7bf82..1c74460 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 @@ -22,6 +22,8 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController 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 @@ -141,6 +143,16 @@ fun AniStreamNavGraph( PlayerRoute(titleSlug = titleSlug, episodeNumber = episodeNumber), ) }, + onOpenCatalog = { category -> navController.navigate(CatalogRoute(categoryPath = category.path)) }, + ) + } + + composable { + val viewModel = hiltViewModel() + CatalogScreen( + viewModel = viewModel, + onBack = navController::popBackStack, + onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) }, ) } 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..b97bedb 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) 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 056228c..4da9f16 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 @@ -112,6 +116,11 @@ fun PlayerScreen( 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 @@ -183,21 +192,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(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 }, - ) + 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( @@ -283,12 +326,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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2175da8..6ce08e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,9 @@ قائمة الأعمال الجديدة مباشرة من Anime3rb. فهرس الأنمي يدعم الترتيب والتحميل التدريجي من الموقع. + قوائم Anime3rb + افتح قوائم الأنمي، الأفلام، OVA، ONA، الحلقات الخاصة والمزيد. + قائمة مباشرة من Anime3rb حسب النوع المحدد. ترتيب الفهرس تاريخ الإضافة الاسم @@ -112,6 +115,8 @@ التقييم الحلقات التصنيف العمري + تاريخ العرض + عدد التقييمات مشاهدة الحلقة %1$d 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 index 9961cb6..db1cf6b 100644 --- a/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt +++ b/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt @@ -48,6 +48,8 @@ class Anime3rbHtmlParserTest { 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("أكشن")) @@ -152,6 +154,7 @@ class Anime3rbHtmlParserTest { الحالة:منتهي إصدار:خريف 2020 +
23:59
الحلقة 1

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

23:59
الحلقة 2

من أجل نفسي

From 0f6de6f83f4cecd14d24a151887c7ced2c2f7265 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:53:48 +0000 Subject: [PATCH 5/5] Fix search challenge playback and trailers --- .../data/scraper/Anime3rbExtractor.kt | 35 +++++++--- .../data/scraper/Anime3rbHtmlParser.kt | 13 +++- .../anistream/domain/model/AnimeModels.kt | 6 ++ .../presentation/details/DetailsScreen.kt | 4 +- .../navigation/AniStreamNavGraph.kt | 14 ++++ .../presentation/navigation/Routes.kt | 6 ++ .../presentation/player/PlayerScreen.kt | 7 +- .../presentation/trailer/TrailerScreen.kt | 64 +++++++++++++++++++ .../data/scraper/Anime3rbHtmlParserTest.kt | 3 +- 9 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/exapps/anistream/presentation/trailer/TrailerScreen.kt 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 2fab2d6..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,6 +1,9 @@ package com.exapps.anistream.data.scraper import com.exapps.anistream.core.common.DispatcherProvider +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 @@ -16,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 @@ -25,6 +29,8 @@ class Anime3rbExtractor @Inject constructor( private val parser: Anime3rbHtmlParser, private val videoStreamResolver: VideoStreamResolver, private val autoFailoverPlaybackResolver: AutoFailoverPlaybackResolver, + private val sessionBridge: Anime3rbSessionBridge, + private val challengeSolver: VisibleCloudflareChallengeSolver, private val dispatchers: DispatcherProvider, ) : AnimeExtractor { @@ -49,13 +55,14 @@ class Anime3rbExtractor @Inject constructor( } override suspend fun search(query: String, page: Int, filters: CatalogFilters): PaginatedTitles { - val url = BASE_URL.toHttpUrl().newBuilder() - .addPathSegment("search") - .addQueryParameter("q", query) - .addQueryParameter("deep", "1") - .addCatalogFilters(page, filters, includeDefaultSort = false) - .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 { @@ -105,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, @@ -114,9 +121,19 @@ class Anime3rbExtractor @Inject constructor( private suspend fun fetchDocument(url: String): Document { return withContext(dispatchers.io) { + 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()) } } 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 3cd5cd5..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 @@ -228,9 +228,10 @@ class Anime3rbHtmlParser @Inject constructor() { 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") ?: directPlayerUrl val episodeTitle = document.selectFirst("h2.inline.text-lg.font-light")?.text()?.trim() ?: structuredVideos.firstNotNullOfOrNull { it.stringValue("description") } ?.substringAfter(" بعنوان ", missingDelimiterValue = "") @@ -257,7 +258,7 @@ 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 }, ) @@ -290,7 +291,7 @@ class Anime3rbHtmlParser @Inject constructor() { episodeTitle = episodeTitle, refererUrl = document.location(), iframeUrl = iframeUrl, - playbackUrl = directPlayerUrl, + playbackUrl = iframeUrl ?: directPlayerUrl, availableSources = when { availableSources.isNotEmpty() -> availableSources structuredSources.isNotEmpty() -> structuredSources @@ -535,6 +536,12 @@ class Anime3rbHtmlParser @Inject constructor() { 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 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 3e444b0..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 @@ -146,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, 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 b0ed5a6..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 @@ -53,6 +53,7 @@ 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 @@ -67,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 @@ -175,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) }, ) } 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 1c74460..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,6 +20,7 @@ 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 @@ -35,6 +36,7 @@ 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, @@ -185,6 +187,9 @@ fun AniStreamNavGraph( ) }, onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) }, + onPlayTrailer = { trailer -> + navController.navigate(TrailerRoute(title = trailer.title, embedUrl = trailer.embedUrl)) + }, ) } @@ -200,6 +205,15 @@ fun AniStreamNavGraph( }, ) } + + 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 b97bedb..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 @@ -22,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 4da9f16..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 @@ -122,12 +122,7 @@ fun PlayerScreen( ?: 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 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/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt b/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt index db1cf6b..6c6a7e6 100644 --- a/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt +++ b/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt @@ -67,6 +67,7 @@ class Anime3rbHtmlParserTest { 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) } @@ -168,7 +169,7 @@ class Anime3rbHtmlParserTest {
- +

Jujutsu Kaisen الحلقة 1

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