diff --git a/.agent/skills/material-thinking/references/m3-expressive.md b/.agent/skills/material-thinking/references/m3-expressive.md index 5718e3b..9e479ad 100644 --- a/.agent/skills/material-thinking/references/m3-expressive.md +++ b/.agent/skills/material-thinking/references/m3-expressive.md @@ -35,6 +35,17 @@ M3 Expressiveは、標準のMaterial 3を拡張し、以下を実現します: URL: https://m3.material.io/blog/building-with-m3-expressive +### Research & Philosophy (Google Design 2024-2025) + +Base on "Expressive Design: Google's UX Research": +- **Emotional Connection**: 87% of users aged 18-24 prefer expressive designs. +- **Usability**: Expressive elements (larger, bolder, colorful) help users spot key UI elements up to 4x faster. +- **Brand Relevance**: Expressive design increases perception of "modernity" (+34%) and "subculture relevance" (+32%). +- **Context is Key**: Don't sacrifice standard patterns (e.g., lists) for expression if it hurts usability. +- **Accessibility**: Expressive design (larger targets, high contrast) often improves accessibility for older adults and those with varying abilities. + +Key Takeaway: Use bold colors and shapes not just for decoration, but to *guide attention* and *improve task speed*. + --- ## Usability Principles diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0400a0e..8379d9b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "com.ivor.openanime" minSdk = 26 targetSdk = 36 - versionCode = 2 - versionName = "1.1" + versionCode = 3 + versionName = "1.2" val localProperties = Properties() val localPropertiesFile = rootProject.file("local.properties") 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 887b925..7ad05a3 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 @@ -18,6 +18,25 @@ interface TmdbApi { @Query("with_keywords") keywords: String = "210024|287501" // Optionally specify anime-specific keywords ): TmdbResponse + @GET("trending/tv/{time_window}") + suspend fun getTrendingAnime( + @Path("time_window") timeWindow: String = "day", + @Query("page") page: Int = 1 + ): TmdbResponse + + @GET("tv/top_rated") + suspend fun getTopRatedAnime( + @Query("page") page: Int = 1, + @Query("language") language: String = "en-US" + ): TmdbResponse + + @GET("tv/airing_today") + suspend fun getAiringTodayAnime( + @Query("page") page: Int = 1, + @Query("language") language: String = "en-US", + @Query("timezone") timezone: String = "America/New_York" + ): TmdbResponse + @GET("search/multi") suspend fun searchMulti( @Query("query") query: String, 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 acfa4f0..a2334e9 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 @@ -42,7 +42,12 @@ data class AnimeDetailsDto( @SerialName("number_of_seasons") val numberOfSeasons: Int? = null, @SerialName("number_of_episodes") val numberOfEpisodes: Int? = null, @SerialName("seasons") val seasons: List? = null, - @SerialName("runtime") val runtime: Int? = null + @SerialName("runtime") val runtime: Int? = null, + @SerialName("status") val status: String? = null, + @SerialName("tagline") val tagline: String? = null, + @SerialName("genres") val genres: List? = null, + @SerialName("production_companies") val productionCompanies: List? = null, + @SerialName("homepage") val homepage: String? = null ) { val name: String get() = movieTitle ?: tvName ?: "" @@ -51,6 +56,20 @@ data class AnimeDetailsDto( get() = releaseDate ?: firstAirDate ?: "" } +@Serializable +data class GenreDto( + @SerialName("id") val id: Int, + @SerialName("name") val name: String +) + +@Serializable +data class ProductionCompanyDto( + @SerialName("id") val id: Int, + @SerialName("name") val name: String, + @SerialName("logo_path") val logoPath: String? = null, + @SerialName("origin_country") val originCountry: String? = null +) + fun AnimeDetailsDto.toAnimeDto(mediaType: String): AnimeDto { return AnimeDto( id = id, 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 75d5530..a04dfa9 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 @@ -22,6 +22,18 @@ class AnimeRepositoryImpl @Inject constructor( api.getPopularAnime(page = page).results } + override suspend fun getTrendingAnime(timeWindow: String, page: Int): Result> = runCatching { + api.getTrendingAnime(timeWindow, page).results + } + + override suspend fun getTopRatedAnime(page: Int): Result> = runCatching { + api.getTopRatedAnime(page).results + } + + override suspend fun getAiringTodayAnime(page: Int): Result> = runCatching { + api.getAiringTodayAnime(page).results + } + override suspend fun searchAnime(query: String, page: Int, filter: String): Result> = runCatching { when (filter) { "movie" -> api.searchMovie(query, page).results.map { it.copy(mediaType = "movie") } 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 761a4ed..4c9ccff 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 @@ -6,6 +6,10 @@ import com.ivor.openanime.data.remote.model.SeasonDetailsDto interface AnimeRepository { suspend fun getPopularAnime(page: Int): Result> + suspend fun getTrendingAnime(timeWindow: String = "day", page: Int = 1): Result> + suspend fun getTopRatedAnime(page: Int = 1): Result> + suspend fun getAiringTodayAnime(page: Int = 1): Result> + suspend fun searchAnime(query: String, page: Int, filter: String = "all"): Result> suspend fun getAnimeDetails(id: Int): Result suspend fun getMovieDetails(id: Int): Result diff --git a/app/src/main/java/com/ivor/openanime/presentation/details/DetailsScreen.kt b/app/src/main/java/com/ivor/openanime/presentation/details/DetailsScreen.kt index 3f1f94e..1b10e86 100644 --- a/app/src/main/java/com/ivor/openanime/presentation/details/DetailsScreen.kt +++ b/app/src/main/java/com/ivor/openanime/presentation/details/DetailsScreen.kt @@ -1,63 +1,101 @@ package com.ivor.openanime.presentation.details +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +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.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember 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.layout.ContentScale +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import coil3.compose.AsyncImage -import com.ivor.openanime.ui.theme.ExpressiveShapes -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import com.ivor.openanime.data.remote.model.AnimeDetailsDto -import com.ivor.openanime.data.remote.model.SeasonDto import com.ivor.openanime.data.remote.model.EpisodeDto - import com.ivor.openanime.presentation.components.ExpressiveBackButton +import com.ivor.openanime.ui.theme.ExpressiveShapes + +// Expressive Motion Tokens (Spring approximations from M3 specs) +// Source: https://m3.material.io/styles/motion/overview/specs + +// Spatial (Large movements) +private val ExpressiveDefaultSpatial = CubicBezierEasing(0.38f, 1.21f, 0.22f, 1.00f) // 500ms +private val ExpressiveDefaultEffects = CubicBezierEasing(0.34f, 0.80f, 0.34f, 1.00f) // 200ms + +private const val DurationSpatialDefault = 500 +private const val DurationEffectsDefault = 200 + +private fun materialSharedAxisYIn(): ContentTransform { + return (slideInVertically( + animationSpec = tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial) + ) { height -> height / 2 } + + fadeIn( + animationSpec = tween(DurationEffectsDefault, delayMillis = 50, easing = ExpressiveDefaultEffects) + )) + .togetherWith( + slideOutVertically( + animationSpec = tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial) + ) { height -> -height / 2 } + + fadeOut( + animationSpec = tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects) + ) + ) +} + +private enum class ScreenState { + Loading, Error, Success +} -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) @Composable fun DetailsScreen( mediaType: String, @@ -66,173 +104,253 @@ fun DetailsScreen( viewModel: DetailsViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() + + val screenState = remember(uiState) { + when (uiState) { + is DetailsUiState.Loading -> ScreenState.Loading + is DetailsUiState.Error -> ScreenState.Error + is DetailsUiState.Success -> ScreenState.Success + } + } Scaffold( - // Remove topBar to let content go under status bar + floatingActionButton = { + if (uiState is DetailsUiState.Success) { + val state = uiState as DetailsUiState.Success + val details = state.details + val seasonDetails = state.selectedSeasonDetails + + ExtendedFloatingActionButton( + onClick = { + val seasonNum = seasonDetails?.seasonNumber + ?: details.seasons?.firstOrNull()?.seasonNumber ?: 1 + val episodeNum = 1 + onPlayClick(seasonNum, episodeNum) + }, + icon = { Icon(Icons.Filled.PlayArrow, contentDescription = null) }, + text = { Text("Play Now") }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + expanded = true + ) + } + } ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { - // Success/Content State... (Details logic follows) - // Overlay Back Button - ExpressiveBackButton( - onClick = onBackClick, - modifier = Modifier - .statusBarsPadding() - .padding(8.dp) - .align(Alignment.TopStart) - ) - when (val state = uiState) { - is DetailsUiState.Loading -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - LoadingIndicator() + + AnimatedContent( + targetState = screenState, + transitionSpec = { materialSharedAxisYIn() }, + modifier = Modifier.fillMaxSize(), + label = "DetailsContent" + ) { targetState -> + when (targetState) { + ScreenState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LoadingIndicator() + } } - } - is DetailsUiState.Error -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = "Error: ${state.message}", color = MaterialTheme.colorScheme.error) + ScreenState.Error -> { + val errorState = uiState as? DetailsUiState.Error + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "Error: ${errorState?.message ?: "Unknown error"}", + color = MaterialTheme.colorScheme.error + ) + } } - } - is DetailsUiState.Success -> { - val details = state.details - val seasonDetails = state.selectedSeasonDetails - val isLoadingEpisodes = state.isLoadingEpisodes - - LazyColumn( - modifier = Modifier.fillMaxSize(), - // Remove contentPadding here or handle it carefully with full-screen header - // We want the image to go under the status bar, so no strict top padding here ideally - // But innerPadding forces it. Let's ignore innerPadding top for image effect if edge-to-edge - ) { - // Header Item - item { - Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { - AsyncImage( - model = "https://image.tmdb.org/t/p/w1280${details.backdropPath ?: details.posterPath}", - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - // Scrim - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, MaterialTheme.colorScheme.background), - startY = 0f, - endY = 1000f - ) + ScreenState.Success -> { + val successState = uiState as? DetailsUiState.Success + if (successState != null) { + val details = successState.details + val seasonDetails = successState.selectedSeasonDetails + val isLoadingEpisodes = successState.isLoadingEpisodes + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB + ) { + // Header Item + item { + Box(modifier = Modifier + .fillMaxWidth() + .height(500.dp)) { // Taller, more immersive header + AsyncImage( + model = "https://image.tmdb.org/t/p/w1280${details.backdropPath ?: details.posterPath}", + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() ) - ) - - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(16.dp) - ) { - Text( - text = details.name, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Rating: ${String.format("%.1f", details.voteAverage)} • ${details.date.take(4)}", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } + // Gradient Scrim + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + MaterialTheme.colorScheme.background.copy(alpha = 0.2f), + MaterialTheme.colorScheme.background.copy(alpha = 0.8f), + MaterialTheme.colorScheme.background + ), + startY = 0f, + endY = Float.POSITIVE_INFINITY + ) + ) + ) + + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(24.dp) // More padding + ) { + // Expressive Chips (Vibrant) + if (!details.genres.isNullOrEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 12.dp) + ) { + details.genres.take(3).forEach { genre -> + SuggestionChip( + onClick = { /* No-op */ }, + label = { + Text( + genre.name, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold) + ) + }, + shape = ExpressiveShapes.small, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + labelColor = MaterialTheme.colorScheme.onTertiaryContainer + ), + border = null + ) + } + } + } - // Actions & Overview - item { - Column(modifier = Modifier.padding(16.dp)) { - Button( - onClick = { - // Play logic: First episode of selected season, or S1E1 fallback - val seasonNum = seasonDetails?.seasonNumber - ?: details.seasons?.firstOrNull()?.seasonNumber ?: 1 - val episodeNum = 1 // Default to first episode - onPlayClick(seasonNum, episodeNum) - }, - shape = ExpressiveShapes.large, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Filled.PlayArrow, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Play Now") + Text( + text = details.name, + style = MaterialTheme.typography.displayMedium.copy(fontWeight = FontWeight.ExtraBold), // Bolder + color = MaterialTheme.colorScheme.onBackground + ) + if (!details.tagline.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = details.tagline, + style = MaterialTheme.typography.titleMedium.copy(fontStyle = FontStyle.Italic), + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "★ ${String.format("%.1f", details.voteAverage)} • ${details.date.take(4)} • ${details.status ?: "Unknown"}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } - - Spacer(modifier = Modifier.height(24.dp)) - Text("Overview", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = details.overview, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(24.dp)) - } - } - // Season Selector - if (!details.seasons.isNullOrEmpty()) { - item { - Text( - text = "Seasons", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - LazyRow( - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp), - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp) - ) { - items( - items = details.seasons!!, - key = { it.seasonNumber } - ) { season -> - val isSelected = seasonDetails?.seasonNumber == season.seasonNumber - FilterChip( - selected = isSelected, - onClick = { viewModel.loadSeason(season.seasonNumber) }, - label = { Text(season.name) }, - shape = ExpressiveShapes.small + // Overview & Studios + item { + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { + Text( + text = details.overview, + style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 24.sp), + color = MaterialTheme.colorScheme.onSurface ) + + // Studios as small expressive tags + if (!details.productionCompanies.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + details.productionCompanies.forEach { company -> + Text( + text = company.name.uppercase(), + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), + ExpressiveShapes.extraSmall + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) } } - Spacer(modifier = Modifier.height(16.dp)) - } - } - // Episodes List - if (isLoadingEpisodes) { - item { - Box(modifier = Modifier.fillMaxWidth().height(100.dp), contentAlignment = Alignment.Center) { - LoadingIndicator() + // Season Selector (Scrollable Chips) + if (!details.seasons.isNullOrEmpty()) { + item { + LazyRow( + contentPadding = PaddingValues(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = details.seasons, + key = { it.seasonNumber } + ) { season -> + val isSelected = seasonDetails?.seasonNumber == season.seasonNumber + FilterChip( + selected = isSelected, + onClick = { viewModel.loadSeason(season.seasonNumber) }, + label = { Text(season.name) }, + shape = ExpressiveShapes.small, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } } - } - } else { - seasonDetails?.episodes?.let { episodes -> - items( - items = episodes, - key = { it.id } - ) { episode -> - EpisodeItem( - episode = episode, - onClick = { onPlayClick(episode.seasonNumber, episode.episodeNumber) } - ) + + // Episodes List + if (isLoadingEpisodes) { + item { + Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { + LoadingIndicator() + } + } + } else { + seasonDetails?.episodes?.let { episodes -> + items( + items = episodes, + key = { it.id } + ) { episode -> + EpisodeItem( + episode = episode, + onClick = { onPlayClick(episode.seasonNumber, episode.episodeNumber) } + ) + } + } } } } - - // Bottom Padding for Navigation Bar - item { - Spacer(modifier = Modifier.height(innerPadding.calculateBottomPadding())) - } } } } + + // Overlay Back Button (Outside AnimatedContent to be stable) + ExpressiveBackButton( + onClick = onBackClick, + modifier = Modifier + .statusBarsPadding() + .padding(8.dp) + .align(Alignment.TopStart) + ) } } } @@ -243,12 +361,26 @@ fun EpisodeItem( onClick: () -> Unit ) { ListItem( - headlineContent = { Text(episode.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - supportingContent = { Text("Episode ${episode.episodeNumber} • ${episode.voteAverage}", maxLines = 1) }, + headlineContent = { + Text( + episode.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + ) + }, + supportingContent = { + Text( + "Episode ${episode.episodeNumber} • ${String.format("%.1f", episode.voteAverage)}", + maxLines = 1, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, leadingContent = { Card( shape = ExpressiveShapes.small, - modifier = Modifier.size(width = 120.dp, height = 68.dp) + modifier = Modifier.size(width = 100.dp, height = 56.dp) ) { AsyncImage( model = "https://image.tmdb.org/t/p/w300${episode.stillPath}", @@ -258,6 +390,11 @@ fun EpisodeItem( ) } }, - modifier = Modifier.clickable(onClick = onClick) + colors = ListItemDefaults.colors( + containerColor = Color.Transparent // Integrate with background + ), + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 8.dp) // Indent items slightly ) } 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 33710b9..8e44104 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 @@ -1,42 +1,46 @@ package com.ivor.openanime.presentation.home -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.slideInVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +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.material3.IconButton +import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp import androidx.hilt.navigation.compose.hiltViewModel import com.ivor.openanime.data.remote.model.AnimeDto import com.ivor.openanime.presentation.components.AnimeCard -import kotlinx.coroutines.delay - -private const val StaggerDelay = 80 +import kotlin.math.absoluteValue +import androidx.compose.foundation.layout.aspectRatio @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -48,12 +52,23 @@ fun HomeScreen( ) { val uiState by viewModel.uiState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - CenterAlignedTopAppBar( - title = { Text("OpenStream") } + LargeTopAppBar( + title = { + Text( + "OpenStream", + style = MaterialTheme.typography.displaySmall + ) + }, + scrollBehavior = scrollBehavior, + actions = { + // Actions removed as requested previously + } ) } ) { innerPadding -> @@ -76,72 +91,138 @@ fun HomeScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Text(text = "Error: ${state.message}", color = MaterialTheme.colorScheme.error) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "Error loading content", style = MaterialTheme.typography.titleMedium) + Text(text = state.message, color = MaterialTheme.colorScheme.error) + } } } is HomeUiState.Success -> { - AnimeList( - animeList = state.animeList, - onAnimeClick = onAnimeClick - ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp) + ) { + if (state.trending.isNotEmpty()) { + item { + SectionHeader(title = "Trending Now") + TrendingHeroCarousel( + animeList = state.trending.take(10), // Limit carousel to top 10 + onAnimeClick = onAnimeClick + ) + } + } + + if (state.topRated.isNotEmpty()) { + item { + SectionHeader(title = "Top Rated") + HorizontalAnimeList( + animeList = state.topRated, + onAnimeClick = onAnimeClick + ) + } + } + + if (state.airingToday.isNotEmpty()) { + item { + SectionHeader(title = "Airing Today") + HorizontalAnimeList( + animeList = state.airingToday, + onAnimeClick = onAnimeClick + ) + } + } + + if (state.popular.isNotEmpty()) { + item { + SectionHeader(title = "Popular") + HorizontalAnimeList( + animeList = state.popular, + onAnimeClick = onAnimeClick + ) + } + } + } } } } } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun AnimeList( +fun TrendingHeroCarousel( animeList: List, onAnimeClick: (Int) -> Unit ) { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(2), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalItemSpacing = 16.dp, - modifier = Modifier.fillMaxSize() - ) { - items(animeList, key = { it.id }) { anime -> - val index = animeList.indexOf(anime) - StaggeredAnimeCard( - index = index, - delay = StaggerDelay, - anime = anime, - onClick = { onAnimeClick(anime.id) } + val pagerState = rememberPagerState(pageCount = { animeList.size }) + + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(horizontal = 32.dp), + pageSpacing = 16.dp, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { page -> + val pageOffset = ( + (pagerState.currentPage - page) + pagerState + .currentPageOffsetFraction + ).absoluteValue + + AnimeCard( + anime = animeList[page], + onClick = { onAnimeClick(animeList[page].id) }, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.7f) + .graphicsLayer { + // Expressive Scale Effect: Center item is larger + val scale = lerp( + start = 0.85f, + stop = 1f, + fraction = 1f - pageOffset.coerceIn(0f, 1f) + ) + scaleX = scale + scaleY = scale + + // Fade out side items + alpha = lerp( + start = 0.5f, + stop = 1f, + fraction = 1f - pageOffset.coerceIn(0f, 1f) + ) + } ) } } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun StaggeredAnimeCard( - index: Int, - delay: Int, - anime: AnimeDto, - onClick: () -> Unit -) { - var visible by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - delay((delay * index).toLong()) - visible = true - } +fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, // Larger/bolder header + color = MaterialTheme.colorScheme.primary, // Use primary color for emphasis + modifier = Modifier.padding(start = 24.dp, top = 32.dp, bottom = 16.dp) // More breathing room + ) +} - AnimatedVisibility( - visible = visible, - enter = fadeIn( - animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec() - ) + slideInVertically( - animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), - initialOffsetY = { it / 4 } - ) +@Composable +fun HorizontalAnimeList( + animeList: List, + onAnimeClick: (Int) -> Unit +) { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - AnimeCard( - anime = anime, - onClick = onClick - ) + items(animeList, key = { it.id }) { anime -> + Box(modifier = Modifier.width(140.dp)) { + AnimeCard( + anime = anime, + onClick = { onAnimeClick(anime.id) } + ) + } + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ivor/openanime/presentation/home/HomeViewModel.kt b/app/src/main/java/com/ivor/openanime/presentation/home/HomeViewModel.kt index 8cf7ff5..7c125fd 100644 --- a/app/src/main/java/com/ivor/openanime/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/ivor/openanime/presentation/home/HomeViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import com.ivor.openanime.data.remote.model.AnimeDto import com.ivor.openanime.domain.repository.AnimeRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -20,25 +22,49 @@ class HomeViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() init { - loadPopularAnime() + loadData() } - fun loadPopularAnime() { + fun loadData() { viewModelScope.launch { _uiState.value = HomeUiState.Loading - repository.getPopularAnime(page = 1) - .onSuccess { animeList -> - _uiState.value = HomeUiState.Success(animeList) - } - .onFailure { exception -> - _uiState.value = HomeUiState.Error(exception.message ?: "Unknown error") + try { + coroutineScope { + val trendingDeferred = async { repository.getTrendingAnime() } + val topRatedDeferred = async { repository.getTopRatedAnime() } + val popularDeferred = async { repository.getPopularAnime(page = 1) } + val airingTodayDeferred = async { repository.getAiringTodayAnime() } + + val trending = trendingDeferred.await().getOrElse { emptyList() } + val topRated = topRatedDeferred.await().getOrElse { emptyList() } + val popular = popularDeferred.await().getOrElse { emptyList() } + val airingToday = airingTodayDeferred.await().getOrElse { emptyList() } + + if (trending.isEmpty() && topRated.isEmpty() && popular.isEmpty() && airingToday.isEmpty()) { + _uiState.value = HomeUiState.Error("Failed to load data") + } else { + _uiState.value = HomeUiState.Success( + trending = trending, + topRated = topRated, + popular = popular, + airingToday = airingToday + ) + } } + } catch (e: Exception) { + _uiState.value = HomeUiState.Error(e.message ?: "Unknown error") + } } } } sealed interface HomeUiState { data object Loading : HomeUiState - data class Success(val animeList: List) : HomeUiState + data class Success( + val trending: List, + val topRated: List, + val popular: List, + val airingToday: List + ) : HomeUiState data class Error(val message: String) : HomeUiState } 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 97d8ce0..37b26aa 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 @@ -11,10 +11,24 @@ import android.webkit.WebSettings import android.webkit.WebViewClient import androidx.activity.compose.BackHandler import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +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.slideInVertically +import androidx.compose.animation.slideOutVertically +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.WindowInsets @@ -39,6 +53,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -59,6 +75,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -69,10 +86,17 @@ import coil3.compose.AsyncImage import com.ivor.openanime.data.remote.model.SubtitleDto import com.ivor.openanime.presentation.player.components.ExoPlayerView import com.ivor.openanime.presentation.components.ExpressiveBackButton +import com.ivor.openanime.ui.theme.ExpressiveShapes + +// 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 @SuppressLint("SetJavaScriptEnabled") @androidx.annotation.OptIn(UnstableApi::class) -@kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class) +@kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun PlayerScreen( mediaType: String, @@ -85,11 +109,13 @@ fun PlayerScreen( ) { val context = LocalContext.current val activity = context as? Activity - + // Collect specific state updates val nextEpisodes by viewModel.nextEpisodes.collectAsState() val isLoadingEpisodes by viewModel.isLoadingEpisodes.collectAsState() val remoteSubtitles by viewModel.remoteSubtitles.collectAsState() + val mediaDetails by viewModel.mediaDetails.collectAsState() + val currentEpisode by viewModel.currentEpisode.collectAsState() var sniffedSubtitles by remember { mutableStateOf>(emptyList()) } @@ -113,7 +139,14 @@ fun PlayerScreen( } var videoUrl by rememberSaveable { mutableStateOf(null) } - val currentTitle = if (mediaType == "movie") "Movie" else "Season $season - Episode $episode" + // 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" + } // Fullscreen management fun enterFullscreen() { @@ -144,7 +177,7 @@ fun PlayerScreen( exitFullscreen() } - // Clean up on dispose -- restore orientation and system bars + // Clean up on dispose DisposableEffect(Unit) { onDispose { activity?.let { act -> @@ -157,6 +190,14 @@ fun PlayerScreen( } } + // Determine next episode click + val nextEpisode = nextEpisodes.firstOrNull { it.episodeNumber > episode } + val onNextClick: (() -> Unit)? = if (nextEpisode != null) { + { + onEpisodeClick(nextEpisode.episodeNumber) + } + } else null + Column( modifier = Modifier .fillMaxSize() @@ -176,136 +217,227 @@ fun PlayerScreen( modifier = videoModifier .background(Color.Black) ) { - if (videoUrl != null) { - ExoPlayerView( - videoUrl = videoUrl!!, - title = currentTitle, - isFullscreen = isFullscreen, - onFullscreenToggle = { - if (isFullscreen) exitFullscreen() else enterFullscreen() - }, - onBackClick = { - if (isFullscreen) exitFullscreen() else onBackClick() - }, - modifier = Modifier.fillMaxSize(), - remoteSubtitles = allSubtitles - ) - } else { - // Invisible WebView for extraction - AndroidView( - factory = { context -> - WebView(context).apply { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.mediaPlaybackRequiresUserGesture = false - settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW - settings.userAgentString = "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" - - webViewClient = object : WebViewClient() { - override fun shouldInterceptRequest( - view: WebView?, - request: WebResourceRequest? - ): WebResourceResponse? { - val url = request?.url?.toString() - if (url != null) { - if (url.contains(".m3u8") || url.contains(".mp4") || url.contains(".m4s") || url.contains("/manifest")) { - if (!url.contains("googleads") && !url.contains("doubleclick") && !url.contains("telemetry")) { - view?.post { - if (videoUrl == null) { - Log.i("PlayerSniffer", "Video URL Found: $url") - videoUrl = url + AnimatedContent( + targetState = videoUrl != null, + transitionSpec = { + fadeIn(tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects)) togetherWith + fadeOut(tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects)) + }, + label = "PlayerState" + ) { hasUrl -> + if (hasUrl) { + ExoPlayerView( + videoUrl = videoUrl!!, + title = currentTitle, + isFullscreen = isFullscreen, + onFullscreenToggle = { + if (isFullscreen) exitFullscreen() else enterFullscreen() + }, + onBackClick = { + if (isFullscreen) exitFullscreen() else onBackClick() + }, + modifier = Modifier.fillMaxSize(), + remoteSubtitles = allSubtitles, + onNextClick = onNextClick + ) + } else { + Box(Modifier.fillMaxSize()) { + // Invisible WebView for extraction + AndroidView( + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + settings.userAgentString = "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" + + webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest? + ): WebResourceResponse? { + val url = request?.url?.toString() + if (url != null) { + if (url.contains(".m3u8") || url.contains(".mp4") || url.contains(".m4s") || url.contains("/manifest")) { + if (!url.contains("googleads") && !url.contains("doubleclick") && !url.contains("telemetry")) { + view?.post { + if (videoUrl == null) { + Log.i("PlayerSniffer", "Video URL Found: $url") + videoUrl = url + } + } } - } - } - } else if (url.contains("sub.wyzie.ru") || url.contains(".vtt") || url.contains(".srt") || url.contains("subtitle")) { - if (!url.contains("googleads")) { - view?.post { - if (sniffedSubtitles.none { it.url == url }) { - Log.i("PlayerSniffer", "Subtitle URL Found: $url") - sniffedSubtitles = sniffedSubtitles + SubtitleDto( - id = "sniffed_${url.hashCode()}", - url = url, - display = "Detected Subtitle ${sniffedSubtitles.size + 1}" - ) + } else if (url.contains("sub.wyzie.ru") || url.contains(".vtt") || url.contains(".srt") || url.contains("subtitle")) { + if (!url.contains("googleads")) { + view?.post { + if (sniffedSubtitles.none { it.url == url }) { + Log.i("PlayerSniffer", "Subtitle URL Found: $url") + sniffedSubtitles = sniffedSubtitles + SubtitleDto( + id = "sniffed_${url.hashCode()}", + url = url, + display = "Detected Subtitle ${sniffedSubtitles.size + 1}" + ) + } + } } } } + return super.shouldInterceptRequest(view, request) } } - return super.shouldInterceptRequest(view, request) + loadUrl(embedUrl) } + }, + modifier = Modifier + .fillMaxSize() + .alpha(0f) + ) + + // Loading Overlay + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + // Back button visible during loading + Box(modifier = Modifier.align(Alignment.TopStart).padding(8.dp)) { + ExpressiveBackButton( + onClick = onBackClick, + containerColor = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + LoadingIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Extracting Stream...", + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) } - loadUrl(embedUrl) } - }, - modifier = Modifier - .fillMaxSize() - .alpha(0f) - ) - - // Loading Overlay - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black), - contentAlignment = Alignment.Center - ) { - // Back button visible during loading - Box(modifier = Modifier.align(Alignment.TopStart).padding(8.dp)) { - ExpressiveBackButton( - onClick = onBackClick, - containerColor = Color.Black.copy(alpha = 0.5f), - contentColor = Color.White - ) - } - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - LoadingIndicator() - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Extracting Stream...", - color = Color.White, - style = MaterialTheme.typography.bodySmall - ) } } } } // 2. Details and Next Episodes - Only visible when NOT in fullscreen - if (!isFullscreen) { + AnimatedVisibility( + visible = !isFullscreen, + enter = fadeIn(tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects)) + + slideInVertically(tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial)) { it / 4 }, + exit = fadeOut(tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects)) + + slideOutVertically(tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial)) { it / 4 } + ) { LazyColumn( modifier = Modifier .fillMaxSize() - .weight(1f) ) { - // Title and Description + // Title and Detailed Description item { Column(modifier = Modifier.padding(16.dp)) { + // Show Title (if TV) or Movie Tagline + if (mediaType != "movie") { + Text( + text = mediaDetails?.name ?: "TV Show", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + // Main Title (Episode Name or Movie Name) Text( - text = if (mediaType == "movie") "Movie" else "Season $season Episode $episode", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground + text = if (mediaType == "movie") mediaDetails?.name ?: "Movie" + else currentEpisode?.name ?: "Episode $episode", + style = MaterialTheme.typography.headlineSmall, // Expressive + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold ) + Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Release Date: 2024 • Rating: 4.8", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + + // Metadata Row + Row(verticalAlignment = Alignment.CenterVertically) { + if (mediaType != "movie") { + Text( + text = "S$season • E$episode", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(12.dp)) + } + + 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)) + } + + val date = if (mediaType == "movie") mediaDetails?.date else currentEpisode?.airDate + if (!date.isNullOrEmpty()) { + Text( + text = date.take(4), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Genres Chips + if (mediaDetails?.genres?.isNotEmpty() == true) { + Spacer(modifier = Modifier.height(12.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) + ) + ) + } + } + } + + // Overview (Plot) + val overview = if (mediaType == "movie") mediaDetails?.overview else currentEpisode?.overview + if (!overview.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = overview, + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 24.sp), + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } } // Next Episodes Header - item { - Text( - text = "Up Next", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.onBackground - ) + if (nextEpisodes.isNotEmpty()) { + item { + Text( + text = "Up Next", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.onBackground + ) + } } if (isLoadingEpisodes) { @@ -324,8 +456,8 @@ fun PlayerScreen( headlineContent = { Text( text = ep.name, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -341,7 +473,7 @@ fun PlayerScreen( modifier = Modifier .width(120.dp) .height(68.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(ExpressiveShapes.small) // Expressive Shape .background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center ) { diff --git a/app/src/main/java/com/ivor/openanime/presentation/player/PlayerViewModel.kt b/app/src/main/java/com/ivor/openanime/presentation/player/PlayerViewModel.kt index 32c0eac..e0f8e68 100644 --- a/app/src/main/java/com/ivor/openanime/presentation/player/PlayerViewModel.kt +++ b/app/src/main/java/com/ivor/openanime/presentation/player/PlayerViewModel.kt @@ -4,8 +4,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ivor.openanime.data.remote.SubtitleApi import com.ivor.openanime.data.remote.TmdbApi +import com.ivor.openanime.data.remote.model.AnimeDetailsDto import com.ivor.openanime.data.remote.model.EpisodeDto import com.ivor.openanime.data.remote.model.SubtitleDto +import com.ivor.openanime.data.remote.model.toAnimeDto +import com.ivor.openanime.domain.repository.AnimeRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,7 +22,8 @@ import javax.inject.Inject @HiltViewModel class PlayerViewModel @Inject constructor( private val tmdbApi: TmdbApi, - private val subtitleApi: SubtitleApi + private val subtitleApi: SubtitleApi, + private val repository: AnimeRepository ) : ViewModel() { private val _nextEpisodes = MutableStateFlow>(emptyList()) @@ -31,16 +35,46 @@ class PlayerViewModel @Inject constructor( private val _remoteSubtitles = MutableStateFlow>(emptyList()) val remoteSubtitles = _remoteSubtitles.asStateFlow() + // NEW state for details + private val _mediaDetails = MutableStateFlow(null) + val mediaDetails = _mediaDetails.asStateFlow() + + private val _currentEpisode = MutableStateFlow(null) + val currentEpisode = _currentEpisode.asStateFlow() + fun loadSeasonDetails(mediaType: String, tmdbId: Int, seasonNumber: Int, currentEpisodeNumber: Int) { viewModelScope.launch { + // Fetch Media Details (Show or Movie) + launch { + try { + val result = if (mediaType == "movie") { + repository.getMovieDetails(tmdbId) + } else { + repository.getAnimeDetails(tmdbId) + } + result.onSuccess { details -> + _mediaDetails.value = details + repository.addToWatchHistory(details.toAnimeDto(mediaType)) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + // Fetch Season Episodes (for TV) if (mediaType != "movie") { _isLoadingEpisodes.value = true try { // Fetch full season details val seasonDetails = tmdbApi.getSeasonDetails(tmdbId, seasonNumber) - // Filter for episodes after the current one - // We keep upcoming episodes. + // Set current episode details + val currentEp = seasonDetails.episodes.find { it.episodeNumber == currentEpisodeNumber } + if (currentEp != null) { + _currentEpisode.value = currentEp + } + + // Filter for next episodes _nextEpisodes.value = seasonDetails.episodes.filter { it.episodeNumber > currentEpisodeNumber } } catch (e: Exception) { e.printStackTrace() @@ -49,9 +83,10 @@ class PlayerViewModel @Inject constructor( } } else { _nextEpisodes.value = emptyList() + _currentEpisode.value = null } - // Fetch subtitles independently + // Fetch subtitles try { val jsonElement = if (mediaType == "tv") { subtitleApi.searchSubtitles(tmdbId, seasonNumber, currentEpisodeNumber) 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 8e1583a..4e5098f 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 @@ -67,7 +67,8 @@ fun ExoPlayerView( onFullscreenToggle: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, - remoteSubtitles: List = emptyList() + remoteSubtitles: List = emptyList(), + onNextClick: (() -> Unit)? = null ) { val context = LocalContext.current @@ -80,7 +81,7 @@ fun ExoPlayerView( // UI State var areControlsVisible by remember { mutableStateOf(true) } - var showSettingsSheet by remember { mutableStateOf(false) } + var showSettingsDialog by remember { mutableStateOf(false) } // Settings State var playbackSpeed by remember { mutableFloatStateOf(1.0f) } @@ -515,8 +516,9 @@ fun ExoPlayerView( currentTime = exoPlayer.currentPosition areControlsVisible = true }, + onNextClick = onNextClick, onSettingsClick = { - showSettingsSheet = true + showSettingsDialog = true areControlsVisible = false }, isFullscreen = isFullscreen, @@ -545,10 +547,10 @@ fun ExoPlayerView( } } - // Settings Bottom Sheet - if (showSettingsSheet) { - PlayerSettingsSheet( - onDismiss = { showSettingsSheet = false }, + // Settings Dialog + if (showSettingsDialog) { + PlayerSettingsDialog( + onDismiss = { showSettingsDialog = false }, qualityOptions = qualityOptions, selectedQuality = selectedQuality, onQualitySelected = { option -> 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 c38c128..5682522 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,9 +1,17 @@ package com.ivor.openanime.presentation.player.components import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +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 com.ivor.openanime.presentation.components.ExpressiveBackButton +import com.ivor.openanime.ui.theme.ExpressiveShapes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,8 +32,12 @@ 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.SkipNext +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults 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 @@ -41,6 +53,13 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color 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) @Composable fun PlayerControls( modifier: Modifier = Modifier, @@ -55,6 +74,7 @@ fun PlayerControls( onSeek: (Long) -> Unit, onForward: () -> Unit, onRewind: () -> Unit, + onNextClick: (() -> Unit)? = null, onSettingsClick: () -> Unit, onFullscreenToggle: () -> Unit = {}, onBackClick: () -> Unit @@ -70,8 +90,8 @@ fun PlayerControls( AnimatedVisibility( visible = isVisible, - enter = fadeIn(), - exit = fadeOut(), + enter = fadeIn(tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects)), + exit = fadeOut(tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects)), modifier = modifier ) { Box( @@ -79,11 +99,15 @@ fun PlayerControls( .background(Color.Black.copy(alpha = 0.4f)) .fillMaxSize() ) { - // Top Bar (Title and Options) + // Top Bar (Title and Options) - Slides from Top 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( @@ -95,62 +119,75 @@ fun PlayerControls( .padding(if (isFullscreen) 16.dp else 4.dp) ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Row(verticalAlignment = Alignment.CenterVertically) { ExpressiveBackButton( onClick = onBackClick, - containerColor = Color.Transparent, // Already inside a gradient background + containerColor = Color.White.copy(alpha = 0.1f), // Subtle background contentColor = Color.White ) Text( text = title, style = MaterialTheme.typography.titleMedium, color = Color.White, - modifier = Modifier.padding(start = 8.dp) + modifier = Modifier.padding(start = 12.dp) ) } - IconButton(onClick = onSettingsClick) { + IconButton( + onClick = onSettingsClick, + colors = IconButtonDefaults.iconButtonColors(contentColor = Color.White) + ) { Icon( imageVector = Icons.Filled.Settings, - contentDescription = "Settings", - tint = Color.White + contentDescription = "Settings" ) } } } - // Center Controls (Play/Pause, Rewind, Forward) -- hidden when buffering + // Center Controls (Play/Pause, Rewind, Forward) -- Scale In if (!isBuffering) { - val centerIconSize = if (isFullscreen) 64.dp else 48.dp - val sideIconSize = if (isFullscreen) 40.dp else 32.dp - val iconSize = if (isFullscreen) 32.dp else 24.dp - val spacing = if (isFullscreen) 32.dp else 16.dp + 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), + 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 ) { - IconButton(onClick = onRewind, modifier = Modifier.size(sideIconSize)) { + // 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.fillMaxSize() + modifier = Modifier.size(24.dp) ) } + // Play/Pause - Prominent and Shaped IconButton( onClick = onPauseToggle, modifier = Modifier .size(centerIconSize) .background( - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f), - shape = MaterialTheme.shapes.extraLarge + color = MaterialTheme.colorScheme.primaryContainer, + shape = ExpressiveShapes.large // Squircle ) ) { Icon( @@ -161,22 +198,60 @@ fun PlayerControls( ) } - IconButton(onClick = onForward, modifier = Modifier.size(sideIconSize)) { + // Forward + IconButton( + onClick = onForward, + modifier = Modifier + .size(sideIconSize) + .background(Color.White.copy(alpha = 0.1f), ExpressiveShapes.medium) + ) { Icon( imageVector = Icons.Default.FastForward, contentDescription = "Forward 10s", tint = Color.White, - modifier = Modifier.fillMaxSize() + modifier = Modifier.size(24.dp) ) } } + + // Extra Next Button in Row? No, user prefers Overlay Button. } - // Bottom Controls (Seekbar, Time, Fullscreen) + // "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)) + } + } + } + + // Bottom Controls (Seekbar, Time, Fullscreen) - Slides from Bottom Column( 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( @@ -186,10 +261,10 @@ fun PlayerControls( ) ) .padding( - start = if (isFullscreen) 16.dp else 8.dp, - end = if (isFullscreen) 16.dp else 8.dp, - top = if (isFullscreen) 16.dp else 0.dp, - bottom = if (isFullscreen) 8.dp else 0.dp + 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 ) ) { Slider( @@ -206,8 +281,16 @@ fun PlayerControls( colors = SliderDefaults.colors( thumbColor = MaterialTheme.colorScheme.primary, activeTrackColor = MaterialTheme.colorScheme.primary, - inactiveTrackColor = Color.Gray.copy(alpha = 0.5f) - ) + inactiveTrackColor = Color.White.copy(alpha = 0.3f) + ), + thumb = { + // Expressive Thumb (larger) + Box( + Modifier + .size(20.dp) + .background(MaterialTheme.colorScheme.primary, ExpressiveShapes.extraSmall) + ) + } ) Row( @@ -218,7 +301,7 @@ fun PlayerControls( // Time labels Text( text = "${formatTime(if (isDragging) (dragProgress * duration).toLong() else currentTime)} / ${formatTime(duration)}", - color = Color.White, + color = Color.White.copy(alpha = 0.8f), style = MaterialTheme.typography.labelMedium ) diff --git a/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerSettingsSheet.kt b/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerSettingsDialog.kt similarity index 69% rename from app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerSettingsSheet.kt rename to app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerSettingsDialog.kt index 4de5eb6..032813e 100644 --- a/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerSettingsSheet.kt +++ b/app/src/main/java/com/ivor/openanime/presentation/player/components/PlayerSettingsDialog.kt @@ -9,15 +9,16 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable +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.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -25,13 +26,12 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ClosedCaption import androidx.compose.material.icons.filled.HighQuality import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.HourglassBottom -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator @@ -43,9 +43,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,9 +52,12 @@ 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.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.ivor.openanime.ui.theme.ExpressiveShapes /** * Represents available quality options parsed from ExoPlayer tracks. @@ -86,7 +88,7 @@ private enum class SettingsPage { val SPEED_OPTIONS = listOf(0.25f, 0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f) @Composable -fun PlayerSettingsSheet( +fun PlayerSettingsDialog( onDismiss: () -> Unit, qualityOptions: List, selectedQuality: QualityOption?, @@ -98,72 +100,98 @@ fun PlayerSettingsSheet( onSubtitleSelected: (SubtitleOption?) -> Unit, subtitleLoadingState: SubtitleLoadingState = SubtitleLoadingState.IDLE ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var currentPage by remember { mutableStateOf(SettingsPage.MAIN) } - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface - ) { - AnimatedContent( - targetState = currentPage, - transitionSpec = { - if (targetState == SettingsPage.MAIN) { - (slideInHorizontally { -it } + fadeIn()) togetherWith - (slideOutHorizontally { it } + fadeOut()) - } else { - (slideInHorizontally { it } + fadeIn()) togetherWith - (slideOutHorizontally { -it } + fadeOut()) + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = ExpressiveShapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .width(360.dp) // Good width for phone/tablet/landscape + .heightIn(max = 500.dp) + .clip(ExpressiveShapes.extraLarge) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Header (Close Button Only on Main Page) + if (currentPage == SettingsPage.MAIN) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 8.dp, top = 12.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Settings", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) } - }, - label = "SettingsPageTransition" - ) { page -> - when (page) { - SettingsPage.MAIN -> MainSettingsMenu( - currentQualityLabel = selectedQuality?.label ?: "Auto", - currentSpeedLabel = formatSpeedLabel(currentSpeed), - currentSubtitleLabel = selectedSubtitle?.label ?: "Off", - hasSubtitles = subtitleOptions.isNotEmpty(), - onQualityClick = { currentPage = SettingsPage.QUALITY }, - onSpeedClick = { currentPage = SettingsPage.SPEED }, - onSubtitlesClick = { currentPage = SettingsPage.SUBTITLES } - ) - SettingsPage.QUALITY -> QualitySettingsMenu( - options = qualityOptions, - selected = selectedQuality, - onSelect = { option -> - onQualitySelected(option) - currentPage = SettingsPage.MAIN + AnimatedContent( + targetState = currentPage, + transitionSpec = { + if (targetState == SettingsPage.MAIN) { + (slideInHorizontally { -it } + fadeIn()) togetherWith + (slideOutHorizontally { it } + fadeOut()) + } else { + (slideInHorizontally { it } + fadeIn()) togetherWith + (slideOutHorizontally { -it } + fadeOut()) + } }, - onBack = { currentPage = SettingsPage.MAIN } - ) + label = "SettingsPageTransition" + ) { page -> + when (page) { + SettingsPage.MAIN -> MainSettingsMenu( + currentQualityLabel = selectedQuality?.label ?: "Auto", + currentSpeedLabel = formatSpeedLabel(currentSpeed), + currentSubtitleLabel = selectedSubtitle?.label ?: "Off", + hasSubtitles = subtitleOptions.isNotEmpty(), + onQualityClick = { currentPage = SettingsPage.QUALITY }, + onSpeedClick = { currentPage = SettingsPage.SPEED }, + onSubtitlesClick = { currentPage = SettingsPage.SUBTITLES } + ) - SettingsPage.SPEED -> SpeedSettingsMenu( - currentSpeed = currentSpeed, - onSelect = { speed -> - onSpeedSelected(speed) - currentPage = SettingsPage.MAIN - }, - onBack = { currentPage = SettingsPage.MAIN } - ) + SettingsPage.QUALITY -> QualitySettingsMenu( + options = qualityOptions, + selected = selectedQuality, + onSelect = { option -> + onQualitySelected(option) + currentPage = SettingsPage.MAIN + }, + onBack = { currentPage = SettingsPage.MAIN } + ) - SettingsPage.SUBTITLES -> SubtitleSettingsMenu( - options = subtitleOptions, - selected = selectedSubtitle, - onSelect = { option -> - onSubtitleSelected(option) - currentPage = SettingsPage.MAIN - }, - loadingState = subtitleLoadingState, - onBack = { currentPage = SettingsPage.MAIN } - ) + SettingsPage.SPEED -> SpeedSettingsMenu( + currentSpeed = currentSpeed, + onSelect = { speed -> + onSpeedSelected(speed) + currentPage = SettingsPage.MAIN + }, + onBack = { currentPage = SettingsPage.MAIN } + ) + + SettingsPage.SUBTITLES -> SubtitleSettingsMenu( + options = subtitleOptions, + selected = selectedSubtitle, + onSelect = { option -> + onSubtitleSelected(option) + currentPage = SettingsPage.MAIN + }, + loadingState = subtitleLoadingState, + onBack = { currentPage = SettingsPage.MAIN } + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) } } - - Spacer(modifier = Modifier.height(16.dp)) } } @@ -178,15 +206,7 @@ private fun MainSettingsMenu( onSubtitlesClick: () -> Unit ) { Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = "Settings", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) - ) - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - + // Content ListItem( headlineContent = { Text("Quality") }, supportingContent = { Text(currentQualityLabel) }, @@ -278,12 +298,50 @@ private fun QualitySettingsMenu( modifier = Modifier.padding(24.dp) ) } else { - options.forEach { option -> - val isSelected = option == selected + LazyColumn(modifier = Modifier.heightIn(max = 300.dp)) { + items(options) { option -> + val isSelected = option == selected + ListItem( + headlineContent = { + Text( + text = option.label, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + }, + trailingContent = { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable { onSelect(option) } + ) + } + } + } + } +} + +@Composable +private fun SpeedSettingsMenu( + currentSpeed: Float, + onSelect: (Float) -> Unit, + onBack: () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + SubPageHeader(title = "Playback Speed", onBack = onBack) + + Column { + SPEED_OPTIONS.forEach { speed -> + val isSelected = speed == currentSpeed ListItem( headlineContent = { Text( - text = option.label, + text = formatSpeedLabel(speed), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal ) }, @@ -297,47 +355,13 @@ private fun QualitySettingsMenu( } }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable { onSelect(option) } + modifier = Modifier.clickable { onSelect(speed) } ) } } } } -@Composable -private fun SpeedSettingsMenu( - currentSpeed: Float, - onSelect: (Float) -> Unit, - onBack: () -> Unit -) { - Column(modifier = Modifier.fillMaxWidth()) { - SubPageHeader(title = "Playback Speed", onBack = onBack) - - SPEED_OPTIONS.forEach { speed -> - val isSelected = speed == currentSpeed - ListItem( - headlineContent = { - Text( - text = formatSpeedLabel(speed), - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal - ) - }, - trailingContent = { - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary - ) - } - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable { onSelect(speed) } - ) - } - } -} - @Composable private fun SubtitleSettingsMenu( options: List, @@ -348,7 +372,7 @@ private fun SubtitleSettingsMenu( ) { var searchQuery by remember { mutableStateOf("") } - // Sort options: English (Extracted) > English > Others + // Sort options logic (same as before) val sortedOptions = remember(options) { options.filter { !it.isDisabled }.sortedWith( compareByDescending { it.label == "English (Extracted)" } @@ -363,7 +387,7 @@ private fun SubtitleSettingsMenu( sortedOptions.filter { it.label.contains(searchQuery, ignoreCase = true) } } - Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.8f)) { + Column(modifier = Modifier.fillMaxWidth()) { SubPageHeader(title = "Subtitles / CC", onBack = onBack) // Search Bar @@ -390,7 +414,7 @@ private fun SubtitleSettingsMenu( ) ) - LazyColumn(modifier = Modifier.fillMaxWidth()) { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp)) { // "Off" option item { val isOffSelected = selected == null || selected.isDisabled 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 f2f0f91..fce7c07 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 @@ -1,8 +1,14 @@ package com.ivor.openanime.presentation.search -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +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.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,49 +21,95 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.AppBarWithSearch import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.ExpandedFullScreenSearchBar +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.ToggleButton +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.SearchBarValue -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberSearchBarState +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope +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.input.nestedscroll.nestedScroll -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.ivor.openanime.presentation.components.AnimeCard -import kotlinx.coroutines.launch - import com.ivor.openanime.presentation.components.ExpressiveBackButton +import com.ivor.openanime.ui.theme.ExpressiveShapes + + + +// Expressive Motion Tokens (Spring approximations from M3 specs) +// Source: https://m3.material.io/styles/motion/overview/specs + +// Spatial (Large movements) +val ExpressiveFastSpatial = CubicBezierEasing(0.42f, 1.67f, 0.21f, 0.90f) // 350ms +val ExpressiveDefaultSpatial = CubicBezierEasing(0.38f, 1.21f, 0.22f, 1.00f) // 500ms +val ExpressiveSlowSpatial = CubicBezierEasing(0.39f, 1.29f, 0.35f, 0.98f) // 650ms + +// Effects (Small movements like fade, scale) +val ExpressiveFastEffects = CubicBezierEasing(0.31f, 0.94f, 0.34f, 1.00f) // 150ms +val ExpressiveDefaultEffects = CubicBezierEasing(0.34f, 0.80f, 0.34f, 1.00f) // 200ms +val ExpressiveSlowEffects = CubicBezierEasing(0.34f, 0.88f, 0.34f, 1.00f) // 300ms + +private const val DurationSpatialDefault = 500 +private const val DurationEffectsDefault = 200 + +private fun materialSharedAxisYIn(): ContentTransform { + // Shared Axis Y (Expressive) + // Slide uses Spatial curve (physics-based), opacity uses Effects curve + return (slideInVertically( + animationSpec = tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial) + ) { height -> height / 2 } + + fadeIn( + animationSpec = tween(DurationEffectsDefault, delayMillis = 50, easing = ExpressiveDefaultEffects) + )) + .togetherWith( + slideOutVertically( + animationSpec = tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial) + ) { height -> -height / 2 } + + fadeOut( + animationSpec = tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects) + ) + ) +} @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -67,230 +119,250 @@ fun SearchScreen( viewModel: SearchViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() - val textFieldState = rememberTextFieldState() - val searchBarState = rememberSearchBarState() - val scope = rememberCoroutineScope() - val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() + var searchQuery by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current - // Input field definition following M3 Expressive pattern - val inputField = @Composable { - SearchBarDefaults.InputField( - searchBarState = searchBarState, - textFieldState = textFieldState, - onSearch = { - val query = textFieldState.text.toString() - viewModel.onSearch(query) - scope.launch { searchBarState.animateToCollapsed() } - }, - placeholder = { - if (searchBarState.currentValue == SearchBarValue.Collapsed) { - Text( - modifier = Modifier - .fillMaxWidth() - .clearAndSetSemantics {}, - text = "Search Anime...", - textAlign = TextAlign.Center, - ) - } else { - Text("Search Anime...") - } - }, - leadingIcon = { - if (searchBarState.currentValue == SearchBarValue.Expanded) { - ExpressiveBackButton( - onClick = { - scope.launch { searchBarState.animateToCollapsed() } - } - ) - } else { - Icon(Icons.Default.Search, contentDescription = null) - } - }, - trailingIcon = { - if (searchBarState.currentValue == SearchBarValue.Expanded && - textFieldState.text.isNotEmpty() - ) { - IconButton(onClick = { - textFieldState.setTextAndPlaceCursorAtEnd("") - }) { - Icon(Icons.Default.Close, contentDescription = "Clear") - } - } - }, - ) + LaunchedEffect(uiState.query) { + if (uiState.query != searchQuery) { + searchQuery = uiState.query + } } Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - AppBarWithSearch( - scrollBehavior = scrollBehavior, - state = searchBarState, - inputField = inputField, - navigationIcon = { - ExpressiveBackButton(onClick = onBackClick) - }, - ) - ExpandedFullScreenSearchBar( - state = searchBarState, - inputField = inputField, - ) { - // Search history suggestions - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - // History header - if (uiState.history.isNotEmpty()) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "Recent Searches", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - "Clear All", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.clickable { viewModel.clearHistory() } - ) - } - } - } - - items(uiState.history) { historyItem -> - ListItem( - headlineContent = { Text(historyItem) }, - leadingContent = { - Icon( - Icons.Default.History, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - trailingContent = { - IconButton(onClick = { viewModel.removeHistoryItem(historyItem) }) { - Icon( - Icons.Default.Close, - contentDescription = "Remove", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - modifier = Modifier.clickable { - textFieldState.setTextAndPlaceCursorAtEnd(historyItem) - viewModel.onSearch(historyItem) - scope.launch { searchBarState.animateToCollapsed() } - } - ) - } - } - } - } + containerColor = MaterialTheme.colorScheme.background ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) + .padding(horizontal = 16.dp) ) { - SingleChoiceSegmentedButtonRow( + Spacer(modifier = Modifier.height(16.dp)) + + // Expressive Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + ExpressiveBackButton(onClick = onBackClick) + Spacer(modifier = Modifier.weight(1f)) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Discover", + style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Custom Expressive Search Bar + Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) + .height(64.dp) + .clip(ExpressiveShapes.large), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 4.dp ) { - SearchFilter.entries.forEachIndexed { index, filter -> - SegmentedButton( - selected = uiState.filter == filter, - onClick = { viewModel.onFilterSelected(filter) }, - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = SearchFilter.entries.size + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Anime, movies, or genres...") }, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent ), - label = { - Text( - when (filter) { - SearchFilter.ALL -> "All" - SearchFilter.MOVIE -> "Movies" - SearchFilter.TV -> "TV Shows" - } + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { + viewModel.onSearch(searchQuery) + keyboardController?.hide() + }) + ) + + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { + searchQuery = "" + viewModel.onSearch("") + }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - ) + } } } - Box( - modifier = Modifier.fillMaxSize() + + Spacer(modifier = Modifier.height(24.dp)) + + // Filters (Connected Button Group) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween) ) { - when { - uiState.isLoading -> { - LoadingIndicator(modifier = Modifier.align(Alignment.Center)) - } - uiState.error != null -> { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally + SearchFilter.entries.forEachIndexed { index, filter -> + ToggleButton( + checked = uiState.filter == filter, + onCheckedChange = { viewModel.onFilterSelected(filter) }, + modifier = Modifier + .weight(1f) + .semantics { role = Role.RadioButton }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + SearchFilter.entries.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + } ) { Text( - text = "Something went wrong", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = uiState.error ?: "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + when (filter) { + SearchFilter.ALL -> "All" + SearchFilter.MOVIE -> "Movies" + SearchFilter.TV -> "TV Shows" + } ) } } - uiState.searchResults.isNotEmpty() -> { - androidx.compose.animation.AnimatedVisibility( - visible = true, - enter = fadeIn() + slideInVertically { it / 3 }, - ) { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(2), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalItemSpacing = 16.dp, - modifier = Modifier.fillMaxSize() - ) { - items(uiState.searchResults, key = { it.id }) { anime -> - AnimeCard( - anime = anime, - onClick = { onAnimeClick(anime.id, anime.mediaType ?: "tv") } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Main Content Area with Material Motion + AnimatedContent( + targetState = when { + uiState.isLoading -> SearchContentState.Loading + uiState.error != null -> SearchContentState.Error + uiState.searchResults.isNotEmpty() -> SearchContentState.Results + searchQuery.isNotEmpty() -> SearchContentState.Empty + else -> SearchContentState.History + }, + transitionSpec = { materialSharedAxisYIn() }, + label = "SearchContent" + ) { targetState -> + Box(modifier = Modifier.fillMaxSize()) { + when (targetState) { + SearchContentState.Loading -> { + LoadingIndicator(modifier = Modifier.align(Alignment.Center)) + } + SearchContentState.Error -> { + Text( + text = uiState.error ?: "Unknown error", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.Center) + ) + } + SearchContentState.Results -> { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + verticalItemSpacing = 16.dp, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(bottom = 24.dp) + ) { + items(uiState.searchResults, key = { it.id }) { anime -> + AnimeCard( + anime = anime, + onClick = { onAnimeClick(anime.id, anime.mediaType ?: "tv") } + ) + } + } + } + SearchContentState.History -> { + Column { + Text( + text = "Recent Searches", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn { + items(uiState.history) { historyItem -> + ListItem( + headlineContent = { Text(historyItem) }, + leadingContent = { + Icon( + Icons.Default.History, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + trailingContent = { + IconButton(onClick = { viewModel.removeHistoryItem(historyItem) }) { + Icon(Icons.Default.Close, contentDescription = "Remove") + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ), + modifier = Modifier + .clickable { + searchQuery = historyItem + viewModel.onSearch(historyItem) + keyboardController?.hide() + } + ) + } + item { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Clear History", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .clickable { viewModel.clearHistory() } + .padding(8.dp) + ) + } + } + } + } + SearchContentState.Empty -> { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No matches found", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Try a different keyword", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) } } - } - } - uiState.query.isNotEmpty() && !uiState.isLoading -> { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "No results found", - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Try searching for something else", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } } } } } + +enum class SearchContentState { + Loading, Error, Results, History, Empty } diff --git a/app/src/main/java/com/ivor/openanime/presentation/watch_history/WatchHistoryScreen.kt b/app/src/main/java/com/ivor/openanime/presentation/watch_history/WatchHistoryScreen.kt index a9fe400..f291848 100644 --- a/app/src/main/java/com/ivor/openanime/presentation/watch_history/WatchHistoryScreen.kt +++ b/app/src/main/java/com/ivor/openanime/presentation/watch_history/WatchHistoryScreen.kt @@ -1,45 +1,76 @@ package com.ivor.openanime.presentation.watch_history -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +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.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith 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.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.History import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LargeFlexibleTopAppBar import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.ivor.openanime.presentation.components.AnimeCard import com.ivor.openanime.presentation.components.ExpressiveBackButton +// Expressive Motion Tokens +private val ExpressiveDefaultSpatial = CubicBezierEasing(0.38f, 1.21f, 0.22f, 1.00f) // 500ms +private val ExpressiveDefaultEffects = CubicBezierEasing(0.34f, 0.80f, 0.34f, 1.00f) // 200ms + +private const val DurationSpatialDefault = 500 +private const val DurationEffectsDefault = 200 + +private fun materialSharedAxisYIn(): ContentTransform { + return (slideInVertically( + animationSpec = tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial) + ) { height -> height / 2 } + + fadeIn( + animationSpec = tween(DurationEffectsDefault, delayMillis = 50, easing = ExpressiveDefaultEffects) + )) + .togetherWith( + slideOutVertically( + animationSpec = tween(DurationSpatialDefault, easing = ExpressiveDefaultSpatial) + ) { height -> -height / 2 } + + fadeOut( + animationSpec = tween(DurationEffectsDefault, easing = ExpressiveDefaultEffects) + ) + ) +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun WatchHistoryScreen( @@ -48,98 +79,125 @@ fun WatchHistoryScreen( viewModel: WatchHistoryViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - LargeFlexibleTopAppBar( - title = { - Text( - "Watch History", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - subtitle = { - val count = uiState.history.size - if (count > 0) { - Text( - "$count titles", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - }, - navigationIcon = { - ExpressiveBackButton(onClick = onBackClick) - }, - actions = { - if (uiState.history.isNotEmpty()) { - IconButton(onClick = { viewModel.clearHistory() }) { - Icon( - imageVector = Icons.Default.DeleteSweep, - contentDescription = "Clear History" - ) - } - } - }, - scrollBehavior = scrollBehavior - ) - } + containerColor = MaterialTheme.colorScheme.background ) { innerPadding -> - Box( + Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) + .padding(horizontal = 16.dp) ) { - if (uiState.isLoading) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - LoadingIndicator() - } - } else { - AnimatedVisibility( - visible = uiState.history.isNotEmpty(), - enter = fadeIn(), - exit = fadeOut() - ) { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(2), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalItemSpacing = 16.dp, - modifier = Modifier.fillMaxSize() - ) { - items(uiState.history, key = { it.id }) { anime -> - AnimeCard( - anime = anime, - onClick = { onAnimeClick(anime.id, anime.mediaType ?: "tv") } - ) - } + Spacer(modifier = Modifier.height(16.dp)) + + // Fixed Header matching SearchScreen pattern + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + ExpressiveBackButton(onClick = onBackClick) + + // Expressive Clear Button + if (uiState.history.isNotEmpty()) { + TextButton(onClick = { viewModel.clearHistory() }) { + Icon( + imageVector = Icons.Default.DeleteSweep, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Clear All", color = MaterialTheme.colorScheme.error) } } + } - if (uiState.history.isEmpty() && !uiState.isLoading) { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "No history yet", - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Titles you watch will appear here", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Watch History", + style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(24.dp)) + + val contentState = when { + uiState.isLoading -> WatchHistoryContentState.Loading + uiState.history.isEmpty() -> WatchHistoryContentState.Empty + else -> WatchHistoryContentState.Content + } + + AnimatedContent( + targetState = contentState, + transitionSpec = { materialSharedAxisYIn() }, + modifier = Modifier.fillMaxSize(), + label = "WatchHistoryContent" + ) { state -> + Box(modifier = Modifier.fillMaxSize()) { + when (state) { + WatchHistoryContentState.Loading -> { + LoadingIndicator(modifier = Modifier.align(Alignment.Center)) + } + WatchHistoryContentState.Empty -> { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.History, + contentDescription = null, + modifier = Modifier.size(80.dp), // Larger illustrative icon + tint = MaterialTheme.colorScheme.surfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "No history yet", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Titles you watch will appear here", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + WatchHistoryContentState.Content -> { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + contentPadding = PaddingValues(bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + // Recent Header + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = "Recently Watched", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(bottom = 8.dp), + color = MaterialTheme.colorScheme.primary + ) + } + + items(uiState.history, key = { it.id }) { anime -> + AnimeCard( + anime = anime, + onClick = { onAnimeClick(anime.id, anime.mediaType ?: "tv") } + ) + } + } + } } } } } } } + +private enum class WatchHistoryContentState { + Loading, Empty, Content +} diff --git a/app/src/main/java/com/ivor/openanime/ui/theme/Color.kt b/app/src/main/java/com/ivor/openanime/ui/theme/Color.kt index 7d0920b..e2486ab 100644 --- a/app/src/main/java/com/ivor/openanime/ui/theme/Color.kt +++ b/app/src/main/java/com/ivor/openanime/ui/theme/Color.kt @@ -5,7 +5,7 @@ import androidx.compose.ui.graphics.Color // Seed Color: Violet val SeedColor = Color(0xFF6750A4) -// Light Theme +// Vibrant Light Theme val PrimaryLight = Color(0xFF6750A4) val OnPrimaryLight = Color(0xFFFFFFFF) val PrimaryContainerLight = Color(0xFFEADDFF) @@ -28,7 +28,7 @@ val SurfaceVariantLight = Color(0xFFE7E0EC) val OnSurfaceVariantLight = Color(0xFF49454F) val OutlineLight = Color(0xFF79747E) -// Dark Theme +// Vibrant Dark Theme val PrimaryDark = Color(0xFFD0BCFF) val OnPrimaryDark = Color(0xFF381E72) val PrimaryContainerDark = Color(0xFF4F378B) @@ -51,7 +51,17 @@ val SurfaceVariantDark = Color(0xFF49454F) val OnSurfaceVariantDark = Color(0xFFCAC4D0) val OutlineDark = Color(0xFF938F99) -// Custom Expressive Colors (if needed, e.g., specifically for certain anime genres) -val AnimeAction = Color(0xFFD32F2F) -val AnimeRomance = Color(0xFFEC407A) -val AnimeSciFi = Color(0xFF7B1FA2) \ No newline at end of file +// Vibrant Overrides (Increased Saturation) +val VibrantPrimaryLight = Color(0xFF6F43C0) // More vivid +val VibrantPrimaryContainerLight = Color(0xFFE9DDFF) +val VibrantSecondaryLight = Color(0xFF6D4EA1) // More purple than gray +val VibrantSecondaryContainerLight = Color(0xFFEBDDFF) +val VibrantTertiaryLight = Color(0xFF984061) // More vivid pink +val VibrantTertiaryContainerLight = Color(0xFFFFD9E2) + +val VibrantPrimaryDark = Color(0xFFD0BCFF) // Keep light purple +val VibrantPrimaryContainerDark = Color(0xFF5636A6) // Darker vivid +val VibrantSecondaryDark = Color(0xFFD4BBFF) +val VibrantSecondaryContainerDark = Color(0xFF533887) +val VibrantTertiaryDark = Color(0xFFFFB0C8) +val VibrantTertiaryContainerDark = Color(0xFF7B2949) \ No newline at end of file diff --git a/app/src/main/java/com/ivor/openanime/ui/theme/Theme.kt b/app/src/main/java/com/ivor/openanime/ui/theme/Theme.kt index 02a52bd..2678eac 100644 --- a/app/src/main/java/com/ivor/openanime/ui/theme/Theme.kt +++ b/app/src/main/java/com/ivor/openanime/ui/theme/Theme.kt @@ -17,30 +17,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat -private val DarkColorScheme = darkColorScheme( - primary = PrimaryDark, - onPrimary = OnPrimaryDark, - primaryContainer = PrimaryContainerDark, - onPrimaryContainer = OnPrimaryContainerDark, - secondary = SecondaryDark, - onSecondary = OnSecondaryDark, - secondaryContainer = SecondaryContainerDark, - onSecondaryContainer = OnSecondaryContainerDark, - tertiary = TertiaryDark, - onTertiary = OnTertiaryDark, - tertiaryContainer = TertiaryContainerDark, - onTertiaryContainer = OnTertiaryContainerDark, - error = ErrorDark, - onError = OnErrorDark, - errorContainer = ErrorContainerDark, - onErrorContainer = OnErrorContainerDark, - surface = SurfaceDark, - onSurface = OnSurfaceDark, - surfaceVariant = SurfaceVariantDark, - onSurfaceVariant = OnSurfaceVariantDark, - outline = OutlineDark, -) - @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun OpenAnimeTheme( @@ -53,30 +29,52 @@ fun OpenAnimeTheme( val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - darkTheme -> expressiveLightColorScheme().copy( - primary = DarkColorScheme.primary, - onPrimary = DarkColorScheme.onPrimary, - primaryContainer = DarkColorScheme.primaryContainer, - onPrimaryContainer = DarkColorScheme.onPrimaryContainer, - secondary = DarkColorScheme.secondary, - onSecondary = DarkColorScheme.onSecondary, - secondaryContainer = DarkColorScheme.secondaryContainer, - onSecondaryContainer = DarkColorScheme.onSecondaryContainer, - tertiary = DarkColorScheme.tertiary, - onTertiary = DarkColorScheme.onTertiary, - tertiaryContainer = DarkColorScheme.tertiaryContainer, - onTertiaryContainer = DarkColorScheme.onTertiaryContainer, - error = DarkColorScheme.error, - onError = DarkColorScheme.onError, - errorContainer = DarkColorScheme.errorContainer, - onErrorContainer = DarkColorScheme.onErrorContainer, - surface = DarkColorScheme.surface, - onSurface = DarkColorScheme.onSurface, - surfaceVariant = DarkColorScheme.surfaceVariant, - onSurfaceVariant = DarkColorScheme.onSurfaceVariant, - outline = DarkColorScheme.outline, + darkTheme -> darkColorScheme( + primary = VibrantPrimaryDark, + onPrimary = OnPrimaryDark, + primaryContainer = VibrantPrimaryContainerDark, + onPrimaryContainer = OnPrimaryContainerDark, + secondary = VibrantSecondaryDark, + onSecondary = OnSecondaryDark, + secondaryContainer = VibrantSecondaryContainerDark, + onSecondaryContainer = OnSecondaryContainerDark, + tertiary = VibrantTertiaryDark, + onTertiary = OnTertiaryDark, + tertiaryContainer = VibrantTertiaryContainerDark, + onTertiaryContainer = OnTertiaryContainerDark, + error = ErrorDark, + onError = OnErrorDark, + errorContainer = ErrorContainerDark, + onErrorContainer = OnErrorContainerDark, + surface = SurfaceDark, + onSurface = OnSurfaceDark, + surfaceVariant = SurfaceVariantDark, + onSurfaceVariant = OnSurfaceVariantDark, + outline = OutlineDark, + ) + else -> androidx.compose.material3.lightColorScheme( + primary = VibrantPrimaryLight, + onPrimary = OnPrimaryLight, + primaryContainer = VibrantPrimaryContainerLight, + onPrimaryContainer = OnPrimaryContainerLight, + secondary = VibrantSecondaryLight, + onSecondary = OnSecondaryLight, + secondaryContainer = VibrantSecondaryContainerLight, + onSecondaryContainer = OnSecondaryContainerLight, + tertiary = VibrantTertiaryLight, + onTertiary = OnTertiaryLight, + tertiaryContainer = VibrantTertiaryContainerLight, + onTertiaryContainer = OnTertiaryContainerLight, + error = ErrorLight, + onError = OnErrorLight, + errorContainer = ErrorContainerLight, + onErrorContainer = OnErrorContainerLight, + surface = SurfaceLight, + onSurface = OnSurfaceLight, + surfaceVariant = SurfaceVariantLight, + onSurfaceVariant = OnSurfaceVariantLight, + outline = OutlineLight, ) - else -> expressiveLightColorScheme() } val view = LocalView.current @@ -93,6 +91,7 @@ fun OpenAnimeTheme( colorScheme = colorScheme, typography = Typography, shapes = ExpressiveShapes, + motionScheme = MotionScheme.expressive(), content = content ) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dcc6940..4b6a45a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,9 @@ material = { group = "com.google.android.material", name = "material", version.r # Navigation & Transitions androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +material-motion-compose-core = { group = "io.github.fornewid", name = "material-motion-compose-core", version = "2.0.1" } +material-motion-compose-material3 = { group = "io.github.fornewid", name = "material-motion-compose-material3", version = "2.0.1" } + # Image Loading (Coil 3) coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }