diff --git a/core/ui/common/src/main/java/io/filmtime/core/ui/common/componnents/VideoCard.kt b/core/ui/common/src/main/java/io/filmtime/core/ui/common/componnents/VideoCard.kt new file mode 100644 index 00000000..49085ce9 --- /dev/null +++ b/core/ui/common/src/main/java/io/filmtime/core/ui/common/componnents/VideoCard.kt @@ -0,0 +1,71 @@ +package io.filmtime.core.ui.common.componnents + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.filmtime.core.designsystem.theme.PreviewFilmTimeTheme +import io.filmtime.core.designsystem.theme.ThemePreviews +import io.filmtime.core.ui.common.componnents.placeholder.PlaceholderHighlight +import io.filmtime.core.ui.common.componnents.placeholder.fade +import io.filmtime.core.ui.common.componnents.placeholder.placeholder +import io.filmtime.data.model.MovieVideo +import io.filmtime.data.model.VideoSource.YouTube + +@Composable +fun VideoCard( + video: MovieVideo, + placeHolderVisible: Boolean, + onClick: ((String) -> Unit)? = null, +) { + val placeholderHighlight = PlaceholderHighlight.fade() + Box( + modifier = Modifier + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(8.dp)) + .then( + if (video.link != null && onClick != null) { + Modifier.clickable { + onClick.invoke(video.link!!) + } + } else { + Modifier + }, + ), + ) { + AsyncImage( + modifier = Modifier + .aspectRatio(16f / 9f) + .placeholder( + visible = placeHolderVisible, + highlight = placeholderHighlight, + ), + model = video.posterUrl, + contentDescription = video.link, + contentScale = ContentScale.Crop, + ) + } +} + +@ThemePreviews +@Composable +private fun PreviewVideoCard() { + PreviewFilmTimeTheme { + VideoCard( + video = MovieVideo( + name = "The Batman", + posterUrl = "https://image.tmdb.org/t/p/w500/5VJSIAhSn4qUq3FjV3j6Uz5Zc4c.jpg", + link = "https://www.youtube.com/watch?v=IhYDiZ0fF5Y", + source = YouTube, + key = "", + ), + placeHolderVisible = false, + ) + } +} diff --git a/core/ui/common/src/main/java/io/filmtime/core/ui/common/componnents/VideoTrailerRow.kt b/core/ui/common/src/main/java/io/filmtime/core/ui/common/componnents/VideoTrailerRow.kt new file mode 100644 index 00000000..c13db08d --- /dev/null +++ b/core/ui/common/src/main/java/io/filmtime/core/ui/common/componnents/VideoTrailerRow.kt @@ -0,0 +1,63 @@ +package io.filmtime.core.ui.common.componnents + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.filmtime.core.ui.common.R +import io.filmtime.data.model.MovieVideo + +@Composable +fun VideoTrailerRow( + modifier: Modifier = Modifier, + onClick: ((String) -> Unit)?, + items: List, + isLoading: Boolean, +) { + Column( + modifier = modifier, + ) { + Text( + text = stringResource(id = R.string.core_ui_trailers_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .wrapContentSize(), + ) + } else if (items.isNotEmpty()) { + LazyRow( + modifier = Modifier + .height(180.dp) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items) { item -> + VideoCard( + video = item, + placeHolderVisible = false, + onClick = onClick, + ) + } + } + } + } +} diff --git a/core/ui/common/src/main/res/values/strings.xml b/core/ui/common/src/main/res/values/strings.xml index 5fe24171..c2f16be2 100644 --- a/core/ui/common/src/main/res/values/strings.xml +++ b/core/ui/common/src/main/res/values/strings.xml @@ -6,5 +6,6 @@ Unknown API error Collection + Trailers Your device doesn\'t support this action. \ No newline at end of file diff --git a/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/MovieVideoExt.kt b/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/MovieVideoExt.kt new file mode 100644 index 00000000..56bb8403 --- /dev/null +++ b/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/MovieVideoExt.kt @@ -0,0 +1,19 @@ +package io.filmtime.data.api.tmdb + +import io.filmtime.data.model.MovieVideo +import io.filmtime.data.model.VideoSource +import io.filmtime.data.network.model.Site +import io.filmtime.data.network.model.Site.YouTube +import io.filmtime.data.network.model.VideoData + +fun VideoData.toVideos(): MovieVideo = + MovieVideo( + key = key, + name = name, + source = site.toSource(), + ) + +fun Site.toSource(): VideoSource = + when (this) { + YouTube -> VideoSource.YouTube + } diff --git a/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbMoviesRemoteSource.kt b/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbMoviesRemoteSource.kt index 4e726f5b..a33f3076 100644 --- a/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbMoviesRemoteSource.kt +++ b/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbMoviesRemoteSource.kt @@ -2,6 +2,7 @@ package io.filmtime.data.api.tmdb import io.filmtime.data.model.GeneralError import io.filmtime.data.model.MovieCollection +import io.filmtime.data.model.MovieVideo import io.filmtime.data.model.Person import io.filmtime.data.model.Result import io.filmtime.data.model.VideoDetail @@ -39,6 +40,8 @@ interface TmdbMoviesRemoteSource { suspend fun getByGenres(page: Int, genresId: List): Result, GeneralError> + suspend fun getMovieVideos(movieId: Int): Result, GeneralError> + companion object { const val PAGE_SIZE = 20 // TMDB API default page size } diff --git a/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbMoviesRemoteSourceImpl.kt b/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbMoviesRemoteSourceImpl.kt index 8e9e9dcf..accb7a78 100644 --- a/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbMoviesRemoteSourceImpl.kt +++ b/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbMoviesRemoteSourceImpl.kt @@ -2,6 +2,7 @@ package io.filmtime.data.api.tmdb import io.filmtime.data.model.GeneralError import io.filmtime.data.model.MovieCollection +import io.filmtime.data.model.MovieVideo import io.filmtime.data.model.Person import io.filmtime.data.model.Result import io.filmtime.data.model.VideoDetail @@ -12,6 +13,7 @@ import io.filmtime.data.network.TmdbErrorResponse import io.filmtime.data.network.TmdbMoviesService import io.filmtime.data.network.TmdbVideoListResponse import io.filmtime.data.network.adapter.NetworkResponse +import io.filmtime.data.network.model.Type.Trailer import javax.inject.Inject internal class TmdbMoviesRemoteSourceImpl @Inject constructor( @@ -92,6 +94,7 @@ internal class TmdbMoviesRemoteSourceImpl @Inject constructor( Result.Success(collectionResponse.toCollection()) } } + is NetworkResponse.ApiError -> { val errorResponse = result.body Result.Failure(GeneralError.ApiError(errorResponse.statusMessage, errorResponse.statusCode)) @@ -106,6 +109,29 @@ internal class TmdbMoviesRemoteSourceImpl @Inject constructor( tmdbDiscoverService.getMovies(page, genresId.map { it.toString() }) } + override suspend fun getMovieVideos(movieId: Int): Result, GeneralError> = + when (val result = tmdbMoviesService.getMovieVideos(movieId)) { + is NetworkResponse.ApiError -> { + val errorResponse = result.body + Result.Failure(GeneralError.ApiError(errorResponse.statusMessage, errorResponse.statusCode)) + } + + is NetworkResponse.NetworkError -> Result.Failure(GeneralError.NetworkError) + is NetworkResponse.UnknownError -> Result.Failure(GeneralError.UnknownError(result.error)) + is NetworkResponse.Success -> { + val body = result.body + if (body == null) { + Result.Failure(GeneralError.UnknownError(Throwable("Videos is null"))) + } else { + Result.Success( + body.results + .filter { it.official && it.type == Trailer } + .map { it.toVideos() }, + ) + } + } + } + override suspend fun upcomingMovies( page: Int, ): Result, GeneralError> = diff --git a/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbShowsRemoteSource.kt b/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbShowsRemoteSource.kt index 89cdf412..de19c0a8 100644 --- a/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbShowsRemoteSource.kt +++ b/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbShowsRemoteSource.kt @@ -2,6 +2,7 @@ package io.filmtime.data.api.tmdb import io.filmtime.data.model.EpisodeThumbnail import io.filmtime.data.model.GeneralError +import io.filmtime.data.model.MovieVideo import io.filmtime.data.model.Person import io.filmtime.data.model.Result import io.filmtime.data.model.VideoDetail @@ -39,6 +40,8 @@ interface TmdbShowsRemoteSource { suspend fun getByGenres(page: Int, genresId: List): Result, GeneralError> + suspend fun getShowVideos(showId: Int): Result, GeneralError> + companion object { const val PAGE_SIZE = 20 // TMDB API default page size } diff --git a/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbShowsRemoteSourceImpl.kt b/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbShowsRemoteSourceImpl.kt index bb98b75f..73f3dedb 100644 --- a/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbShowsRemoteSourceImpl.kt +++ b/data/api/tmdb/src/main/java/io/filmtime/data/api/tmdb/TmdbShowsRemoteSourceImpl.kt @@ -2,6 +2,7 @@ package io.filmtime.data.api.tmdb import io.filmtime.data.model.EpisodeThumbnail import io.filmtime.data.model.GeneralError +import io.filmtime.data.model.MovieVideo import io.filmtime.data.model.Person import io.filmtime.data.model.Result import io.filmtime.data.model.VideoDetail @@ -11,6 +12,7 @@ import io.filmtime.data.network.TmdbErrorResponse import io.filmtime.data.network.TmdbShowListResponse import io.filmtime.data.network.TmdbShowsService import io.filmtime.data.network.adapter.NetworkResponse +import io.filmtime.data.network.model.Type.Trailer import javax.inject.Inject class TmdbShowsRemoteSourceImpl @Inject constructor( @@ -122,6 +124,29 @@ class TmdbShowsRemoteSourceImpl @Inject constructor( tmdbDiscoverService.getShows(page, genresId.map { it.toString() }) } + override suspend fun getShowVideos(showId: Int): Result, GeneralError> = + when (val result = tmdbShowsService.getShowVideos(showId)) { + is NetworkResponse.ApiError -> { + val errorResponse = result.body + Result.Failure(GeneralError.ApiError(errorResponse.statusMessage, errorResponse.statusCode)) + } + + is NetworkResponse.NetworkError -> Result.Failure(GeneralError.NetworkError) + is NetworkResponse.UnknownError -> Result.Failure(GeneralError.UnknownError(result.error)) + is NetworkResponse.Success -> { + val body = result.body + if (body == null) { + Result.Failure(GeneralError.UnknownError(Throwable("Videos is null"))) + } else { + Result.Success( + body.results + .filter { it.official && it.type == Trailer } + .map { it.toVideos() }, + ) + } + } + } + override suspend fun similar(showId: Int): Result, GeneralError> = getShowsList { tmdbShowsService.getSimilar(showId) } diff --git a/data/model/src/main/java/io/filmtime/data/model/MovieVideo.kt b/data/model/src/main/java/io/filmtime/data/model/MovieVideo.kt new file mode 100644 index 00000000..ea81f208 --- /dev/null +++ b/data/model/src/main/java/io/filmtime/data/model/MovieVideo.kt @@ -0,0 +1,13 @@ +package io.filmtime.data.model + +data class MovieVideo( + val name: String, + val link: String? = null, + val source: VideoSource? = null, + val key: String, + val posterUrl: String? = null, +) + +enum class VideoSource { + YouTube, +} diff --git a/data/network/src/main/java/io/filmtime/data/network/TmdbMoviesService.kt b/data/network/src/main/java/io/filmtime/data/network/TmdbMoviesService.kt index fde58deb..38559c56 100644 --- a/data/network/src/main/java/io/filmtime/data/network/TmdbMoviesService.kt +++ b/data/network/src/main/java/io/filmtime/data/network/TmdbMoviesService.kt @@ -1,6 +1,7 @@ package io.filmtime.data.network import io.filmtime.data.network.adapter.NetworkResponse +import io.filmtime.data.network.model.TmdbVideosResponse import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query @@ -47,4 +48,9 @@ interface TmdbMoviesService { suspend fun getSimilar( @Path("movie_id") movieId: Int, ): NetworkResponse + + @GET("movie/{movie_id}/videos") + suspend fun getMovieVideos( + @Path("movie_id") movieId: Int, + ): NetworkResponse } diff --git a/data/network/src/main/java/io/filmtime/data/network/TmdbShowsService.kt b/data/network/src/main/java/io/filmtime/data/network/TmdbShowsService.kt index fdc93601..05bf1ee6 100644 --- a/data/network/src/main/java/io/filmtime/data/network/TmdbShowsService.kt +++ b/data/network/src/main/java/io/filmtime/data/network/TmdbShowsService.kt @@ -2,6 +2,7 @@ package io.filmtime.data.network import io.filmtime.data.network.adapter.NetworkResponse import io.filmtime.data.network.model.TmdbSeasonResponse +import io.filmtime.data.network.model.TmdbVideosResponse import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query @@ -56,4 +57,9 @@ interface TmdbShowsService { @Path("series_id") seriesId: Int, @Path("season_number") seasonNumber: Int, ): NetworkResponse + + @GET("tv/{series_id}/videos") + suspend fun getShowVideos( + @Path("series_id") seriesId: Int, + ): NetworkResponse } diff --git a/data/network/src/main/java/io/filmtime/data/network/model/TmdbVideoResponse.kt b/data/network/src/main/java/io/filmtime/data/network/model/TmdbVideoResponse.kt new file mode 100644 index 00000000..4f1f8497 --- /dev/null +++ b/data/network/src/main/java/io/filmtime/data/network/model/TmdbVideoResponse.kt @@ -0,0 +1,51 @@ +package io.filmtime.data.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TmdbVideosResponse( + val id: Long, + val results: List, +) + +@Serializable +data class VideoData( + @SerialName("iso_639_1") + val iso639_1: String, + @SerialName("iso_3166_1") + val iso3166_1: String, + val name: String, + val key: String, + val site: Site, + val size: Long, + val type: Type, + val official: Boolean, + @SerialName("published_at") + val publishedAt: String, + val id: String, +) + +@Serializable +enum class Site(val value: String) { + @SerialName("YouTube") + YouTube("YouTube"), +} + +@Serializable +enum class Type(val value: String) { + @SerialName("Behind the Scenes") + BehindTheScenes("Behind the Scenes"), + + @SerialName("Clip") + Clip("Clip"), + + @SerialName("Featurette") + Featurette("Featurette"), + + @SerialName("Teaser") + Teaser("Teaser"), + + @SerialName("Trailer") + Trailer("Trailer"), +} diff --git a/data/tmdb-movies/src/main/java/io/fimltime/data/tmdb/movies/TmdbMovieRepository.kt b/data/tmdb-movies/src/main/java/io/fimltime/data/tmdb/movies/TmdbMovieRepository.kt index c326657a..28671d3c 100644 --- a/data/tmdb-movies/src/main/java/io/fimltime/data/tmdb/movies/TmdbMovieRepository.kt +++ b/data/tmdb-movies/src/main/java/io/fimltime/data/tmdb/movies/TmdbMovieRepository.kt @@ -3,6 +3,7 @@ package io.fimltime.data.tmdb.movies import androidx.paging.PagingData import io.filmtime.data.model.GeneralError import io.filmtime.data.model.MovieCollection +import io.filmtime.data.model.MovieVideo import io.filmtime.data.model.Person import io.filmtime.data.model.Result import io.filmtime.data.model.VideoDetail @@ -34,6 +35,8 @@ interface TmdbMovieRepository { suspend fun getCollection(collectionId: Int): Result suspend fun getBookmarkedMovies(): Flow> + + suspend fun getMovieVideos(movieId: Int): Result, GeneralError> } enum class MovieListType { diff --git a/data/tmdb-movies/src/main/java/io/fimltime/data/tmdb/movies/TmdbMovieRepositoryImpl.kt b/data/tmdb-movies/src/main/java/io/fimltime/data/tmdb/movies/TmdbMovieRepositoryImpl.kt index c631d98a..85cf512c 100644 --- a/data/tmdb-movies/src/main/java/io/fimltime/data/tmdb/movies/TmdbMovieRepositoryImpl.kt +++ b/data/tmdb-movies/src/main/java/io/fimltime/data/tmdb/movies/TmdbMovieRepositoryImpl.kt @@ -8,11 +8,13 @@ import io.filmtime.data.database.dao.BookmarksDao import io.filmtime.data.database.dao.MovieDetailDao import io.filmtime.data.model.GeneralError import io.filmtime.data.model.MovieCollection +import io.filmtime.data.model.MovieVideo import io.filmtime.data.model.Person import io.filmtime.data.model.Result import io.filmtime.data.model.Result.Failure import io.filmtime.data.model.Result.Success import io.filmtime.data.model.VideoDetail +import io.filmtime.data.model.VideoSource.YouTube import io.filmtime.data.model.VideoThumbnail import io.filmtime.data.model.VideoType.Movie import kotlinx.coroutines.flow.Flow @@ -26,6 +28,11 @@ internal class TmdbMovieRepositoryImpl @Inject constructor( private val bookmarksDao: BookmarksDao, ) : TmdbMovieRepository { + companion object { + const val YOUTUBE_WATCH_PAGE = "https://youtube.com/watch?v=" + const val YOUTUBE_THUMBNAIL_URL = "https://img.youtube.com/vi/{id}/hqdefault.jpg" + } + override suspend fun getMovieDetails(movieId: Int): Flow> = flow { // val localData = movieDao.getMovieByTmdbId(movieId).firstOrNull() // if (localData != null) { @@ -113,4 +120,22 @@ internal class TmdbMovieRepositoryImpl @Inject constructor( movieList } } + + override suspend fun getMovieVideos(movieId: Int): Result, GeneralError> { + val videos = tmdbMoviesRemoteSource.getMovieVideos(movieId) + + return videos.mapSuccess { data -> + data.map { + val source = when (it.source) { + YouTube -> YOUTUBE_WATCH_PAGE + it.key + null -> null + } + val poster = when (it.source) { + YouTube -> YOUTUBE_THUMBNAIL_URL.replace("{id}", it.key) + null -> null + } + it.copy(link = source, posterUrl = poster) + } + } + } } diff --git a/data/tmdb-shows/src/main/java/io/filmtime/data/tmdb/shows/TmdbShowsRepository.kt b/data/tmdb-shows/src/main/java/io/filmtime/data/tmdb/shows/TmdbShowsRepository.kt index 055cb8d6..f27a0a10 100644 --- a/data/tmdb-shows/src/main/java/io/filmtime/data/tmdb/shows/TmdbShowsRepository.kt +++ b/data/tmdb-shows/src/main/java/io/filmtime/data/tmdb/shows/TmdbShowsRepository.kt @@ -3,6 +3,7 @@ package io.filmtime.data.tmdb.shows import androidx.paging.PagingData import io.filmtime.data.model.EpisodeThumbnail import io.filmtime.data.model.GeneralError +import io.filmtime.data.model.MovieVideo import io.filmtime.data.model.Person import io.filmtime.data.model.Result import io.filmtime.data.model.VideoDetail @@ -34,6 +35,8 @@ interface TmdbShowsRepository { suspend fun episodesBySeason(showId: Int, seasonNumber: Int): Result, GeneralError> suspend fun getBookmarkedShows(): Flow> + + suspend fun getVideos(showId: Int): Result, GeneralError> } enum class ShowListType { diff --git a/data/tmdb-shows/src/main/java/io/filmtime/data/tmdb/shows/TmdbShowsRepositoryImpl.kt b/data/tmdb-shows/src/main/java/io/filmtime/data/tmdb/shows/TmdbShowsRepositoryImpl.kt index bb19fdaf..0a6b4912 100644 --- a/data/tmdb-shows/src/main/java/io/filmtime/data/tmdb/shows/TmdbShowsRepositoryImpl.kt +++ b/data/tmdb-shows/src/main/java/io/filmtime/data/tmdb/shows/TmdbShowsRepositoryImpl.kt @@ -7,11 +7,13 @@ import io.filmtime.data.api.tmdb.TmdbShowsRemoteSource import io.filmtime.data.database.dao.BookmarksDao import io.filmtime.data.model.EpisodeThumbnail import io.filmtime.data.model.GeneralError +import io.filmtime.data.model.MovieVideo import io.filmtime.data.model.Person import io.filmtime.data.model.Result import io.filmtime.data.model.Result.Failure import io.filmtime.data.model.Result.Success import io.filmtime.data.model.VideoDetail +import io.filmtime.data.model.VideoSource.YouTube import io.filmtime.data.model.VideoThumbnail import io.filmtime.data.model.VideoType.Show import kotlinx.coroutines.flow.Flow @@ -23,6 +25,11 @@ internal class TmdbShowsRepositoryImpl @Inject constructor( private val bookmarksDao: BookmarksDao, ) : TmdbShowsRepository { + companion object { + const val YOUTUBE_WATCH_PAGE = "https://youtube.com/watch?v=" + const val YOUTUBE_THUMBNAIL_URL = "https://img.youtube.com/vi/{id}/hqdefault.jpg" + } + override suspend fun showDetails(showId: Int): Result = tmdbShowsRemoteSource.showDetails(showId) @@ -92,4 +99,22 @@ internal class TmdbShowsRepositoryImpl @Inject constructor( showsList } } + + override suspend fun getVideos(showId: Int): Result, GeneralError> { + val result = tmdbShowsRemoteSource.getShowVideos(showId) + + return result.mapSuccess { data -> + data.map { + val source = when (it.source) { + YouTube -> YOUTUBE_WATCH_PAGE + it.key + null -> null + } + val poster = when (it.source) { + YouTube -> YOUTUBE_THUMBNAIL_URL.replace("{id}", it.key) + null -> null + } + it.copy(link = source, posterUrl = poster) + } + } + } } diff --git a/domain/tmdb-movies/src/main/java/io/filmtime/domain/tmdb/movies/GetMovieVideosUseCase.kt b/domain/tmdb-movies/src/main/java/io/filmtime/domain/tmdb/movies/GetMovieVideosUseCase.kt new file mode 100644 index 00000000..34c92d7a --- /dev/null +++ b/domain/tmdb-movies/src/main/java/io/filmtime/domain/tmdb/movies/GetMovieVideosUseCase.kt @@ -0,0 +1,10 @@ +package io.filmtime.domain.tmdb.movies + +import io.filmtime.data.model.GeneralError +import io.filmtime.data.model.MovieVideo +import io.filmtime.data.model.Result + +interface GetMovieVideosUseCase { + + suspend operator fun invoke(movieId: Int): Result, GeneralError> +} diff --git a/domain/tmdb-movies/src/main/java/io/filmtime/domain/tmdb/movies/di/TmdbMoviesUseCaseModule.kt b/domain/tmdb-movies/src/main/java/io/filmtime/domain/tmdb/movies/di/TmdbMoviesUseCaseModule.kt index 673a91c4..8202f9a8 100644 --- a/domain/tmdb-movies/src/main/java/io/filmtime/domain/tmdb/movies/di/TmdbMoviesUseCaseModule.kt +++ b/domain/tmdb-movies/src/main/java/io/filmtime/domain/tmdb/movies/di/TmdbMoviesUseCaseModule.kt @@ -8,6 +8,7 @@ import io.filmtime.domain.tmdb.movies.GetBookmarkedMoviesUseCase import io.filmtime.domain.tmdb.movies.GetMovieCollectionUseCase import io.filmtime.domain.tmdb.movies.GetMovieCreditsUseCase import io.filmtime.domain.tmdb.movies.GetMovieDetailsUseCase +import io.filmtime.domain.tmdb.movies.GetMovieVideosUseCase import io.filmtime.domain.tmdb.movies.GetMoviesByGenreUseCase import io.filmtime.domain.tmdb.movies.GetMoviesListUseCase import io.filmtime.domain.tmdb.movies.GetSimilarMoviesUseCase @@ -16,6 +17,7 @@ import io.filmtime.domain.tmdb.movies.impl.GetBookmarkedMoviesUseCaseImpl import io.filmtime.domain.tmdb.movies.impl.GetMovieCollectionUseCaseImpl import io.filmtime.domain.tmdb.movies.impl.GetMovieCreditsUseCaseImpl import io.filmtime.domain.tmdb.movies.impl.GetMovieDetailsUseCaseImpl +import io.filmtime.domain.tmdb.movies.impl.GetMovieVideosUseCaseImpl import io.filmtime.domain.tmdb.movies.impl.GetMoviesByGenreUseCaseImpl import io.filmtime.domain.tmdb.movies.impl.GetMoviesListUseCaseImpl import io.filmtime.domain.tmdb.movies.impl.GetSimilarMoviesUseCaseImpl @@ -48,4 +50,7 @@ internal abstract class TmdbMoviesUseCaseModule { @Binds abstract fun bindGetBookmarkedMoviesUseCase(impl: GetBookmarkedMoviesUseCaseImpl): GetBookmarkedMoviesUseCase + + @Binds + abstract fun bindGetMovieVideosUseCase(impl: GetMovieVideosUseCaseImpl): GetMovieVideosUseCase } diff --git a/domain/tmdb-movies/src/main/java/io/filmtime/domain/tmdb/movies/impl/GetMovieVideosUseCaseImpl.kt b/domain/tmdb-movies/src/main/java/io/filmtime/domain/tmdb/movies/impl/GetMovieVideosUseCaseImpl.kt new file mode 100644 index 00000000..47453f2a --- /dev/null +++ b/domain/tmdb-movies/src/main/java/io/filmtime/domain/tmdb/movies/impl/GetMovieVideosUseCaseImpl.kt @@ -0,0 +1,15 @@ +package io.filmtime.domain.tmdb.movies.impl + +import io.filmtime.data.model.GeneralError +import io.filmtime.data.model.MovieVideo +import io.filmtime.data.model.Result +import io.filmtime.domain.tmdb.movies.GetMovieVideosUseCase +import io.fimltime.data.tmdb.movies.TmdbMovieRepository +import javax.inject.Inject + +internal class GetMovieVideosUseCaseImpl @Inject constructor( + private val repository: TmdbMovieRepository, +) : GetMovieVideosUseCase { + override suspend fun invoke(movieId: Int): Result, GeneralError> = + repository.getMovieVideos(movieId) +} diff --git a/domain/tmdb-shows/src/main/java/io/filmtime/domain/tmdb/shows/GetShowVideosUseCase.kt b/domain/tmdb-shows/src/main/java/io/filmtime/domain/tmdb/shows/GetShowVideosUseCase.kt new file mode 100644 index 00000000..2e8be953 --- /dev/null +++ b/domain/tmdb-shows/src/main/java/io/filmtime/domain/tmdb/shows/GetShowVideosUseCase.kt @@ -0,0 +1,10 @@ +package io.filmtime.domain.tmdb.shows + +import io.filmtime.data.model.GeneralError +import io.filmtime.data.model.MovieVideo +import io.filmtime.data.model.Result + +interface GetShowVideosUseCase { + + suspend operator fun invoke(movieId: Int): Result, GeneralError> +} diff --git a/domain/tmdb-shows/src/main/java/io/filmtime/domain/tmdb/shows/di/TmdbShowsDomainModule.kt b/domain/tmdb-shows/src/main/java/io/filmtime/domain/tmdb/shows/di/TmdbShowsDomainModule.kt index 9c323069..9b09de27 100644 --- a/domain/tmdb-shows/src/main/java/io/filmtime/domain/tmdb/shows/di/TmdbShowsDomainModule.kt +++ b/domain/tmdb-shows/src/main/java/io/filmtime/domain/tmdb/shows/di/TmdbShowsDomainModule.kt @@ -8,6 +8,7 @@ import io.filmtime.domain.tmdb.shows.GetBookmarkedShowsUseCase import io.filmtime.domain.tmdb.shows.GetEpisodesBySeasonUseCase import io.filmtime.domain.tmdb.shows.GetShowCreditsUseCase import io.filmtime.domain.tmdb.shows.GetShowDetailsUseCase +import io.filmtime.domain.tmdb.shows.GetShowVideosUseCase import io.filmtime.domain.tmdb.shows.GetShowsByGenreUseCase import io.filmtime.domain.tmdb.shows.GetShowsListUseCase import io.filmtime.domain.tmdb.shows.GetSimilarShowsUseCase @@ -17,6 +18,7 @@ import io.filmtime.domain.tmdb.shows.impl.GetBookmarkedShowsUseCaseImpl import io.filmtime.domain.tmdb.shows.impl.GetEpisodesBySeasonUseCaseImpl import io.filmtime.domain.tmdb.shows.impl.GetShowCreditsUseCaseImpl import io.filmtime.domain.tmdb.shows.impl.GetShowDetailsUseCaseImpl +import io.filmtime.domain.tmdb.shows.impl.GetShowVideosUseCaseImpl import io.filmtime.domain.tmdb.shows.impl.GetShowsByGenreUseCaseImpl import io.filmtime.domain.tmdb.shows.impl.GetShowsListUseCaseImpl import io.filmtime.domain.tmdb.shows.impl.GetSimilarShowsUseCaseImpl @@ -53,4 +55,7 @@ internal abstract class TmdbShowsDomainModule { @Binds abstract fun bindGetBookmarkedShowsUseCase(impl: GetBookmarkedShowsUseCaseImpl): GetBookmarkedShowsUseCase + + @Binds + abstract fun bindGetShowVideosUseCase(impl: GetShowVideosUseCaseImpl): GetShowVideosUseCase } diff --git a/domain/tmdb-shows/src/main/java/io/filmtime/domain/tmdb/shows/impl/GetShowVideosUseCaseImpl.kt b/domain/tmdb-shows/src/main/java/io/filmtime/domain/tmdb/shows/impl/GetShowVideosUseCaseImpl.kt new file mode 100644 index 00000000..44fa6b16 --- /dev/null +++ b/domain/tmdb-shows/src/main/java/io/filmtime/domain/tmdb/shows/impl/GetShowVideosUseCaseImpl.kt @@ -0,0 +1,15 @@ +package io.filmtime.domain.tmdb.shows.impl + +import io.filmtime.data.model.GeneralError +import io.filmtime.data.model.MovieVideo +import io.filmtime.data.model.Result +import io.filmtime.data.tmdb.shows.TmdbShowsRepository +import io.filmtime.domain.tmdb.shows.GetShowVideosUseCase +import javax.inject.Inject + +internal class GetShowVideosUseCaseImpl @Inject constructor( + private val repository: TmdbShowsRepository, +) : GetShowVideosUseCase { + override suspend fun invoke(movieId: Int): Result, GeneralError> = + repository.getVideos(movieId) +} diff --git a/feature/movie-detail/build.gradle.kts b/feature/movie-detail/build.gradle.kts index a9d7989d..61a6d1d6 100644 --- a/feature/movie-detail/build.gradle.kts +++ b/feature/movie-detail/build.gradle.kts @@ -9,7 +9,6 @@ android { dependencies { implementation(project(":core:browser")) - implementation(project(":data:model")) implementation(project(":domain:tmdb-movies")) diff --git a/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailScreen.kt b/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailScreen.kt index 3dbbc2ba..e0c74905 100644 --- a/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailScreen.kt +++ b/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailScreen.kt @@ -39,6 +39,7 @@ import io.filmtime.core.ui.common.componnents.VideoInfo import io.filmtime.core.ui.common.componnents.VideoSectionRow import io.filmtime.core.ui.common.componnents.VideoThumbnailInfo import io.filmtime.core.ui.common.componnents.VideoThumbnailPoster +import io.filmtime.core.ui.common.componnents.VideoTrailerRow import io.filmtime.data.model.Preview import io.filmtime.data.model.PreviewMovie import io.filmtime.data.model.Ratings @@ -69,7 +70,7 @@ fun MovieDetailScreen( MovieDetailScreen( state = state, - onRetry = viewModel::loadMovieDetail, + onRetry = viewModel::reload, onMovieClick = onMovieClick, onAddBookmark = viewModel::addBookmark, onRemoveBookmark = viewModel::removeBookmark, @@ -90,6 +91,7 @@ fun MovieDetailScreen( onGenreClick: (VideoGenre, VideoType) -> Unit, ) { val videoDetail = state.videoDetail + val context = LocalContext.current if (state.isLoading) { CircularProgressIndicator( @@ -125,6 +127,17 @@ fun MovieDetailScreen( videoType = VideoType.Movie, ) }, + videos = { + state.videos?.let { videos -> + VideoTrailerRow( + onClick = { + context.openUrl(it, isExternal = false) + }, + items = videos, + isLoading = state.isTrailersLoading, + ) + } + }, collections = { state.collection?.let { collections -> VideoSectionRow( @@ -174,6 +187,7 @@ private fun MovieDetailContent( credits: @Composable () -> Unit, similar: @Composable () -> Unit, collections: @Composable () -> Unit, + videos: @Composable () -> Unit, traktHistoryButton: @Composable RowScope.() -> Unit, ) { var imageHeight by remember { mutableIntStateOf(4000) } @@ -236,6 +250,11 @@ private fun MovieDetailContent( ) { collections() } + item( + key = "videos", + ) { + videos() + } item( key = "similar", ) { @@ -297,6 +316,9 @@ private fun MovieDetailScreenPreview() { collections = { Text("Collections goes here") }, + videos = { + Text("Videos section") + }, ) } } diff --git a/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailState.kt b/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailState.kt index 675ff579..6172c3f1 100644 --- a/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailState.kt +++ b/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailState.kt @@ -2,6 +2,7 @@ package io.filmtime.feature.movie.detail import io.filmtime.core.ui.common.UiMessage import io.filmtime.data.model.MovieCollection +import io.filmtime.data.model.MovieVideo import io.filmtime.data.model.Ratings import io.filmtime.data.model.StreamInfo import io.filmtime.data.model.VideoDetail @@ -16,4 +17,6 @@ data class MovieDetailState( val isStreamLoading: Boolean = false, val streamInfo: StreamInfo? = null, val error: UiMessage? = null, + val videos: List? = null, + val isTrailersLoading: Boolean = false, ) diff --git a/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailViewModel.kt b/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailViewModel.kt index 640f00e7..3a2123f9 100644 --- a/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailViewModel.kt +++ b/feature/movie-detail/src/main/java/io/filmtime/feature/movie/detail/MovieDetailViewModel.kt @@ -12,6 +12,7 @@ import io.filmtime.domain.bookmarks.ObserveBookmarkUseCase import io.filmtime.domain.stream.GetStreamInfoUseCase import io.filmtime.domain.tmdb.movies.GetMovieCollectionUseCase import io.filmtime.domain.tmdb.movies.GetMovieDetailsUseCase +import io.filmtime.domain.tmdb.movies.GetMovieVideosUseCase import io.filmtime.domain.trakt.GetRatingsUseCase import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -32,6 +33,7 @@ class MovieDetailViewModel @Inject constructor( private val observeBookmark: ObserveBookmarkUseCase, private val getRatings: GetRatingsUseCase, private val getCollection: GetMovieCollectionUseCase, + private val getMovieVideos: GetMovieVideosUseCase, ) : ViewModel() { private val videoId: Int = savedStateHandle["video_id"] ?: throw IllegalStateException("videoId is required") @@ -44,9 +46,15 @@ class MovieDetailViewModel @Inject constructor( init { loadMovieDetail() observeBookmark() + loadVideos() } - fun loadMovieDetail() = viewModelScope.launch { + fun reload() { + loadMovieDetail() + loadVideos() + } + + private fun loadMovieDetail() = viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, error = null) getMovieDetail(videoId) @@ -123,4 +131,22 @@ class MovieDetailViewModel @Inject constructor( fun removeBookmark() = viewModelScope.launch { deleteBookmark(videoId, Movie) } + + private fun loadVideos() = viewModelScope.launch { + _state.update { state -> state.copy(isTrailersLoading = true) } + getMovieVideos(videoId) + .fold( + onSuccess = { + _state.update { state -> + state.copy( + videos = it, + isTrailersLoading = false, + ) + } + }, + onFailure = { + _state.update { state -> state.copy(isTrailersLoading = false) } + }, + ) + } } diff --git a/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailScreen.kt b/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailScreen.kt index 34eea074..ffa6a85f 100644 --- a/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailScreen.kt +++ b/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailScreen.kt @@ -35,6 +35,7 @@ import io.filmtime.core.ui.common.componnents.VideoDescription import io.filmtime.core.ui.common.componnents.VideoInfo import io.filmtime.core.ui.common.componnents.VideoThumbnailInfo import io.filmtime.core.ui.common.componnents.VideoThumbnailPoster +import io.filmtime.core.ui.common.componnents.VideoTrailerRow import io.filmtime.data.model.EpisodeThumbnail import io.filmtime.data.model.Preview import io.filmtime.data.model.PreviewShow @@ -86,6 +87,7 @@ private fun ShowDetailScreen( removeFromHistory: (EpisodeThumbnail) -> Unit, ) { val videoDetail = state.videoDetail + val context = LocalContext.current if (state.isLoading) { CircularProgressIndicator( @@ -133,6 +135,17 @@ private fun ShowDetailScreen( onVideoClick = onShowClick, ) }, + videos = { + state.videos?.let { videos -> + VideoTrailerRow( + items = videos, + isLoading = state.isTrailersLoading, + onClick = { link -> + context.openUrl(link, isExternal = false) + }, + ) + } + }, ) } } @@ -153,6 +166,7 @@ private fun ShowDetailContent( primaryButton: @Composable () -> Unit, credits: @Composable () -> Unit, similar: @Composable () -> Unit, + videos: @Composable () -> Unit, ) { var imageHeight by remember { mutableIntStateOf(4000) } val density = LocalDensity.current @@ -225,6 +239,11 @@ private fun ShowDetailContent( ) { credits() } + item( + key = "videos", + ) { + videos() + } item( key = "similar", ) { @@ -285,6 +304,9 @@ private fun ShowDetailScreenPreview() { similar = { Text("Similar goes here") }, + videos = { + Text("Videos goes here") + }, ) } } diff --git a/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailState.kt b/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailState.kt index ce527f91..c0d938c7 100644 --- a/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailState.kt +++ b/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailState.kt @@ -2,6 +2,7 @@ package io.filmtime.feature.show.detail import io.filmtime.core.ui.common.UiMessage import io.filmtime.data.model.EpisodeThumbnail +import io.filmtime.data.model.MovieVideo import io.filmtime.data.model.Ratings import io.filmtime.data.model.VideoDetail @@ -13,6 +14,8 @@ internal data class ShowDetailState( val ratings: Ratings? = null, val message: String? = null, val error: UiMessage? = null, + val videos: List? = null, + val isTrailersLoading: Boolean = false, ) internal data class SeasonsState( diff --git a/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailViewModel.kt b/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailViewModel.kt index 62211343..f5b71a9f 100644 --- a/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailViewModel.kt +++ b/feature/show-detail/src/main/java/io/filmtime/feature/show/detail/ShowDetailViewModel.kt @@ -14,6 +14,7 @@ import io.filmtime.domain.bookmarks.DeleteBookmarkUseCase import io.filmtime.domain.bookmarks.ObserveBookmarkUseCase import io.filmtime.domain.tmdb.shows.GetEpisodesBySeasonUseCase import io.filmtime.domain.tmdb.shows.GetShowDetailsUseCase +import io.filmtime.domain.tmdb.shows.GetShowVideosUseCase import io.filmtime.domain.trakt.GetRatingsUseCase import io.filmtime.domain.trakt.history.AddEpisodeToHistoryUseCase import io.filmtime.domain.trakt.history.IsShowWatchedUseCase @@ -38,6 +39,7 @@ internal class ShowDetailViewModel @Inject constructor( private val isShowWatched: IsShowWatchedUseCase, private val addToHistory: AddEpisodeToHistoryUseCase, private val removeFromHistory: RemoveEpisodeFromHistoryUseCase, + private val getShowVideos: GetShowVideosUseCase, ) : ViewModel() { private val videoId: Int = savedStateHandle["video_id"] ?: throw IllegalStateException("videoId is required") @@ -47,6 +49,7 @@ internal class ShowDetailViewModel @Inject constructor( init { observeBookmark() load() + loadVideos() } fun load() = viewModelScope.launch { @@ -260,4 +263,22 @@ internal class ShowDetailViewModel @Inject constructor( } } } + + private fun loadVideos() = viewModelScope.launch { + _state.update { state -> state.copy(isTrailersLoading = true) } + getShowVideos(videoId) + .fold( + onSuccess = { + _state.update { state -> + state.copy( + isTrailersLoading = false, + videos = it, + ) + } + }, + onFailure = { + _state.update { state -> state.copy(isTrailersLoading = false) } + }, + ) + } }