diff --git a/AGENTS.md b/AGENTS.md
index adffa00..53ebbad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,10 +1,10 @@
-# OpenAnime Agent Guide
+# OpenStream Agent Guide
This file is the operating manual for any agent working in this repository. Follow it strictly.
## Mission
-OpenAnime is a native Android app for discovering and streaming anime using Jetpack Compose, Material 3 Expressive, Hilt, Retrofit, Room, and Media3.
+OpenStream is a native Android app for discovering and streaming anime using Jetpack Compose, Material 3 Expressive, Hilt, Retrofit, Room, and Media3.
The job is not just to make code compile. The job is to keep the app coherent, expressive, maintainable, and unmistakably premium.
@@ -15,10 +15,10 @@ When documentation conflicts with code, trust the code.
Some repo docs are stale or use the old product name `OpenStream`. Do not propagate that drift.
For new work:
-- Prefer the product name `OpenAnime` in code comments, docs, and user-facing copy unless the user explicitly asks for a rename.
+- Prefer the product name `OpenStream` in code comments, docs, and user-facing copy unless the user explicitly asks for a rename.
- Verify dependencies in [gradle/libs.versions.toml](gradle/libs.versions.toml).
-- Verify theme behavior in [Theme.kt](app/src/main/java/com/ivor/openanime/ui/theme/Theme.kt).
-- Verify navigation in [AppNavigation.kt](app/src/main/java/com/ivor/openanime/presentation/navigation/AppNavigation.kt).
+- Verify theme behavior in [Theme.kt](app/src/main/java/com/ivor/OpenStream/ui/theme/Theme.kt).
+- Verify navigation in [AppNavigation.kt](app/src/main/java/com/ivor/OpenStream/presentation/navigation/AppNavigation.kt).
- Verify rules in [rules.md](rules.md).
## Non-Negotiable Rules
@@ -49,7 +49,7 @@ For new work:
## Current Codebase Shape
Main app entry:
-- [OpenAnimeApp.kt](app/src/main/java/com/ivor/openanime/OpenAnimeApp.kt)
+- [OpenStreamApp.kt](app/src/main/java/com/ivor/OpenStream/OpenStreamApp.kt)
- [MainActivity.kt](MainActivity.kt)
Important layers:
@@ -88,7 +88,7 @@ Important screens:
### Navigation
-- Routes live in [AppNavigation.kt](app/src/main/java/com/ivor/openanime/presentation/navigation/AppNavigation.kt).
+- Routes live in [AppNavigation.kt](app/src/main/java/com/ivor/OpenStream/presentation/navigation/AppNavigation.kt).
- Reuse the existing route patterns and argument style.
- If a new screen is added, wire the route, typed arguments, and callbacks cleanly.
@@ -150,7 +150,7 @@ The UI must not feel:
## Material 3 Expressive Rules
-1. Start with the theme and component system already present in [Theme.kt](app/src/main/java/com/ivor/openanime/ui/theme/Theme.kt), [Shape.kt](app/src/main/java/com/ivor/openanime/ui/theme/Shape.kt), and [Type.kt](app/src/main/java/com/ivor/openanime/ui/theme/Type.kt).
+1. Start with the theme and component system already present in [Theme.kt](app/src/main/java/com/ivor/OpenStream/ui/theme/Theme.kt), [Shape.kt](app/src/main/java/com/ivor/OpenStream/ui/theme/Shape.kt), and [Type.kt](app/src/main/java/com/ivor/OpenStream/ui/theme/Type.kt).
2. Prefer expressive components such as `MaterialExpressiveTheme`, `LoadingIndicator`, floating toolbars, connected button groups, and expressive button shapes when available.
3. Use `MaterialTheme.colorScheme` roles. Do not hardcode random colors into screens unless there is a strong artistic reason and it still harmonizes with the theme.
4. Favor surface containers over naked backgrounds when grouping content.
@@ -238,7 +238,7 @@ If adding motion:
## Engineering Rules For UI Work
- Before creating a custom composable, check whether a Material 3 or expressive component already solves the problem.
-- Reuse existing shared UI pieces such as [AnimeCard.kt](app/src/main/java/com/ivor/openanime/presentation/components/AnimeCard.kt) and [ExpressiveBackButton.kt](app/src/main/java/com/ivor/openanime/presentation/components/ExpressiveBackButton.kt) when appropriate.
+- Reuse existing shared UI pieces such as [AnimeCard.kt](app/src/main/java/com/ivor/OpenStream/presentation/components/AnimeCard.kt) and [ExpressiveBackButton.kt](app/src/main/java/com/ivor/OpenStream/presentation/components/ExpressiveBackButton.kt) when appropriate.
- If an existing shared component is weak, improve it rather than cloning variants across screens.
- Keep modifier chains readable.
- Prefer stable, named helper composables when a screen becomes too long.
@@ -270,7 +270,7 @@ If adding motion:
## Documentation Hygiene
If you touch documentation:
-- keep product naming consistent with `OpenAnime`
+- keep product naming consistent with `OpenStream`
- remove stale claims rather than stacking new contradictory notes on top
- keep docs shorter and truer rather than broader and vaguer
@@ -280,11 +280,11 @@ If you touch documentation:
- [README.md](README.md)
- [Material3ExpressiveDesignGuide.md](Material3ExpressiveDesignGuide.md)
- [material-3-expressive-guide.md](material-3-expressive-guide.md)
-- [Theme.kt](app/src/main/java/com/ivor/openanime/ui/theme/Theme.kt)
-- [Shape.kt](app/src/main/java/com/ivor/openanime/ui/theme/Shape.kt)
-- [Type.kt](app/src/main/java/com/ivor/openanime/ui/theme/Type.kt)
-- [AppNavigation.kt](app/src/main/java/com/ivor/openanime/presentation/navigation/AppNavigation.kt)
-- [PlayerScreen.kt](app/src/main/java/com/ivor/openanime/presentation/player/PlayerScreen.kt)
+- [Theme.kt](app/src/main/java/com/ivor/OpenStream/ui/theme/Theme.kt)
+- [Shape.kt](app/src/main/java/com/ivor/OpenStream/ui/theme/Shape.kt)
+- [Type.kt](app/src/main/java/com/ivor/OpenStream/ui/theme/Type.kt)
+- [AppNavigation.kt](app/src/main/java/com/ivor/OpenStream/presentation/navigation/AppNavigation.kt)
+- [PlayerScreen.kt](app/src/main/java/com/ivor/OpenStream/presentation/player/PlayerScreen.kt)
## Final Standard
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6422845..d87225d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -43,6 +43,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/ivor/openanime/data/remote/GithubApi.kt b/app/src/main/java/com/ivor/openanime/data/remote/GithubApi.kt
new file mode 100644
index 0000000..e1c3fcb
--- /dev/null
+++ b/app/src/main/java/com/ivor/openanime/data/remote/GithubApi.kt
@@ -0,0 +1,27 @@
+package com.ivor.openanime.data.remote
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import retrofit2.http.GET
+
+@Serializable
+data class GithubReleaseDto(
+ @SerialName("tag_name") val tagName: String,
+ @SerialName("name") val name: String,
+ @SerialName("body") val body: String,
+ @SerialName("html_url") val htmlUrl: String,
+ @SerialName("published_at") val publishedAt: String,
+ @SerialName("assets") val assets: List = emptyList()
+)
+
+@Serializable
+data class GithubAssetDto(
+ @SerialName("name") val name: String,
+ @SerialName("browser_download_url") val downloadUrl: String,
+ @SerialName("size") val size: Long
+)
+
+interface GithubApi {
+ @GET("repos/ivorisnoob/openstream/releases/latest")
+ suspend fun getLatestRelease(): GithubReleaseDto
+}
diff --git a/app/src/main/java/com/ivor/openanime/data/remote/TmdbApi.kt b/app/src/main/java/com/ivor/openanime/data/remote/TmdbApi.kt
index d43c336..7214953 100644
--- a/app/src/main/java/com/ivor/openanime/data/remote/TmdbApi.kt
+++ b/app/src/main/java/com/ivor/openanime/data/remote/TmdbApi.kt
@@ -4,11 +4,33 @@ import com.ivor.openanime.data.remote.model.AnimeDetailsDto
import com.ivor.openanime.data.remote.model.AnimeDto
import com.ivor.openanime.data.remote.model.SeasonDetailsDto
import com.ivor.openanime.data.remote.model.TmdbResponse
+import com.ivor.openanime.data.remote.model.KeywordDto
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface TmdbApi {
+ @GET("discover/movie")
+ suspend fun discoverMovie(
+ @Query("page") page: Int = 1,
+ @Query("sort_by") sortBy: String? = null,
+ @Query("with_genres") withGenres: String? = null,
+ @Query("with_keywords") withKeywords: String? = null
+ ): TmdbResponse
+
+ @GET("discover/tv")
+ suspend fun discoverTv(
+ @Query("page") page: Int = 1,
+ @Query("sort_by") sortBy: String? = null,
+ @Query("with_genres") withGenres: String? = null,
+ @Query("with_keywords") withKeywords: String? = null
+ ): TmdbResponse
+
+ @GET("search/keyword")
+ suspend fun searchKeyword(
+ @Query("query") query: String,
+ @Query("page") page: Int = 1
+ ): TmdbResponse
@GET("discover/tv")
suspend fun getPopularAnime(
@Query("page") page: Int = 1,
diff --git a/app/src/main/java/com/ivor/openanime/data/remote/model/AnimeDto.kt b/app/src/main/java/com/ivor/openanime/data/remote/model/AnimeDto.kt
index 4d7a492..86ede4e 100644
--- a/app/src/main/java/com/ivor/openanime/data/remote/model/AnimeDto.kt
+++ b/app/src/main/java/com/ivor/openanime/data/remote/model/AnimeDto.kt
@@ -15,8 +15,9 @@ data class AnimeDto(
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
@SerialName("genre_ids") val genreIds: List? = null,
- @SerialName("media_type") val mediaType: String? = "tv",
- @SerialName("original_language") val originalLanguage: String? = null
+ @SerialName("media_type") val mediaType: String? = "tv",
+ @SerialName("original_language") val originalLanguage: String? = null,
+ @SerialName("popularity") val popularity: Double? = null
) {
val name: String
get() = movieTitle ?: tvName ?: ""
diff --git a/app/src/main/java/com/ivor/openanime/data/remote/model/KeywordDto.kt b/app/src/main/java/com/ivor/openanime/data/remote/model/KeywordDto.kt
new file mode 100644
index 0000000..fa666aa
--- /dev/null
+++ b/app/src/main/java/com/ivor/openanime/data/remote/model/KeywordDto.kt
@@ -0,0 +1,10 @@
+package com.ivor.openanime.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class KeywordDto(
+ @SerialName("id") val id: Int,
+ @SerialName("name") val name: String
+)
diff --git a/app/src/main/java/com/ivor/openanime/data/repository/AnimeRepositoryImpl.kt b/app/src/main/java/com/ivor/openanime/data/repository/AnimeRepositoryImpl.kt
index a04dfa9..eb86bf2 100644
--- a/app/src/main/java/com/ivor/openanime/data/repository/AnimeRepositoryImpl.kt
+++ b/app/src/main/java/com/ivor/openanime/data/repository/AnimeRepositoryImpl.kt
@@ -43,6 +43,48 @@ class AnimeRepositoryImpl @Inject constructor(
}
}
+ override suspend fun discoverWithFilters(query: String, page: Int, mediaType: String, sortBy: String): Result> = runCatching {
+ var keywordIds: String? = null
+ if (query.isNotBlank()) {
+ val keywordResponse = api.searchKeyword(query, 1)
+ if (keywordResponse.results.isNotEmpty()) {
+ // Use up to top 3 matching keywords for discovery
+ keywordIds = keywordResponse.results.take(3).joinToString("|") { it.id.toString() }
+ }
+ }
+
+ // If a query was entered but no keywords were found, fallback to standard search
+ if (query.isNotBlank() && keywordIds == null) {
+ return searchAnime(query, page, mediaType)
+ }
+
+ val movies = mutableListOf()
+ val tvShows = mutableListOf()
+
+ if (mediaType == "movie" || mediaType == "all") {
+ val movieRes = api.discoverMovie(page = page, sortBy = sortBy, withKeywords = keywordIds)
+ movies.addAll(movieRes.results.map { it.copy(mediaType = "movie") })
+ }
+
+ if (mediaType == "tv" || mediaType == "all") {
+ val tvRes = api.discoverTv(page = page, sortBy = sortBy, withKeywords = keywordIds)
+ tvShows.addAll(tvRes.results.map { it.copy(mediaType = "tv") })
+ }
+
+ val combined = (movies + tvShows)
+
+ // Local sorting logic if "all" is selected since they are combined
+ when (sortBy) {
+ "popularity.desc" -> combined.sortedByDescending { it.popularity ?: 0.0 }
+ "popularity.asc" -> combined.sortedBy { it.popularity ?: 0.0 }
+ "vote_average.desc" -> combined.sortedByDescending { it.voteAverage ?: 0.0 }
+ "vote_average.asc" -> combined.sortedBy { it.voteAverage ?: 0.0 }
+ "first_air_date.desc", "primary_release_date.desc" -> combined.sortedByDescending { it.releaseDate ?: it.firstAirDate ?: "" }
+ "first_air_date.asc", "primary_release_date.asc" -> combined.sortedBy { it.releaseDate ?: it.firstAirDate ?: "" }
+ else -> combined
+ }
+ }
+
override suspend fun getAnimeDetails(id: Int): Result = runCatching {
api.getAnimeDetails(id = id)
}
diff --git a/app/src/main/java/com/ivor/openanime/di/NetworkModule.kt b/app/src/main/java/com/ivor/openanime/di/NetworkModule.kt
index 8425ba8..72f0579 100644
--- a/app/src/main/java/com/ivor/openanime/di/NetworkModule.kt
+++ b/app/src/main/java/com/ivor/openanime/di/NetworkModule.kt
@@ -3,6 +3,7 @@ package com.ivor.openanime.di
import com.ivor.openanime.BuildConfig
import com.ivor.openanime.data.remote.SubtitleApi
import com.ivor.openanime.data.remote.TmdbApi
+import com.ivor.openanime.data.remote.GithubApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -109,4 +110,26 @@ object NetworkModule {
.build()
.create(SubtitleApi::class.java)
}
-}
+
+ @Provides
+ @Singleton
+ fun provideGithubApi(json: Json): GithubApi {
+ val contentType = "application/json".toMediaType()
+ return Retrofit.Builder()
+ .baseUrl("https://api.github.com/")
+ .client(
+ OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ chain.proceed(
+ chain.request().newBuilder()
+ .header("Accept", "application/vnd.github+json")
+ .build()
+ )
+ }
+ .build()
+ )
+ .addConverterFactory(json.asConverterFactory(contentType))
+ .build()
+ .create(GithubApi::class.java)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivor/openanime/domain/repository/AnimeRepository.kt b/app/src/main/java/com/ivor/openanime/domain/repository/AnimeRepository.kt
index 4c9ccff..28c27cb 100644
--- a/app/src/main/java/com/ivor/openanime/domain/repository/AnimeRepository.kt
+++ b/app/src/main/java/com/ivor/openanime/domain/repository/AnimeRepository.kt
@@ -11,6 +11,7 @@ interface AnimeRepository {
suspend fun getAiringTodayAnime(page: Int = 1): Result>
suspend fun searchAnime(query: String, page: Int, filter: String = "all"): Result>
+ suspend fun discoverWithFilters(query: String, page: Int, mediaType: String, sortBy: String): Result>
suspend fun getAnimeDetails(id: Int): Result
suspend fun getMovieDetails(id: Int): Result
suspend fun getMediaDetails(id: Int, mediaType: String): Result
diff --git a/app/src/main/java/com/ivor/openanime/presentation/home/HomeScreen.kt b/app/src/main/java/com/ivor/openanime/presentation/home/HomeScreen.kt
index da33003..abe063c 100644
--- a/app/src/main/java/com/ivor/openanime/presentation/home/HomeScreen.kt
+++ b/app/src/main/java/com/ivor/openanime/presentation/home/HomeScreen.kt
@@ -16,6 +16,9 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.SystemUpdate
+import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.TopAppBar
@@ -62,6 +65,7 @@ fun HomeScreen(
onAnimeClick: (Int) -> Unit,
onSearchClick: () -> Unit,
onHistoryClick: () -> Unit,
+ onUpdateClick: () -> Unit = {},
viewModel: HomeViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
@@ -91,6 +95,14 @@ fun HomeScreen(
style = MaterialTheme.typography.displaySmall
)
},
+ actions = {
+ IconButton(onClick = onUpdateClick) {
+ Icon(
+ imageVector = Icons.Default.SystemUpdate,
+ contentDescription = "Check for updates"
+ )
+ }
+ },
scrollBehavior = scrollBehavior
)
}
diff --git a/app/src/main/java/com/ivor/openanime/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/ivor/openanime/presentation/navigation/AppNavigation.kt
index f88942a..028063a 100644
--- a/app/src/main/java/com/ivor/openanime/presentation/navigation/AppNavigation.kt
+++ b/app/src/main/java/com/ivor/openanime/presentation/navigation/AppNavigation.kt
@@ -21,7 +21,7 @@ import com.ivor.openanime.presentation.player.PlayerScreen
import com.ivor.openanime.presentation.search.SearchScreen
import com.ivor.openanime.presentation.watch_history.WatchHistoryScreen
import com.ivor.openanime.presentation.watch_later.WatchLaterScreen
-
+import com.ivor.openanime.presentation.update.UpdateScreen
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -52,24 +52,35 @@ import androidx.compose.runtime.collectAsState
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
-
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
-sealed class Screen(val route: String, val label: String = "", val icon: androidx.compose.ui.graphics.vector.ImageVector? = null) {
+sealed class Screen(
+ val route: String,
+ val label: String = "",
+ val icon: androidx.compose.ui.graphics.vector.ImageVector? = null
+) {
data object Home : Screen("home", "Home", Icons.Default.Home)
data object Search : Screen("search", "Search", Icons.Default.Search)
data object WatchLater : Screen("watch_later", "Saved", Icons.Default.Bookmark)
data object Downloads : Screen("downloads", "Downloads", Icons.Default.Download)
data object History : Screen("history", "History", Icons.Default.History)
+ data object Update : Screen("update")
data object Details : Screen("details/{mediaType}/{animeId}") {
fun createRoute(mediaType: String, animeId: Int) = "details/$mediaType/$animeId"
}
+
data object Player : Screen("player/{mediaType}/{animeId}/{season}/{episode}?downloadId={downloadId}") {
- fun createRoute(mediaType: String, animeId: Int, season: Int, episode: Int, downloadId: String? = null): String {
+ fun createRoute(
+ mediaType: String,
+ animeId: Int,
+ season: Int,
+ episode: Int,
+ downloadId: String? = null
+ ): String {
val base = "player/$mediaType/$animeId/$season/$episode"
return if (downloadId != null) "$base?downloadId=$downloadId" else base
}
@@ -84,7 +95,7 @@ fun AppNavigation(
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
-
+
val bottomNavItems = listOf(Screen.Home, Screen.Search, Screen.WatchLater, Screen.Downloads, Screen.History)
val showBottomBar = currentDestination?.route in bottomNavItems.map { it.route }
val isCompact = windowSizeClass == WindowWidthSizeClass.Compact
@@ -105,7 +116,10 @@ fun AppNavigation(
selected = selected,
onClick = {
if (selected && screen.route == Screen.Search.route) {
- navController.currentBackStackEntry?.savedStateHandle?.set("focusSearch", System.currentTimeMillis())
+ navController.currentBackStackEntry?.savedStateHandle?.set(
+ "focusSearch",
+ System.currentTimeMillis()
+ )
}
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
@@ -130,173 +144,184 @@ fun AppNavigation(
Box(
modifier = Modifier.weight(1f).fillMaxHeight()
) {
- NavHost(
- navController = navController,
- startDestination = Screen.Home.route,
- modifier = Modifier.fillMaxSize()
- ) {
- composable(Screen.Home.route) {
- HomeScreen(
- onAnimeClick = { animeId ->
- navController.navigate(Screen.Details.createRoute("tv", animeId))
- },
- onSearchClick = {},
- onHistoryClick = {}
- )
- }
+ NavHost(
+ navController = navController,
+ startDestination = Screen.Home.route,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ composable(Screen.Home.route) {
+ HomeScreen(
+ onAnimeClick = { animeId ->
+ navController.navigate(Screen.Details.createRoute("tv", animeId))
+ },
+ onSearchClick = {},
+ onHistoryClick = {},
+ onUpdateClick = { navController.navigate(Screen.Update.route) }
+ )
+ }
- composable(Screen.Search.route) { backStackEntry ->
- val focusTrigger = backStackEntry.savedStateHandle.getStateFlow("focusSearch", 0L).collectAsState().value
- SearchScreen(
- onBackClick = { navController.popBackStack() },
- onAnimeClick = { animeId, mediaType ->
- navController.navigate(Screen.Details.createRoute(mediaType, animeId))
- },
- focusTrigger = focusTrigger
- )
- }
+ composable(Screen.Search.route) { backStackEntry ->
+ val focusTrigger =
+ backStackEntry.savedStateHandle.getStateFlow("focusSearch", 0L).collectAsState().value
+ SearchScreen(
+ onBackClick = { navController.popBackStack() },
+ onAnimeClick = { animeId, mediaType ->
+ navController.navigate(Screen.Details.createRoute(mediaType, animeId))
+ },
+ focusTrigger = focusTrigger
+ )
+ }
- composable(Screen.WatchLater.route) {
- WatchLaterScreen(
- onBackClick = { navController.popBackStack() },
- onAnimeClick = { animeId, mediaType ->
- navController.navigate(Screen.Details.createRoute(mediaType, animeId))
- }
- )
- }
+ composable(Screen.WatchLater.route) {
+ WatchLaterScreen(
+ onBackClick = { navController.popBackStack() },
+ onAnimeClick = { animeId, mediaType ->
+ navController.navigate(Screen.Details.createRoute(mediaType, animeId))
+ }
+ )
+ }
- composable(Screen.Downloads.route) {
- DownloadsScreen(
- onBackClick = { navController.popBackStack() },
- onDownloadClick = { download ->
- navController.navigate(
- Screen.Player.createRoute(
- mediaType = download.mediaType,
- animeId = download.tmdbId,
- season = download.season,
- episode = download.episode,
- downloadId = download.downloadId
+ composable(Screen.Downloads.route) {
+ DownloadsScreen(
+ onBackClick = { navController.popBackStack() },
+ onDownloadClick = { download ->
+ navController.navigate(
+ Screen.Player.createRoute(
+ mediaType = download.mediaType,
+ animeId = download.tmdbId,
+ season = download.season,
+ episode = download.episode,
+ downloadId = download.downloadId
+ )
)
- )
- }
- )
- }
+ }
+ )
+ }
- composable(Screen.History.route) {
- WatchHistoryScreen(
- onBackClick = { navController.popBackStack() },
- onAnimeClick = { animeId, mediaType ->
- navController.navigate(Screen.Details.createRoute(mediaType, animeId))
- }
- )
- }
-
- composable(
- route = Screen.Details.route,
- arguments = listOf(
- navArgument("mediaType") { type = NavType.StringType },
- navArgument("animeId") { type = NavType.IntType }
- )
- ) { backStackEntry ->
- val mediaType = backStackEntry.arguments?.getString("mediaType") ?: "tv"
- val animeId = backStackEntry.arguments?.getInt("animeId") ?: return@composable
- DetailsScreen(
- mediaType = mediaType,
- onBackClick = { navController.popBackStack() },
- onPlayClick = { season, episode ->
- navController.navigate(Screen.Player.createRoute(mediaType, animeId, season, episode))
- }
- )
- }
+ composable(Screen.History.route) {
+ WatchHistoryScreen(
+ onBackClick = { navController.popBackStack() },
+ onAnimeClick = { animeId, mediaType ->
+ navController.navigate(Screen.Details.createRoute(mediaType, animeId))
+ }
+ )
+ }
- composable(
- route = Screen.Player.route,
- arguments = listOf(
- navArgument("mediaType") { type = NavType.StringType },
- navArgument("animeId") { type = NavType.IntType },
- navArgument("season") { type = NavType.IntType },
- navArgument("episode") { type = NavType.IntType },
- navArgument("downloadId") {
- type = NavType.StringType
- nullable = true
- defaultValue = null
- }
- )
- ) { backStackEntry ->
- val mediaType = backStackEntry.arguments?.getString("mediaType") ?: "tv"
- val animeId = backStackEntry.arguments?.getInt("animeId") ?: return@composable
- val season = backStackEntry.arguments?.getInt("season") ?: return@composable
- val episode = backStackEntry.arguments?.getInt("episode") ?: return@composable
- val downloadId = backStackEntry.arguments?.getString("downloadId")
-
- PlayerScreen(
- mediaType = mediaType,
- tmdbId = animeId,
- season = season,
- episode = episode,
- downloadId = downloadId,
- onBackClick = { navController.popBackStack() },
- onEpisodeClick = { newEpisode ->
- navController.navigate(Screen.Player.createRoute(mediaType, animeId, season, newEpisode)) {
- popUpTo(Screen.Player.route) { inclusive = true }
+ composable(Screen.Update.route) {
+ UpdateScreen(
+ onBackClick = { navController.popBackStack() }
+ )
+ }
+
+ composable(
+ route = Screen.Details.route,
+ arguments = listOf(
+ navArgument("mediaType") { type = NavType.StringType },
+ navArgument("animeId") { type = NavType.IntType }
+ )
+ ) { backStackEntry ->
+ val mediaType = backStackEntry.arguments?.getString("mediaType") ?: "tv"
+ val animeId = backStackEntry.arguments?.getInt("animeId") ?: return@composable
+ DetailsScreen(
+ mediaType = mediaType,
+ onBackClick = { navController.popBackStack() },
+ onPlayClick = { season, episode ->
+ navController.navigate(Screen.Player.createRoute(mediaType, animeId, season, episode))
}
- }
- )
+ )
+ }
+
+ composable(
+ route = Screen.Player.route,
+ arguments = listOf(
+ navArgument("mediaType") { type = NavType.StringType },
+ navArgument("animeId") { type = NavType.IntType },
+ navArgument("season") { type = NavType.IntType },
+ navArgument("episode") { type = NavType.IntType },
+ navArgument("downloadId") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ }
+ )
+ ) { backStackEntry ->
+ val mediaType = backStackEntry.arguments?.getString("mediaType") ?: "tv"
+ val animeId = backStackEntry.arguments?.getInt("animeId") ?: return@composable
+ val season = backStackEntry.arguments?.getInt("season") ?: return@composable
+ val episode = backStackEntry.arguments?.getInt("episode") ?: return@composable
+ val downloadId = backStackEntry.arguments?.getString("downloadId")
+
+ PlayerScreen(
+ mediaType = mediaType,
+ tmdbId = animeId,
+ season = season,
+ episode = episode,
+ downloadId = downloadId,
+ onBackClick = { navController.popBackStack() },
+ onEpisodeClick = { newEpisode ->
+ navController.navigate(Screen.Player.createRoute(mediaType, animeId, season, newEpisode)) {
+ popUpTo(Screen.Player.route) { inclusive = true }
+ }
+ }
+ )
+ }
}
- }
- // Expressive Floating Navigation for Mobile (Compact screens)
- androidx.compose.animation.AnimatedVisibility(
- visible = showBottomBar && isCompact,
- enter = slideInVertically { it } + fadeIn(),
- exit = slideOutVertically { it } + fadeOut(),
- modifier = Modifier.align(Alignment.BottomCenter)
- ) {
- HorizontalFloatingToolbar(
- expanded = true,
- modifier = Modifier.padding(bottom = 50.dp),
- content = {
- bottomNavItems.forEach { screen ->
- val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true
-
- if (selected) {
- FilledIconButton(
- onClick = {
- if (screen.route == Screen.Search.route) {
- navController.currentBackStackEntry?.savedStateHandle?.set("focusSearch", System.currentTimeMillis())
- }
- navController.navigate(screen.route) {
- popUpTo(navController.graph.findStartDestination().id) {
- saveState = true
+ // Expressive Floating Navigation for Mobile (Compact screens)
+ androidx.compose.animation.AnimatedVisibility(
+ visible = showBottomBar && isCompact,
+ enter = slideInVertically { it } + fadeIn(),
+ exit = slideOutVertically { it } + fadeOut(),
+ modifier = Modifier.align(Alignment.BottomCenter)
+ ) {
+ HorizontalFloatingToolbar(
+ expanded = true,
+ modifier = Modifier.padding(bottom = 50.dp),
+ content = {
+ bottomNavItems.forEach { screen ->
+ val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true
+
+ if (selected) {
+ FilledIconButton(
+ onClick = {
+ if (screen.route == Screen.Search.route) {
+ navController.currentBackStackEntry?.savedStateHandle?.set(
+ "focusSearch",
+ System.currentTimeMillis()
+ )
}
- launchSingleTop = true
- restoreState = true
- }
- },
- modifier = Modifier.size(50.dp)
- ) {
- Icon(screen.icon!!, contentDescription = screen.label)
- }
- } else {
- IconButton(
- onClick = {
- navController.navigate(screen.route) {
- popUpTo(navController.graph.findStartDestination().id) {
- saveState = true
+ navController.navigate(screen.route) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ modifier = Modifier.size(50.dp)
+ ) {
+ Icon(screen.icon!!, contentDescription = screen.label)
+ }
+ } else {
+ IconButton(
+ onClick = {
+ navController.navigate(screen.route) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
}
- launchSingleTop = true
- restoreState = true
- }
- },
- modifier = Modifier.size(50.dp)
- ) {
- Icon(screen.icon!!, contentDescription = screen.label)
+ },
+ modifier = Modifier.size(50.dp)
+ ) {
+ Icon(screen.icon!!, contentDescription = screen.label)
+ }
}
}
}
- }
- )
+ )
+ }
}
}
}
-}
diff --git a/app/src/main/java/com/ivor/openanime/presentation/player/PlayerScreen.kt b/app/src/main/java/com/ivor/openanime/presentation/player/PlayerScreen.kt
index 2a712d1..9e1492d 100644
--- a/app/src/main/java/com/ivor/openanime/presentation/player/PlayerScreen.kt
+++ b/app/src/main/java/com/ivor/openanime/presentation/player/PlayerScreen.kt
@@ -21,6 +21,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -46,7 +47,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
@@ -54,13 +54,18 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedIconButton
+import androidx.compose.material3.Surface
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
@@ -77,10 +82,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -162,12 +172,10 @@ fun PlayerScreen(
} else null
// Dynamic Title for Player HUD
- val currentTitle = if (mediaType == "movie") {
- mediaDetails?.name ?: "Movie"
- } else {
- val showName = mediaDetails?.name ?: "Show"
- val epName = currentEpisode?.name ?: "Episode $episode"
- "$showName - S$season:E$episode $epName"
+ val playerTitle = if (mediaType == "movie") mediaDetails?.name ?: "Movie" else mediaDetails?.name ?: "Show"
+ val playerSubtitle = if (mediaType == "movie") "" else {
+ val epName = currentEpisode?.name ?: "Episode $episode"
+ "S$season:E$episode • $epName"
}
// Fullscreen management
@@ -262,7 +270,8 @@ fun PlayerScreen(
if (hasUrl) {
ExoPlayerView(
videoUrl = videoUrl!!,
- title = currentTitle,
+ title = playerTitle,
+ subtitle = playerSubtitle,
dataSourceFactory = viewModel.dataSourceFactory,
isFullscreen = isFullscreen,
onFullscreenToggle = {
@@ -384,36 +393,94 @@ fun PlayerScreen(
.alpha(0f)
)
- // Loading Overlay
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(Color.Black)
- ) {
- // Back button visible during loading
- Box(modifier = Modifier
- .align(Alignment.TopStart)
- .windowInsetsPadding(WindowInsets.statusBars)
- .padding(16.dp)
- ) {
- ExpressiveBackButton(
- onClick = onBackClick,
- containerColor = Color.Black.copy(alpha = 0.5f),
- contentColor = Color.White
- )
- }
-
- Column(
- modifier = Modifier.align(Alignment.Center),
- horizontalAlignment = Alignment.CenterHorizontally
+ // Extraction / Loading Overlay (Cinematic)
+ AnimatedContent(
+ targetState = true,
+ label = "LoadingOverlay"
+ ) { _ ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black)
) {
- LoadingIndicator()
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- text = "Extracting Stream...",
- color = Color.White,
- style = MaterialTheme.typography.titleMedium
- )
+ // Blurred Backdrop
+ val backdropPath = mediaDetails?.backdropPath
+ if (backdropPath != null) {
+ AsyncImage(
+ model = "https://image.tmdb.org/t/p/w1280$backdropPath",
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxSize()
+ .blur(20.dp)
+ .drawWithContent {
+ drawContent()
+ drawRect(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ Color.Black.copy(alpha = 0.5f),
+ Color.Black.copy(alpha = 0.8f)
+ )
+ ),
+ blendMode = BlendMode.SrcOver
+ )
+ },
+ contentScale = ContentScale.Crop,
+ alpha = 0.7f
+ )
+ }
+
+ // Centered Loading Content
+ Column(
+ modifier = Modifier.align(Alignment.Center),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ LoadingIndicator(
+ modifier = Modifier.size(64.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = playerTitle,
+ color = Color.White,
+ style = MaterialTheme.typography.displaySmall.copy(
+ fontWeight = FontWeight.Black
+ ),
+ textAlign = TextAlign.Center
+ )
+
+ if (playerSubtitle.isNotEmpty()) {
+ Text(
+ text = playerSubtitle,
+ color = Color.White.copy(alpha = 0.7f),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Text(
+ text = "Step 1: Extracting Stream...",
+ color = Color.White.copy(alpha = 0.5f),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+
+ // Back button
+ Box(modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(if (isFullscreen) 16.dp else 12.dp)
+ ) {
+ ExpressiveBackButton(
+ onClick = onBackClick,
+ containerColor = Color.White.copy(alpha = 0.1f),
+ contentColor = Color.White
+ )
+ }
}
}
}
@@ -433,51 +500,77 @@ fun PlayerScreen(
modifier = Modifier
.fillMaxSize()
) {
- // Title and Detailed Description
+ // Editorial Header
item {
- Column(modifier = Modifier.padding(16.dp)) {
- // Show Title (if TV) or Movie Tagline
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 24.dp, vertical = 32.dp)
+ .fillMaxWidth()
+ ) {
+ // Media Type / Series Context
if (mediaType != "movie") {
Text(
- text = mediaDetails?.name ?: "TV Show",
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.primary
+ text = (mediaDetails?.name ?: "Series").uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ letterSpacing = 2.sp,
+ fontWeight = FontWeight.Bold
)
- Spacer(modifier = Modifier.height(4.dp))
+ Spacer(modifier = Modifier.height(8.dp))
}
-
- // Main Title (Episode Name or Movie Name)
+
+ // Main Title (Display Grade)
Text(
- text = if (mediaType == "movie") mediaDetails?.name ?: "Movie"
- else currentEpisode?.name ?: "Episode $episode",
- style = MaterialTheme.typography.headlineSmall, // Expressive
+ text = if (mediaType == "movie") playerTitle else currentEpisode?.name ?: "Episode $episode",
+ style = MaterialTheme.typography.displaySmall,
+ fontWeight = FontWeight.Black,
color = MaterialTheme.colorScheme.onBackground,
- fontWeight = FontWeight.Bold
+ lineHeight = 40.sp
)
-
- Spacer(modifier = Modifier.height(8.dp))
-
- // Metadata Row
- Row(verticalAlignment = Alignment.CenterVertically) {
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ // Metadata Chips Row
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // S:E pill
if (mediaType != "movie") {
- Text(
- text = "S$season • E$episode",
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurface
- )
- Spacer(modifier = Modifier.width(12.dp))
+ Surface(
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ shape = ExpressiveShapes.small
+ ) {
+ Text(
+ text = "S$season : E$episode",
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
}
-
+
+ // Rating
val rating = if (mediaType == "movie") mediaDetails?.voteAverage else currentEpisode?.voteAverage
if (rating != null) {
- Text(
- text = "★ ${String.format("%.1f", rating)}",
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Spacer(modifier = Modifier.width(12.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.Star,
+ contentDescription = null,
+ tint = Color(0xFFFFB800),
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = String.format("%.1f", rating),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold
+ )
+ }
}
-
+
+ // Year
val date = if (mediaType == "movie") mediaDetails?.date else currentEpisode?.airDate
if (!date.isNullOrEmpty()) {
Text(
@@ -488,100 +581,116 @@ fun PlayerScreen(
}
}
- // Genres Chips
+ // Genres
if (mediaDetails?.genres?.isNotEmpty() == true) {
- Spacer(modifier = Modifier.height(12.dp))
+ Spacer(modifier = Modifier.height(20.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
mediaDetails?.genres?.take(3)?.forEach { genre ->
- SuggestionChip(
- onClick = { /* No-op */ },
- label = { Text(genre.name) },
- shape = ExpressiveShapes.small,
- colors = SuggestionChipDefaults.suggestionChipColors(
- containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ shape = ExpressiveShapes.extraSmall,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
+ ) {
+ Text(
+ text = genre.name,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelMedium
)
- )
+ }
}
}
}
- // Overview (Plot)
+ // Overview (Editorial Style)
val overview = if (mediaType == "movie") mediaDetails?.overview else currentEpisode?.overview
if (!overview.isNullOrEmpty()) {
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(28.dp))
Text(
text = overview,
- style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 24.sp),
- color = MaterialTheme.colorScheme.onSurface
+ style = MaterialTheme.typography.bodyLarge.copy(
+ lineHeight = 28.sp,
+ letterSpacing = 0.2.sp
+ ),
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
+ // Download Action (Expressive Button)
val currentDownload by viewModel.currentDownload.collectAsState()
+ val downloadState = currentDownload?.status
- // Download Status / Button
- if (videoUrl != null || currentDownload != null) {
- Spacer(modifier = Modifier.height(16.dp))
-
- val downloadState = currentDownload?.status
-
- Row(verticalAlignment = Alignment.CenterVertically) {
- if (downloadState == DownloadManager.STATUS_SUCCESSFUL) {
- SuggestionChip(
- onClick = { /* No-op or show delete dialog */ },
- label = { Text("Downloaded") },
- icon = { Icon(Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary) },
- shape = ExpressiveShapes.medium,
- colors = SuggestionChipDefaults.suggestionChipColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
- )
- )
- Spacer(modifier = Modifier.width(8.dp))
- IconButton(onClick = {
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (downloadState == DownloadManager.STATUS_SUCCESSFUL) {
+ Button(
+ onClick = { /* No-op */ },
+ shape = ExpressiveShapes.medium,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ ),
+ modifier = Modifier.height(56.dp)
+ ) {
+ Icon(Icons.Default.CheckCircle, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("Available Offline")
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ OutlinedIconButton(
+ onClick = {
currentDownload?.let { viewModel.removeDownload(it.downloadId) }
- }) {
- Icon(Icons.Default.Delete, contentDescription = "Delete Download", tint = MaterialTheme.colorScheme.error)
+ },
+ modifier = Modifier.size(56.dp),
+ shape = ExpressiveShapes.medium
+ ) {
+ Icon(Icons.Default.Delete, contentDescription = "Delete Download", tint = MaterialTheme.colorScheme.error)
+ }
+ } else if (downloadState == DownloadManager.STATUS_RUNNING || downloadState == DownloadManager.STATUS_PENDING) {
+ OutlinedButton(
+ onClick = {
+ currentDownload?.let { viewModel.removeDownload(it.downloadId) }
+ },
+ shape = ExpressiveShapes.medium,
+ modifier = Modifier.height(56.dp)
+ ) {
+ if (downloadState == DownloadManager.STATUS_RUNNING) {
+ androidx.compose.material3.CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 3.dp,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("${currentDownload?.progress}%")
+ } else {
+ Icon(Icons.Default.Schedule, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("Queued")
}
- } else if (downloadState == DownloadManager.STATUS_RUNNING || downloadState == DownloadManager.STATUS_PENDING) {
- SuggestionChip(
- onClick = {
- currentDownload?.let { viewModel.removeDownload(it.downloadId) }
- },
- label = { Text(if (downloadState == DownloadManager.STATUS_RUNNING) "${currentDownload?.progress}%" else "Queued") },
- icon = {
- if (downloadState == DownloadManager.STATUS_RUNNING) {
- androidx.compose.material3.CircularProgressIndicator(
- modifier = Modifier.size(16.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.onSurface
- )
- } else {
- Icon(Icons.Default.Schedule, contentDescription = null)
- }
- },
- shape = ExpressiveShapes.medium
- )
- } else {
- SuggestionChip(
- onClick = {
- if (videoUrl != null) {
- val fileName = "${currentTitle.replace(Regex("[^a-zA-Z0-9.-]"), "_")}_$tmdbId.mp4"
- viewModel.downloadVideo(videoUrl!!, currentTitle, fileName, mediaType, tmdbId, season, episode)
- }
- },
- label = { Text("Download") },
- icon = { Icon(Icons.Default.Download, contentDescription = null) },
- shape = ExpressiveShapes.medium,
- enabled = videoUrl != null
- )
+ }
+ } else {
+ Button(
+ onClick = {
+ if (videoUrl != null) {
+ val fileName = "${playerTitle.replace(Regex("[^a-zA-Z0-9.-]"), "_")}_$tmdbId.mp4"
+ viewModel.downloadVideo(videoUrl!!, playerTitle, fileName, mediaType, tmdbId, season, episode)
+ }
+ },
+ enabled = videoUrl != null,
+ shape = ExpressiveShapes.medium,
+ modifier = Modifier.height(56.dp)
+ ) {
+ Icon(Icons.Default.Download, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("Download Details")
}
}
}
-
- Spacer(modifier = Modifier.height(16.dp))
- HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
}
@@ -590,9 +699,10 @@ fun PlayerScreen(
item {
Text(
text = "Up Next",
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- color = MaterialTheme.colorScheme.onBackground
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
+ color = MaterialTheme.colorScheme.onBackground,
+ fontWeight = FontWeight.Black
)
}
}
@@ -600,7 +710,7 @@ fun PlayerScreen(
if (isLoadingEpisodes) {
item {
Box(
- modifier = Modifier.fillMaxWidth().padding(16.dp),
+ modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
@@ -609,64 +719,93 @@ fun PlayerScreen(
}
items(nextEpisodes) { ep ->
- ListItem(
- headlineContent = {
- Text(
- text = ep.name,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ Surface(
+ onClick = {
+ videoUrl = null
+ sniffedSubtitles = emptyList()
+ onEpisodeClick(ep.episodeNumber)
},
- supportingContent = {
- Text(
- text = "Episode ${ep.episodeNumber} • ${ep.runtime ?: "?"}m",
- style = MaterialTheme.typography.bodySmall
- )
- },
- leadingContent = {
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 8.dp),
+ shape = ExpressiveShapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(12.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // High Quality Thumbnail
Box(
modifier = Modifier
- .width(120.dp)
- .height(68.dp)
- .clip(ExpressiveShapes.small) // Expressive Shape
- .background(MaterialTheme.colorScheme.surfaceContainerHighest),
- contentAlignment = Alignment.Center
+ .width(140.dp)
+ .height(80.dp)
+ .clip(ExpressiveShapes.small)
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest)
) {
- if (ep.stillPath != null) {
- AsyncImage(
- model = "https://image.tmdb.org/t/p/w500${ep.stillPath}",
- contentDescription = null,
- contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize()
- )
- }
-
- // Play icon overlay
+ AsyncImage(
+ model = "https://image.tmdb.org/t/p/w500${ep.stillPath}",
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ alpha = 0.9f
+ )
+
+ // Episode Number Badge
Box(
modifier = Modifier
- .fillMaxSize()
- .background(Color.Black.copy(alpha = 0.3f)),
- contentAlignment = Alignment.Center
+ .align(Alignment.BottomStart)
+ .padding(8.dp)
+ .background(
+ MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.8f),
+ ExpressiveShapes.extraSmall
+ )
+ .padding(horizontal = 6.dp, vertical = 2.dp)
) {
- Icon(
- imageVector = Icons.Default.PlayArrow,
- contentDescription = null,
- tint = Color.White
+ Text(
+ text = "EP ${ep.episodeNumber}",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
)
}
}
- },
- modifier = Modifier
- .fillMaxWidth()
- .clickable {
- // Reset state for new episode
- videoUrl = null
- sniffedSubtitles = emptyList()
- onEpisodeClick(ep.episodeNumber)
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // Metadata Column
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = ep.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "${ep.runtime ?: "?"} minutes",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
- )
+
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = null,
+ modifier = Modifier.padding(8.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+
+ // Bottom Padding for FAB or spacing
+ item {
+ Spacer(modifier = Modifier.height(32.dp))
}
}
}
diff --git a/app/src/main/java/com/ivor/openanime/presentation/player/components/ExoPlayerView.kt b/app/src/main/java/com/ivor/openanime/presentation/player/components/ExoPlayerView.kt
index 5f9d5e0..c445f1b 100644
--- a/app/src/main/java/com/ivor/openanime/presentation/player/components/ExoPlayerView.kt
+++ b/app/src/main/java/com/ivor/openanime/presentation/player/components/ExoPlayerView.kt
@@ -50,13 +50,36 @@ import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
-import androidx.media3.ui.PlayerView
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.zIndex
+import androidx.compose.ui.unit.IntOffset
+import com.ivor.openanime.ui.theme.ExpressiveShapes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.net.URL
+import kotlin.math.roundToInt
+import androidx.media3.ui.PlayerView
+import androidx.compose.runtime.mutableIntStateOf
@OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -70,9 +93,39 @@ fun ExoPlayerView(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
remoteSubtitles: List = emptyList(),
+ subtitle: String = "",
onNextClick: (() -> Unit)? = null
) {
val context = LocalContext.current
+ val activity = remember(context) {
+ var ctx = context
+ while (ctx is android.content.ContextWrapper) {
+ if (ctx is android.app.Activity) break
+ ctx = ctx.baseContext
+ }
+ ctx as? android.app.Activity
+ }
+
+ val trackSelector = remember {
+ DefaultTrackSelector(context).apply {
+ parameters = buildUponParameters()
+ .setPreferredTextLanguage("en")
+ .setSelectUndeterminedTextLanguage(true)
+ .build()
+ }
+ }
+
+ val exoPlayer = remember {
+ val mediaSourceFactory = DefaultMediaSourceFactory(context)
+ .setDataSourceFactory(dataSourceFactory)
+
+ ExoPlayer.Builder(context)
+ .setMediaSourceFactory(mediaSourceFactory)
+ .setTrackSelector(trackSelector)
+ .build().apply {
+ playWhenReady = true
+ }
+ }
// Player State
var isPlaying by remember { mutableStateOf(true) }
@@ -97,6 +150,13 @@ fun ExoPlayerView(
var manualCues by remember { mutableStateOf>(emptyList()) }
var subtitleLoadingState by remember { mutableStateOf(SubtitleLoadingState.IDLE) }
+ // Gesture State
+ var brightness by remember { mutableFloatStateOf(1.0f) } // 0.0 to 1.0
+ var volume by remember { mutableFloatStateOf(exoPlayer.volume) }
+ var showBrightnessOverlay by remember { mutableStateOf(false) }
+ var showVolumeOverlay by remember { mutableStateOf(false) }
+ var gestureOverlayTimeout by remember { mutableLongStateOf(0L) }
+
// Reset subtitle state when switching videos
LaunchedEffect(videoUrl) {
selectedSubtitle = null
@@ -145,26 +205,7 @@ fun ExoPlayerView(
}
}
- val trackSelector = remember {
- DefaultTrackSelector(context).apply {
- parameters = buildUponParameters()
- .setPreferredTextLanguage("en")
- .setSelectUndeterminedTextLanguage(true)
- .build()
- }
- }
- val exoPlayer = remember {
- val mediaSourceFactory = DefaultMediaSourceFactory(context)
- .setDataSourceFactory(dataSourceFactory)
-
- ExoPlayer.Builder(context)
- .setMediaSourceFactory(mediaSourceFactory)
- .setTrackSelector(trackSelector)
- .build().apply {
- playWhenReady = true
- }
- }
// Helper: parse available tracks from ExoPlayer
fun parseTracksFromPlayer(tracks: Tracks) {
@@ -261,7 +302,7 @@ fun ExoPlayerView(
}
// Auto-select extracted subtitle if none selected
- if (selectedSubtitle == null || selectedSubtitle?.isDisabled == true) {
+ if (selectedSubtitle == null) {
val extracted = subtitles.find { it.label == "English (Extracted)" }
if (extracted != null) {
Log.i("PlayerSubtitles", "Auto-selecting extracted subtitle: ${extracted.label}")
@@ -421,30 +462,112 @@ fun ExoPlayerView(
}
}
- Box(modifier = modifier.fillMaxSize()) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onTap = { areControlsVisible = !areControlsVisible },
+ onDoubleTap = { offset ->
+ val isForward = offset.x > size.width / 2
+ if (isForward) {
+ exoPlayer.seekTo(exoPlayer.currentPosition + 10000)
+ } else {
+ exoPlayer.seekTo(exoPlayer.currentPosition - 10000)
+ }
+ currentTime = exoPlayer.currentPosition
+ areControlsVisible = true
+ }
+ )
+ }
+ .pointerInput(Unit) {
+ detectVerticalDragGestures(
+ onDragStart = { },
+ onDragEnd = {
+ showBrightnessOverlay = false
+ showVolumeOverlay = false
+ },
+ onDragCancel = {
+ showBrightnessOverlay = false
+ showVolumeOverlay = false
+ },
+ onVerticalDrag = { change, dragAmount ->
+ change.consume()
+ val isLeft = change.position.x < size.width / 2
+ val delta = -dragAmount / size.height // Swipe up to increase
+
+ if (isLeft) {
+ brightness = (brightness + delta).coerceIn(0f, 1f)
+ showBrightnessOverlay = true
+ showVolumeOverlay = false
+ activity?.let { act ->
+ val params = act.window.attributes
+ params.screenBrightness = brightness
+ act.window.setAttributes(params)
+ }
+ } else {
+ volume = (volume + delta).coerceIn(0f, 1f)
+ exoPlayer.volume = volume
+ showVolumeOverlay = true
+ showBrightnessOverlay = false
+ }
+ gestureOverlayTimeout = System.currentTimeMillis() + 2000
+ }
+ )
+ }
+ ) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT,
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT
)
useController = false
- // Disable PlayerView's own subtitle rendering since we render in Compose
subtitleView?.visibility = android.view.View.GONE
}
},
- modifier = Modifier
- .fillMaxSize()
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null
- ) {
- areControlsVisible = !areControlsVisible
- }
+ modifier = Modifier.fillMaxSize()
)
+ // Gesture Overlays
+ androidx.compose.animation.AnimatedVisibility(
+ visible = showBrightnessOverlay,
+ enter = scaleIn() + fadeIn(),
+ exit = scaleOut() + fadeOut(),
+ modifier = Modifier.align(Alignment.Center)
+ ) {
+ GestureIndicator(
+ icon = Icons.Default.BrightnessLow,
+ value = (brightness * 100).toInt(),
+ label = "Brightness"
+ )
+ }
+
+ androidx.compose.animation.AnimatedVisibility(
+ visible = showVolumeOverlay,
+ enter = scaleIn() + fadeIn(),
+ exit = scaleOut() + fadeOut(),
+ modifier = Modifier.align(Alignment.Center)
+ ) {
+ GestureIndicator(
+ icon = Icons.Default.VolumeUp,
+ value = (volume * 100).toInt(),
+ label = "Volume"
+ )
+ }
+
+ // Auto-hide gesture overlays
+ LaunchedEffect(gestureOverlayTimeout) {
+ if (gestureOverlayTimeout > 0) {
+ delay(2000)
+ showBrightnessOverlay = false
+ showVolumeOverlay = false
+ gestureOverlayTimeout = 0
+ }
+ }
+
// Compose-rendered subtitles -- always on top of video, below controls
val displaySubtitleText = remember(currentTime, currentSubtitleText, manualCues) {
if (manualCues.isNotEmpty()) {
@@ -489,6 +612,7 @@ fun ExoPlayerView(
isPlaying = isPlaying,
isBuffering = isBuffering,
title = title,
+ subtitle = subtitle,
currentTime = currentTime,
totalTime = totalTime,
onPauseToggle = {
@@ -622,15 +746,6 @@ fun ExoPlayerView(
}
}
-enum class SubtitleLoadingState {
- IDLE,
- LOADING,
- SUCCESS,
- ERROR
-}
-
-data class SubtitleCue(val startMs: Long, val endMs: Long, val text: String)
-
private fun parseSubtitles(content: String): List {
val cues = mutableListOf()
val cleanContent = content.replace("\ufeff", "").replace("\r\n", "\n").replace("\r", "\n")
@@ -699,6 +814,48 @@ private fun parseSubtitles(content: String): List {
return cues
}
+@Composable
+private fun GestureIndicator(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ value: Int,
+ label: String
+) {
+ Surface(
+ color = Color.Black.copy(alpha = 0.5f),
+ shape = ExpressiveShapes.extraLarge,
+ modifier = Modifier
+ .size(140.dp)
+ .padding(16.dp)
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(48.dp)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "$value%",
+ style = MaterialTheme.typography.titleLarge,
+ color = Color.White,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White.copy(alpha = 0.7f)
+ )
+ }
+ }
+}
+
+private data class SubtitleCue(val startMs: Long, val endMs: Long, val text: String)
+
private fun parseAssTimestamp(ts: String): Long {
try {
val parts = ts.trim().split(':')
diff --git a/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerControls.kt b/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerControls.kt
index 5682522..fa39fb7 100644
--- a/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerControls.kt
+++ b/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerControls.kt
@@ -1,15 +1,16 @@
package com.ivor.openanime.presentation.player.components
+import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
import com.ivor.openanime.presentation.components.ExpressiveBackButton
import com.ivor.openanime.ui.theme.ExpressiveShapes
import androidx.compose.foundation.background
@@ -18,48 +19,62 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
-import androidx.compose.material.icons.filled.Settings
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
+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.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
// Expressive Motion Tokens
-private val ExpressiveDefaultSpatial = CubicBezierEasing(0.38f, 1.21f, 0.22f, 1.00f)
private val ExpressiveDefaultEffects = CubicBezierEasing(0.34f, 0.80f, 0.34f, 1.00f)
-private const val DurationSpatialDefault = 500
private const val DurationEffectsDefault = 200
-@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
+// Scrim gradient that fades over 120dp so artwork breathes between top and bottom zones
+private val TopScrimColors = listOf(Color.Black.copy(alpha = 0.75f), Color.Transparent)
+private val BottomScrimColors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))
+
+@OptIn(
+ androidx.compose.material3.ExperimentalMaterial3Api::class,
+ ExperimentalMaterial3ExpressiveApi::class
+)
@Composable
fun PlayerControls(
modifier: Modifier = Modifier,
@@ -68,6 +83,7 @@ fun PlayerControls(
isBuffering: Boolean = false,
isFullscreen: Boolean = false,
title: String,
+ subtitle: String = "",
currentTime: Long,
totalTime: Long,
onPauseToggle: () -> Unit,
@@ -80,9 +96,8 @@ fun PlayerControls(
onBackClick: () -> Unit
) {
val duration = if (totalTime > 0) totalTime else 0L
-
- // Local state for dragging the slider
- var isDragging by remember { androidx.compose.runtime.mutableStateOf(false) }
+
+ var isDragging by remember { mutableStateOf(false) }
var dragProgress by remember { mutableFloatStateOf(0f) }
val currentProgress = if (duration > 0) currentTime.toFloat() / duration.toFloat() else 0f
@@ -94,225 +109,213 @@ fun PlayerControls(
exit = fadeOut(tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects)),
modifier = modifier
) {
- Box(
- modifier = Modifier
- .background(Color.Black.copy(alpha = 0.4f))
- .fillMaxSize()
- ) {
- // Top Bar (Title and Options) - Slides from Top
+ Box(modifier = Modifier.fillMaxSize()) {
+
+ // ── Top Bar: Back · Title · Settings · Fullscreen ───────────────
Box(
modifier = Modifier
- .fillMaxWidth()
.align(Alignment.TopCenter)
- .animateEnterExit(
- enter = slideInVertically(tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial)) { -it },
- exit = slideOutVertically(tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial)) { -it }
- )
- .background(
- Brush.verticalGradient(
- colors = listOf(
- Color.Black.copy(alpha = 0.8f),
- Color.Transparent
- )
- )
- )
- .padding(if (isFullscreen) 16.dp else 4.dp)
+ .fillMaxWidth()
+ .background(Brush.verticalGradient(TopScrimColors))
+ .windowInsetsPadding(WindowInsets.statusBars)
+ .padding(horizontal = 4.dp, vertical = 8.dp)
) {
Row(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- ExpressiveBackButton(
- onClick = onBackClick,
- containerColor = Color.White.copy(alpha = 0.1f), // Subtle background
- contentColor = Color.White
- )
+ ExpressiveBackButton(onClick = onBackClick)
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = Color.White,
- modifier = Modifier.padding(start = 12.dp)
- )
- }
-
- IconButton(
- onClick = onSettingsClick,
- colors = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
- ) {
- Icon(
- imageVector = Icons.Filled.Settings,
- contentDescription = "Settings"
- )
- }
- }
- }
-
- // Center Controls (Play/Pause, Rewind, Forward) -- Scale In
- if (!isBuffering) {
- val centerIconSize = if (isFullscreen) 80.dp else 64.dp // Larger
- val sideIconSize = if (isFullscreen) 56.dp else 48.dp
- val iconSize = if (isFullscreen) 40.dp else 32.dp
- val spacing = if (isFullscreen) 48.dp else 32.dp
-
- Row(
- modifier = Modifier.align(Alignment.Center)
- .animateEnterExit(
- enter = scaleIn(tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects)) + fadeIn(tween(DurationEffectsDefault)),
- exit = scaleOut(tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects)) + fadeOut(tween(DurationEffectsDefault))
- ),
- horizontalArrangement = Arrangement.spacedBy(spacing),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Rewind
- IconButton(
- onClick = onRewind,
- modifier = Modifier
- .size(sideIconSize)
- .background(Color.White.copy(alpha = 0.1f), ExpressiveShapes.medium)
- ) {
- Icon(
- imageVector = Icons.Default.FastRewind,
- contentDescription = "Rewind 10s",
- tint = Color.White,
- modifier = Modifier.size(24.dp)
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
)
+ if (subtitle.isNotEmpty()) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White.copy(alpha = 0.7f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
}
- // Play/Pause - Prominent and Shaped
- IconButton(
- onClick = onPauseToggle,
- modifier = Modifier
- .size(centerIconSize)
- .background(
- color = MaterialTheme.colorScheme.primaryContainer,
- shape = ExpressiveShapes.large // Squircle
- )
- ) {
+ // Settings — utility action lives in top bar
+ IconButton(onClick = onSettingsClick) {
Icon(
- imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
- contentDescription = if (isPlaying) "Pause" else "Play",
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
- modifier = Modifier.size(iconSize)
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings",
+ tint = Color.White
)
}
- // Forward
- IconButton(
- onClick = onForward,
- modifier = Modifier
- .size(sideIconSize)
- .background(Color.White.copy(alpha = 0.1f), ExpressiveShapes.medium)
- ) {
+ // Fullscreen toggle — utility, not a core playback control
+ IconButton(onClick = onFullscreenToggle) {
Icon(
- imageVector = Icons.Default.FastForward,
- contentDescription = "Forward 10s",
- tint = Color.White,
- modifier = Modifier.size(24.dp)
+ imageVector = if (isFullscreen) Icons.Default.FullscreenExit
+ else Icons.Default.Fullscreen,
+ contentDescription = if (isFullscreen) "Exit fullscreen" else "Fullscreen",
+ tint = Color.White
)
}
}
-
- // Extra Next Button in Row? No, user prefers Overlay Button.
}
- // "Next Episode" Button Overlay (Bottom Right, above seekbar) - Slides in
- if (onNextClick != null && !isBuffering) {
- Box(
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(bottom = 100.dp, end = 24.dp) // Adjusted padding
- .animateEnterExit(
- enter = slideInVertically(tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial)) { it / 2 } + fadeIn(),
- exit = slideOutVertically(tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial)) { it / 2 } + fadeOut()
- )
- ) {
- Button(
- onClick = onNextClick,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.tertiaryContainer,
- contentColor = MaterialTheme.colorScheme.onTertiaryContainer
- ),
- shape = ExpressiveShapes.small
- ) {
- Text("Next Episode", style = MaterialTheme.typography.labelLarge)
- Spacer(modifier = Modifier.width(8.dp))
- Icon(Icons.Default.SkipNext, contentDescription = null, modifier = Modifier.size(16.dp))
- }
- }
+ // ── Centre: Buffering indicator ──────────────────────────────────
+ if (isBuffering) {
+ androidx.compose.material3.LoadingIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
}
- // Bottom Controls (Seekbar, Time, Fullscreen) - Slides from Bottom
- Column(
+ // ── Bottom Bar: Scrubber · Playback controls ──────────────────────
+ Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
- .animateEnterExit(
- enter = slideInVertically(tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial)) { it },
- exit = slideOutVertically(tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial)) { it }
- )
- .background(
- Brush.verticalGradient(
- colors = listOf(
- Color.Transparent,
- Color.Black.copy(alpha = 0.9f)
- )
- )
- )
- .padding(
- start = if (isFullscreen) 24.dp else 16.dp,
- end = if (isFullscreen) 24.dp else 16.dp,
- top = if (isFullscreen) 16.dp else 8.dp,
- bottom = if (isFullscreen) 16.dp else 12.dp
- )
+ .background(Brush.verticalGradient(BottomScrimColors))
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ .padding(horizontal = 20.dp, vertical = 16.dp)
) {
- Slider(
- value = sliderValue,
- onValueChange = {
- isDragging = true
- dragProgress = it
- },
- onValueChangeFinished = {
- isDragging = false
- onSeek((dragProgress * duration).toLong())
- },
+ Column(
modifier = Modifier.fillMaxWidth(),
- colors = SliderDefaults.colors(
- thumbColor = MaterialTheme.colorScheme.primary,
- activeTrackColor = MaterialTheme.colorScheme.primary,
- inactiveTrackColor = Color.White.copy(alpha = 0.3f)
- ),
- thumb = {
- // Expressive Thumb (larger)
- Box(
- Modifier
- .size(20.dp)
- .background(MaterialTheme.colorScheme.primary, ExpressiveShapes.extraSmall)
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+
+ // Progress row: current time · Slider · total time
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = formatTime(currentTime),
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White,
+ fontWeight = FontWeight.Medium
)
- }
- )
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Time labels
- Text(
- text = "${formatTime(if (isDragging) (dragProgress * duration).toLong() else currentTime)} / ${formatTime(duration)}",
- color = Color.White.copy(alpha = 0.8f),
- style = MaterialTheme.typography.labelMedium
- )
+ Slider(
+ value = sliderValue,
+ onValueChange = {
+ isDragging = true
+ dragProgress = it
+ },
+ onValueChangeFinished = {
+ isDragging = false
+ onSeek((dragProgress * duration).toLong())
+ },
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 12.dp),
+ colors = SliderDefaults.colors(
+ thumbColor = MaterialTheme.colorScheme.primary,
+ activeTrackColor = MaterialTheme.colorScheme.primary,
+ inactiveTrackColor = Color.White.copy(alpha = 0.3f)
+ )
+ )
- // Fullscreen button
- IconButton(onClick = onFullscreenToggle) {
- Icon(
- imageVector = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
- contentDescription = if (isFullscreen) "Exit Fullscreen" else "Fullscreen",
- tint = Color.White
+ Text(
+ text = formatTime(duration),
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White.copy(alpha = 0.7f),
+ fontWeight = FontWeight.Medium
)
}
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Playback controls row — clear size hierarchy
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Rewind — secondary action, tonal container
+ FilledTonalIconButton(
+ onClick = onRewind,
+ modifier = Modifier.size(IconButtonDefaults.largeContainerSize()),
+ shape = ExpressiveShapes.medium,
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = Color.White.copy(alpha = 0.15f),
+ contentColor = Color.White
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.FastRewind,
+ contentDescription = "Rewind 10 seconds",
+ modifier = Modifier.size(IconButtonDefaults.largeIconSize)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // Play/Pause — dominant primary action with animated icon swap
+ FilledIconButton(
+ onClick = onPauseToggle,
+ modifier = Modifier.size(72.dp),
+ shape = ExpressiveShapes.extraLarge,
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ )
+ ) {
+ AnimatedContent(
+ targetState = isPlaying,
+ transitionSpec = {
+ (scaleIn(spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessMediumLow)) +
+ fadeIn()) togetherWith
+ (scaleOut() + fadeOut())
+ },
+ label = "PlayPauseIcon"
+ ) { playing ->
+ Icon(
+ imageVector = if (playing) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = if (playing) "Pause" else "Play",
+ modifier = Modifier.size(36.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // Forward — secondary action, tonal container
+ FilledTonalIconButton(
+ onClick = onForward,
+ modifier = Modifier.size(IconButtonDefaults.largeContainerSize()),
+ shape = ExpressiveShapes.medium,
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = Color.White.copy(alpha = 0.15f),
+ contentColor = Color.White
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.FastForward,
+ contentDescription = "Forward 10 seconds",
+ modifier = Modifier.size(IconButtonDefaults.largeIconSize)
+ )
+ }
+
+ // Skip next — optional, subordinate
+ if (onNextClick != null) {
+ Spacer(modifier = Modifier.width(16.dp))
+ IconButton(onClick = onNextClick) {
+ Icon(
+ imageVector = Icons.Default.SkipNext,
+ contentDescription = "Next episode",
+ tint = Color.White.copy(alpha = 0.85f),
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerSettingsDialog.kt b/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerSettingsDialog.kt
index 032813e..d027a6a 100644
--- a/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerSettingsDialog.kt
+++ b/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerSettingsDialog.kt
@@ -81,6 +81,8 @@ data class SubtitleOption(
val subLabel: String? = null
)
+enum class SubtitleLoadingState { IDLE, LOADING, SUCCESS, ERROR }
+
private enum class SettingsPage {
MAIN, QUALITY, SPEED, SUBTITLES
}
diff --git a/app/src/main/java/com/ivor/openanime/presentation/search/SearchScreen.kt b/app/src/main/java/com/ivor/openanime/presentation/search/SearchScreen.kt
index a3ef954..250c9a2 100644
--- a/app/src/main/java/com/ivor/openanime/presentation/search/SearchScreen.kt
+++ b/app/src/main/java/com/ivor/openanime/presentation/search/SearchScreen.kt
@@ -9,6 +9,14 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material3.InputChip
+import androidx.compose.material3.InputChipDefaults
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -222,11 +230,71 @@ fun SearchScreen(
)
}
}
+ IconButton(onClick = { viewModel.toggleFilterPane() }) {
+ Icon(
+ imageVector = Icons.Default.Tune,
+ contentDescription = "Filters",
+ tint = if (uiState.isFilterOpen) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
}
Spacer(modifier = Modifier.height(24.dp))
+ AnimatedVisibility(
+ visible = uiState.isFilterOpen,
+ enter = expandVertically(
+ animationSpec = spring(
+ dampingRatio = 0.7f,
+ stiffness = Spring.StiffnessLow
+ )
+ ) + fadeIn(),
+ exit = shrinkVertically(
+ animationSpec = spring(
+ dampingRatio = 0.7f,
+ stiffness = Spring.StiffnessLow
+ )
+ ) + fadeOut()
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp)
+ .clip(ExpressiveShapes.medium),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ shadowElevation = 2.dp
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Text(
+ text = "Sort By",
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(SortOption.entries.toTypedArray()) { option ->
+ InputChip(
+ selected = uiState.sortBy == option,
+ onClick = { viewModel.onSortSelected(option) },
+ label = { Text(option.displayName) },
+ colors = InputChipDefaults.inputChipColors(
+ selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
+ selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
// Filters (Connected Button Group)
Row(
modifier = Modifier.fillMaxWidth(),
diff --git a/app/src/main/java/com/ivor/openanime/presentation/search/SearchViewModel.kt b/app/src/main/java/com/ivor/openanime/presentation/search/SearchViewModel.kt
index 64c4e28..7d7594a 100644
--- a/app/src/main/java/com/ivor/openanime/presentation/search/SearchViewModel.kt
+++ b/app/src/main/java/com/ivor/openanime/presentation/search/SearchViewModel.kt
@@ -19,13 +19,22 @@ enum class SearchFilter {
ALL, MOVIE, TV
}
+enum class SortOption(val apiValue: String, val displayName: String) {
+ POPULARITY_DESC("popularity.desc", "Most Popular"),
+ POPULARITY_ASC("popularity.asc", "Least Popular"),
+ RATING_DESC("vote_average.desc", "Highest Rated"),
+ DATE_DESC("first_air_date.desc", "Newest")
+}
+
data class SearchUiState(
val query: String = "",
val history: List = emptyList(),
val searchResults: List = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
- val filter: SearchFilter = SearchFilter.ALL
+ val filter: SearchFilter = SearchFilter.ALL,
+ val sortBy: SortOption = SortOption.POPULARITY_DESC,
+ val isFilterOpen: Boolean = false
)
@HiltViewModel
@@ -58,9 +67,18 @@ class SearchViewModel @Inject constructor(
if (_uiState.value.filter == filter) return
_uiState.update { it.copy(filter = filter) }
val query = _uiState.value.query
- if (query.isNotBlank()) {
- performSearch(query)
- }
+ performSearch(query)
+ }
+
+ fun onSortSelected(sortOption: SortOption) {
+ if (_uiState.value.sortBy == sortOption) return
+ _uiState.update { it.copy(sortBy = sortOption) }
+ val query = _uiState.value.query
+ performSearch(query)
+ }
+
+ fun toggleFilterPane() {
+ _uiState.update { it.copy(isFilterOpen = !it.isFilterOpen) }
}
fun onSearch(query: String) {
@@ -77,9 +95,10 @@ class SearchViewModel @Inject constructor(
SearchFilter.MOVIE -> "movie"
SearchFilter.TV -> "tv"
}
+ val sortByString = _uiState.value.sortBy.apiValue
viewModelScope.launch {
- repository.searchAnime(query, 1, filterString).fold(
+ repository.discoverWithFilters(query, 1, filterString, sortByString).fold(
onSuccess = { animeList ->
_uiState.update { it.copy(isLoading = false, searchResults = animeList) }
},
diff --git a/app/src/main/java/com/ivor/openanime/presentation/update/UpdateScreen.kt b/app/src/main/java/com/ivor/openanime/presentation/update/UpdateScreen.kt
new file mode 100644
index 0000000..7bf654d
--- /dev/null
+++ b/app/src/main/java/com/ivor/openanime/presentation/update/UpdateScreen.kt
@@ -0,0 +1,741 @@
+package com.ivor.openanime.presentation.update
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.EaseInOut
+import androidx.compose.animation.core.EaseOutBack
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material.icons.filled.InstallMobile
+import androidx.compose.material.icons.filled.NewReleases
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.ivor.openanime.presentation.components.ExpressiveBackButton
+import com.ivor.openanime.ui.theme.ExpressiveShapes
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun UpdateScreen(
+ onBackClick: () -> Unit,
+ viewModel: UpdateViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val context = LocalContext.current
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ LargeTopAppBar(
+ title = {
+ Text(
+ text = "Updates",
+ style = MaterialTheme.typography.headlineLarge
+ )
+ },
+ navigationIcon = {
+ ExpressiveBackButton(
+ onClick = onBackClick,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ },
+ actions = {
+ if (uiState !is UpdateUiState.Loading && uiState !is UpdateUiState.Downloading) {
+ IconButton(onClick = { viewModel.checkForUpdate() }) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = "Check again",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ },
+ colors = TopAppBarDefaults.largeTopAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ scrollBehavior = scrollBehavior
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.surface
+ ) { innerPadding ->
+ AnimatedContent(
+ targetState = uiState,
+ modifier = Modifier.padding(innerPadding),
+ transitionSpec = {
+ (fadeIn(spring(stiffness = Spring.StiffnessMediumLow)) +
+ scaleIn(spring(stiffness = Spring.StiffnessMediumLow), initialScale = 0.92f))
+ .togetherWith(
+ fadeOut(spring(stiffness = Spring.StiffnessMediumLow)) +
+ scaleOut(spring(stiffness = Spring.StiffnessMediumLow), targetScale = 0.92f)
+ )
+ },
+ label = "UpdateStateTransition"
+ ) { state ->
+ when (state) {
+ is UpdateUiState.Loading -> LoadingState()
+ is UpdateUiState.UpToDate -> UpToDateState(state.currentVersion)
+ is UpdateUiState.UpdateAvailable -> UpdateAvailableState(
+ state = state,
+ onDownload = {
+ val asset = state.release.assets.firstOrNull { it.name.endsWith(".apk") }
+ if (asset != null) {
+ viewModel.downloadAndInstall(asset.downloadUrl, asset.name)
+ }
+ }
+ )
+ is UpdateUiState.Downloading -> DownloadingState(state.progress)
+ is UpdateUiState.ReadyToInstall -> ReadyToInstallState(
+ onInstall = { viewModel.openInstaller(context, state.apkFile) }
+ )
+ is UpdateUiState.Error -> ErrorState(
+ message = state.message,
+ onRetry = { viewModel.checkForUpdate() }
+ )
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// States
+// ---------------------------------------------------------------------------
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
+@Composable
+private fun LoadingState() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ LoadingIndicator(
+ modifier = Modifier.size(64.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "Checking for updates…",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@Composable
+private fun UpToDateState(currentVersion: String) {
+ // Pulsing ring animation
+ val infiniteTransition = rememberInfiniteTransition(label = "ring_pulse")
+ val ring1Scale by infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 1.35f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(2200, easing = EaseInOut),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "ring1_scale"
+ )
+ val ring2Scale by infiniteTransition.animateFloat(
+ initialValue = 1.15f,
+ targetValue = 1.55f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(2800, easing = EaseInOut),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "ring2_scale"
+ )
+ val ring1Alpha by infiniteTransition.animateFloat(
+ initialValue = 0.25f,
+ targetValue = 0.08f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(2200, easing = EaseInOut),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "ring1_alpha"
+ )
+ val ring2Alpha by infiniteTransition.animateFloat(
+ initialValue = 0.12f,
+ targetValue = 0.04f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(2800, easing = EaseInOut),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "ring2_alpha"
+ )
+
+ // Entry animation
+ var entryProgress by remember { mutableFloatStateOf(0f) }
+ val entryScale by animateFloatAsState(
+ targetValue = entryProgress,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessMediumLow
+ ),
+ label = "entry_scale"
+ )
+ val entryAlpha by animateFloatAsState(
+ targetValue = entryProgress,
+ animationSpec = tween(500),
+ label = "entry_alpha"
+ )
+ LaunchedEffect(Unit) { entryProgress = 1f }
+
+ val primaryContainer = MaterialTheme.colorScheme.primaryContainer
+ val surface = MaterialTheme.colorScheme.surface
+ val primary = MaterialTheme.colorScheme.primary
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.radialGradient(
+ colors = listOf(
+ primaryContainer.copy(alpha = 0.35f),
+ surface
+ ),
+ radius = 900f
+ )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier = Modifier.padding(horizontal = 40.dp)
+ ) {
+ // Icon with animated rings
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(160.dp)
+ .scale(entryScale)
+ .alpha(entryAlpha)
+ ) {
+ // Outer ring
+ Box(
+ modifier = Modifier
+ .size(160.dp)
+ .scale(ring2Scale)
+ .alpha(ring2Alpha)
+ .background(primary, CircleShape)
+ )
+ // Inner ring
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .scale(ring1Scale)
+ .alpha(ring1Alpha)
+ .background(primary, CircleShape)
+ )
+ // Icon container — squircle
+ Box(
+ modifier = Modifier
+ .size(96.dp)
+ .background(primaryContainer, ExpressiveShapes.extraLarge),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.CheckCircle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(56.dp)
+ )
+ }
+ }
+
+ Spacer(Modifier.height(36.dp))
+
+ // Headline
+ Text(
+ text = "You're up to date",
+ style = MaterialTheme.typography.displaySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.alpha(entryAlpha)
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ // Body
+ Text(
+ text = "OpenAnime v$currentVersion is the latest version.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.alpha(entryAlpha)
+ )
+
+ Spacer(Modifier.height(40.dp))
+
+ // Version chip / badge
+ AnimatedVisibility(
+ visible = entryProgress > 0.5f,
+ enter = fadeIn(tween(300)) + expandVertically(),
+ modifier = Modifier.alpha(entryAlpha)
+ ) {
+ Surface(
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ shape = CircleShape,
+ tonalElevation = 2.dp
+ ) {
+ Text(
+ text = "v$currentVersion",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun UpdateAvailableState(
+ state: UpdateUiState.UpdateAvailable,
+ onDownload: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Hero gradient card
+ Card(
+ shape = ExpressiveShapes.extraLarge,
+ colors = CardDefaults.cardColors(containerColor = Color.Transparent),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ brush = Brush.linearGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primaryContainer,
+ MaterialTheme.colorScheme.secondaryContainer
+ )
+ ),
+ shape = ExpressiveShapes.extraLarge
+ )
+ .padding(28.dp)
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .background(
+ MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.12f),
+ CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.NewReleases, null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ Text(
+ text = "New update available",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ Text(
+ text = state.release.tagName,
+ style = MaterialTheme.typography.displaySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Text(
+ text = "Current: v${state.currentVersion}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.65f)
+ )
+ }
+ }
+ }
+
+ // Changelog
+ if (state.release.body.isNotBlank()) {
+ Card(
+ shape = ExpressiveShapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "What's new",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+ Text(
+ text = state.release.body
+ .replace("## ", "").replace("### ", "").replace("**", "").trim(),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ lineHeight = MaterialTheme.typography.bodyMedium.fontSize * 1.6f
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(4.dp))
+
+ // Download CTA
+ Button(
+ onClick = onDownload,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ )
+ ) {
+ Icon(Icons.Default.Download, null, modifier = Modifier.size(20.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = "Download ${state.release.tagName}",
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+
+ Spacer(Modifier.height(8.dp))
+ }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun DownloadingState(progress: Int) {
+ val infiniteTransition = rememberInfiniteTransition(label = "dl_pulse")
+ val indicatorAlpha by infiniteTransition.animateFloat(
+ initialValue = 0.7f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(900, easing = EaseInOut),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "dl_alpha"
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.radialGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
+ MaterialTheme.colorScheme.surface
+ ),
+ radius = 900f
+ )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ modifier = Modifier.padding(32.dp)
+ ) {
+ Box(modifier = Modifier.alpha(indicatorAlpha)) {
+ LoadingIndicator(
+ modifier = Modifier.size(72.dp),
+ color = MaterialTheme.colorScheme.tertiary
+ )
+ }
+ Text(
+ text = "Downloading…",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Card(
+ shape = ExpressiveShapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "Progress",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Surface(
+ color = MaterialTheme.colorScheme.tertiaryContainer,
+ shape = CircleShape
+ ) {
+ Text(
+ text = if (progress > 0) "$progress%" else "Starting…",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
+ )
+ }
+ }
+ LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp),
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ color = MaterialTheme.colorScheme.tertiary
+ )
+ }
+ }
+ Text(
+ text = "The update will install from your Downloads folder.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+}
+
+@Composable
+private fun ReadyToInstallState(onInstall: () -> Unit) {
+ // Entry animation
+ var entryProgress by remember { mutableFloatStateOf(0f) }
+ val entryScale by animateFloatAsState(
+ targetValue = entryProgress,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessMediumLow
+ ),
+ label = "install_entry_scale"
+ )
+ val entryAlpha by animateFloatAsState(
+ targetValue = entryProgress,
+ animationSpec = tween(400),
+ label = "install_entry_alpha"
+ )
+ LaunchedEffect(Unit) { entryProgress = 1f }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.radialGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.4f),
+ MaterialTheme.colorScheme.surface
+ ),
+ radius = 900f
+ )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier = Modifier.padding(horizontal = 40.dp)
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(96.dp)
+ .scale(entryScale)
+ .alpha(entryAlpha)
+ .background(MaterialTheme.colorScheme.secondaryContainer, ExpressiveShapes.extraLarge)
+ ) {
+ Icon(
+ Icons.Default.InstallMobile, null,
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(52.dp)
+ )
+ }
+ Spacer(Modifier.height(32.dp))
+ Text(
+ text = "Ready to install",
+ style = MaterialTheme.typography.displaySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.alpha(entryAlpha)
+ )
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = "APK downloaded. Tap below to open the system installer.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.alpha(entryAlpha)
+ )
+ Spacer(Modifier.height(40.dp))
+ Button(
+ onClick = onInstall,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .alpha(entryAlpha),
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondary,
+ contentColor = MaterialTheme.colorScheme.onSecondary
+ )
+ ) {
+ Icon(Icons.Default.InstallMobile, null, modifier = Modifier.size(20.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = "Open Installer",
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ErrorState(message: String, onRetry: () -> Unit) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.radialGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f),
+ MaterialTheme.colorScheme.surface
+ ),
+ radius = 900f
+ )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ modifier = Modifier.padding(horizontal = 40.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(96.dp)
+ .background(MaterialTheme.colorScheme.errorContainer, ExpressiveShapes.extraLarge),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.ErrorOutline, null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(52.dp)
+ )
+ }
+ Spacer(Modifier.height(32.dp))
+ Text(
+ text = "Couldn't check for updates",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center
+ )
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ Spacer(Modifier.height(36.dp))
+ FilledTonalButton(
+ onClick = onRetry,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ shape = CircleShape
+ ) {
+ Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = "Try again",
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/ivor/openanime/presentation/update/UpdateViewModel.kt b/app/src/main/java/com/ivor/openanime/presentation/update/UpdateViewModel.kt
new file mode 100644
index 0000000..20dc92c
--- /dev/null
+++ b/app/src/main/java/com/ivor/openanime/presentation/update/UpdateViewModel.kt
@@ -0,0 +1,144 @@
+package com.ivor.openanime.presentation.update
+
+import android.app.DownloadManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import androidx.core.content.FileProvider
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivor.openanime.BuildConfig
+import com.ivor.openanime.data.remote.GithubApi
+import com.ivor.openanime.data.remote.GithubReleaseDto
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.io.File
+import javax.inject.Inject
+
+sealed class UpdateUiState {
+ data object Loading : UpdateUiState()
+ data class UpToDate(val currentVersion: String) : UpdateUiState()
+ data class UpdateAvailable(val release: GithubReleaseDto, val currentVersion: String) : UpdateUiState()
+ data class Downloading(val progress: Int) : UpdateUiState()
+ data class ReadyToInstall(val apkFile: File) : UpdateUiState()
+ data class Error(val message: String) : UpdateUiState()
+}
+
+@HiltViewModel
+class UpdateViewModel @Inject constructor(
+ private val githubApi: GithubApi,
+ @ApplicationContext private val context: Context
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(UpdateUiState.Loading)
+ val uiState = _uiState.asStateFlow()
+
+ private var downloadId: Long = -1L
+
+ init {
+ checkForUpdate()
+ }
+
+ fun checkForUpdate() {
+ viewModelScope.launch {
+ _uiState.value = UpdateUiState.Loading
+ try {
+ val release = githubApi.getLatestRelease()
+ val current = BuildConfig.VERSION_NAME
+ val latest = release.tagName.trimStart('v')
+ if (isNewerVersion(latest, current)) {
+ _uiState.value = UpdateUiState.UpdateAvailable(release, current)
+ } else {
+ _uiState.value = UpdateUiState.UpToDate(current)
+ }
+ } catch (e: Exception) {
+ _uiState.value = UpdateUiState.Error(e.message ?: "Failed to check for updates")
+ }
+ }
+ }
+
+ fun downloadAndInstall(apkUrl: String, fileName: String) {
+ _uiState.value = UpdateUiState.Downloading(0)
+
+ val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ val request = DownloadManager.Request(Uri.parse(apkUrl)).apply {
+ setTitle("OpenStream Update")
+ setDescription("Downloading $fileName")
+ setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
+ setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ setMimeType("application/vnd.android.package-archive")
+ addRequestHeader("Accept", "application/octet-stream")
+ }
+
+ downloadId = dm.enqueue(request)
+
+ // Register receiver for download completion
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
+ if (id == downloadId) {
+ context.unregisterReceiver(this)
+ val apkFile = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+ fileName
+ )
+ if (apkFile.exists()) {
+ _uiState.value = UpdateUiState.ReadyToInstall(apkFile)
+ } else {
+ _uiState.value = UpdateUiState.Error("Download failed — file not found")
+ }
+ }
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.registerReceiver(
+ receiver,
+ IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
+ Context.RECEIVER_EXPORTED
+ )
+ } else {
+ @Suppress("UnspecifiedRegisterReceiverFlag")
+ context.registerReceiver(
+ receiver,
+ IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
+ )
+ }
+ }
+
+ fun openInstaller(context: Context, apkFile: File) {
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.provider",
+ apkFile
+ )
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/vnd.android.package-archive")
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
+ }
+ context.startActivity(intent)
+ }
+
+ private fun isNewerVersion(latest: String, current: String): Boolean {
+ return try {
+ val l = latest.split(".").map { it.toInt() }
+ val c = current.split(".").map { it.toInt() }
+ for (i in 0 until maxOf(l.size, c.size)) {
+ val lv = l.getOrElse(i) { 0 }
+ val cv = c.getOrElse(i) { 0 }
+ if (lv > cv) return true
+ if (lv < cv) return false
+ }
+ false
+ } catch (e: Exception) {
+ false
+ }
+ }
+}
diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml
new file mode 100644
index 0000000..5fa23a1
--- /dev/null
+++ b/app/src/main/res/xml/file_provider_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+