Skip to content

Commit

Permalink
#115 Display trailers for movies and shows
Browse files Browse the repository at this point in the history
Feature Movie/Show trailers
  • Loading branch information
moallemi authored Sep 23, 2024
2 parents cd41175 + 3295f07 commit 74d23e9
Show file tree
Hide file tree
Showing 29 changed files with 502 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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<MovieVideo>,
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,
)
}
}
}
}
}
1 change: 1 addition & 0 deletions core/ui/common/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
<string name="unknown_api_error">Unknown API error</string>
<string name="core_ui_cast_crew"><![CDATA[Cast & Crew]]></string>
<string name="core_ui_movie_collection">Collection</string>
<string name="core_ui_trailers_title">Trailers</string>
<string name="core_ui_device_does_not_support_this_action">Your device doesn\'t support this action.</string>
</resources>
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,6 +40,8 @@ interface TmdbMoviesRemoteSource {

suspend fun getByGenres(page: Int, genresId: List<Long>): Result<List<VideoThumbnail>, GeneralError>

suspend fun getMovieVideos(movieId: Int): Result<List<MovieVideo>, GeneralError>

companion object {
const val PAGE_SIZE = 20 // TMDB API default page size
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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))
Expand All @@ -106,6 +109,29 @@ internal class TmdbMoviesRemoteSourceImpl @Inject constructor(
tmdbDiscoverService.getMovies(page, genresId.map { it.toString() })
}

override suspend fun getMovieVideos(movieId: Int): Result<List<MovieVideo>, 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<List<VideoThumbnail>, GeneralError> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,6 +40,8 @@ interface TmdbShowsRemoteSource {

suspend fun getByGenres(page: Int, genresId: List<Long>): Result<List<VideoThumbnail>, GeneralError>

suspend fun getShowVideos(showId: Int): Result<List<MovieVideo>, GeneralError>

companion object {
const val PAGE_SIZE = 20 // TMDB API default page size
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -122,6 +124,29 @@ class TmdbShowsRemoteSourceImpl @Inject constructor(
tmdbDiscoverService.getShows(page, genresId.map { it.toString() })
}

override suspend fun getShowVideos(showId: Int): Result<List<MovieVideo>, 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<List<VideoThumbnail>, GeneralError> =
getShowsList { tmdbShowsService.getSimilar(showId) }

Expand Down
13 changes: 13 additions & 0 deletions data/model/src/main/java/io/filmtime/data/model/MovieVideo.kt
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -47,4 +48,9 @@ interface TmdbMoviesService {
suspend fun getSimilar(
@Path("movie_id") movieId: Int,
): NetworkResponse<TmdbVideoListResponse, TmdbErrorResponse>

@GET("movie/{movie_id}/videos")
suspend fun getMovieVideos(
@Path("movie_id") movieId: Int,
): NetworkResponse<TmdbVideosResponse, TmdbErrorResponse>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,4 +57,9 @@ interface TmdbShowsService {
@Path("series_id") seriesId: Int,
@Path("season_number") seasonNumber: Int,
): NetworkResponse<TmdbSeasonResponse, TmdbErrorResponse>

@GET("tv/{series_id}/videos")
suspend fun getShowVideos(
@Path("series_id") seriesId: Int,
): NetworkResponse<TmdbVideosResponse, TmdbErrorResponse>
}
Original file line number Diff line number Diff line change
@@ -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<VideoData>,
)

@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"),
}
Loading

0 comments on commit 74d23e9

Please sign in to comment.