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 @@ + + + + +