Netflix 2025 UI/UX modernization: colors, themes, card animations, Glide TV optimization

This commit is contained in:
tvmon-dev
2026-04-16 13:26:47 +09:00
parent 3d6437be35
commit a2677a3294
10 changed files with 244 additions and 125 deletions

View File

@@ -63,6 +63,9 @@ dependencies {
implementation 'androidx.tv:tv-material:1.0.0-alpha10'
implementation 'com.google.android.material:material:1.11.0'
// LeakCanary for memory leak detection (TV-optimized with Toast notifications)
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'

View File

@@ -1,6 +1,7 @@
package com.example.tvmon
import android.app.Application
import android.util.Log
import com.example.tvmon.di.appModules
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
@@ -8,14 +9,20 @@ import org.koin.core.context.startKoin
import org.koin.core.logger.Level
class TvmonApplication : Application() {
companion object {
private const val TAG = "TvmonApplication"
}
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger(Level.ERROR)
androidContext(this@TvmonApplication)
modules(appModules)
}
Log.d(TAG, "TvmonApplication initialized")
}
}
}

View File

@@ -3,17 +3,30 @@ package com.example.tvmon
import android.content.Context
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.load.engine.cache.LruResourceCache
import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions
@GlideModule
class TvmonGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
val memoryCacheSizeBytes = 1024 * 1024 * 50
// TV-optimized memory cache: 30MB (lower than phone due to limited RAM)
val memoryCacheSizeBytes = 1024 * 1024 * 30
builder.setMemoryCache(LruResourceCache(memoryCacheSizeBytes.toLong()))
val diskCacheSizeBytes = 1024L * 1024L * 200L
// Disk cache: 150MB
val diskCacheSizeBytes = 1024L * 1024L * 150L
builder.setDiskCache(InternalCacheDiskCacheFactory(context, "image_cache", diskCacheSizeBytes))
// Use RGB_565 for lower memory usage (good quality for TV)
builder.setDefaultRequestOptions(
RequestOptions()
.format(DecodeFormat.PREFER_RGB_565)
.disallowHardwareConfig() // Avoid hardware bitmaps that can cause issues on TV
)
}
override fun isManifestParsingEnabled(): Boolean = false
}

View File

@@ -86,7 +86,7 @@ class DetailsFragment : DetailsSupportFragment() {
Log.w(TAG, "setupDetails: Setting up details UI")
val presenterSelector = ClassPresenterSelector()
val detailsPresenter = FullWidthDetailsOverviewRowPresenter(DetailsDescriptionPresenter())
detailsPresenter.actionsBackgroundColor = ContextCompat.getColor(requireContext(), R.color.primary)
detailsPresenter.actionsBackgroundColor = ContextCompat.getColor(requireContext(), R.color.accent)
detailsPresenter.backgroundColor = ContextCompat.getColor(requireContext(), R.color.detail_background)
presenterSelector.addClassPresenter(DetailsOverviewRow::class.java, detailsPresenter)
presenterSelector.addClassPresenter(ListRow::class.java, ListRowPresenter())

View File

@@ -46,26 +46,26 @@ class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemV
}
}
private fun setupUI() {
private fun setupUI() {
Log.w(TAG, "setupUI: Setting up UI")
headersState = HEADERS_ENABLED
title = getString(R.string.browse_title)
brandColor = ContextCompat.getColor(requireContext(), R.color.primary)
searchAffordanceColor = ContextCompat.getColor(requireContext(), R.color.search_opaque)
brandColor = ContextCompat.getColor(requireContext(), R.color.accent)
searchAffordanceColor = ContextCompat.getColor(requireContext(), R.color.accent)
adapter = rowsAdapter
onItemViewClickedListener = this
onItemViewSelectedListener = this
setOnSearchClickedListener {
val activity = requireActivity() as? MainActivity
activity?.showSearchDialog { query ->
val intent = Intent(requireContext(), com.example.tvmon.ui.search.SearchActivity::class.java).apply {
putExtra("initial_query", query)
setOnSearchClickedListener {
val activity = requireActivity() as? MainActivity
activity?.showSearchDialog { query ->
val intent = Intent(requireContext(), com.example.tvmon.ui.search.SearchActivity::class.java).apply {
putExtra("initial_query", query)
}
startActivity(intent)
}
}
startActivity(intent)
}
}
Log.w(TAG, "setupUI: UI setup complete, adapter set")
}

View File

@@ -2,6 +2,8 @@ package com.example.tvmon.ui.presenter
import android.view.View
import android.view.ViewGroup
import android.view.animation.OvershootInterpolator
import androidx.core.content.ContextCompat
import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter
import com.bumptech.glide.Glide
@@ -11,107 +13,150 @@ import com.example.tvmon.data.model.Category
class ContentCardPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply {
isFocusable = true
isFocusableInTouchMode = true
setBackgroundColor(parent.context.getColor(R.color.default_background))
companion object {
private const val FOCUS_SCALE = 1.08f
private const val FOCUS_TRANSLATION_Z = 8f
private const val ANIMATION_DURATION = 150L
}
return ViewHolder(cardView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val content = item as Content
val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources
cardView.titleText = content.title
cardView.contentText = null
val width = res.getDimensionPixelSize(R.dimen.card_width)
val height = res.getDimensionPixelSize(R.dimen.card_height)
cardView.setMainImageDimensions(width, height)
if (content.thumbnail.isNotBlank()) {
Glide.with(cardView.context)
.load(content.thumbnail)
.error(R.drawable.default_background)
.placeholder(R.drawable.default_background)
.centerCrop()
.into(cardView.mainImageView)
} else {
cardView.mainImageView.setImageResource(R.drawable.default_background)
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply {
isFocusable = true
isFocusableInTouchMode = true
setBackgroundColor(ContextCompat.getColor(context, R.color.default_background))
setOnFocusChangeListener { view, hasFocus ->
animateFocus(view, hasFocus)
}
}
return ViewHolder(cardView)
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.badgeImage = null
cardView.mainImage = null
}
private fun animateFocus(view: View, hasFocus: Boolean) {
if (hasFocus) {
view.animate()
.scaleX(FOCUS_SCALE)
.scaleY(FOCUS_SCALE)
.translationZ(FOCUS_TRANSLATION_Z)
.setDuration(ANIMATION_DURATION)
.setInterpolator(OvershootInterpolator(1.2f))
.start()
view.setBackgroundColor(ContextCompat.getColor(view.context, R.color.focused_bg))
} else {
view.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationZ(0f)
.setDuration(ANIMATION_DURATION)
.setInterpolator(OvershootInterpolator(1.2f))
.start()
view.setBackgroundColor(ContextCompat.getColor(view.context, R.color.default_background))
}
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val content = item as Content
val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources
cardView.titleText = content.title
cardView.contentText = null
val width = res.getDimensionPixelSize(R.dimen.card_width)
val height = res.getDimensionPixelSize(R.dimen.card_height)
cardView.setMainImageDimensions(width, height)
if (content.thumbnail.isNotBlank()) {
Glide.with(cardView.context)
.load(content.thumbnail)
.error(R.drawable.default_background)
.placeholder(R.drawable.default_background)
.centerCrop()
.into(cardView.mainImageView)
} else {
cardView.mainImageView.setImageResource(R.drawable.default_background)
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.badgeImage = null
cardView.mainImage = null
}
}
class SearchCardPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply {
isFocusable = true
isFocusableInTouchMode = true
setBackgroundColor(parent.context.getColor(R.color.default_background))
cardType = ImageCardView.CARD_TYPE_INFO_UNDER
infoVisibility = View.VISIBLE
companion object {
private const val FOCUS_SCALE = 1.1f
private const val FOCUS_TRANSLATION_Z = 10f
private const val ANIMATION_DURATION = 100L
}
cardView.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus) {
v.animate()
.scaleX(1.1f)
.scaleY(1.1f)
.translationZ(10f)
.setDuration(100)
.start()
} else {
v.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationZ(0f)
.setDuration(100)
.start()
}
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply {
isFocusable = true
isFocusableInTouchMode = true
setBackgroundColor(ContextCompat.getColor(context, R.color.default_background))
cardType = ImageCardView.CARD_TYPE_INFO_UNDER
infoVisibility = View.VISIBLE
setOnFocusChangeListener { view, hasFocus ->
animateFocus(view, hasFocus)
}
}
return ViewHolder(cardView)
}
return ViewHolder(cardView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val content = item as Content
val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources
cardView.titleText = content.title
cardView.contentText = content.category
val width = res.getDimensionPixelSize(R.dimen.card_width)
val height = res.getDimensionPixelSize(R.dimen.card_height)
cardView.setMainImageDimensions(width, height)
if (content.thumbnail.isNotBlank()) {
Glide.with(cardView.context)
.load(content.thumbnail)
.error(R.drawable.default_background)
.placeholder(R.drawable.default_background)
.centerCrop()
.into(cardView.mainImageView)
} else {
cardView.mainImageView.setImageResource(R.drawable.default_background)
private fun animateFocus(view: View, hasFocus: Boolean) {
if (hasFocus) {
view.animate()
.scaleX(FOCUS_SCALE)
.scaleY(FOCUS_SCALE)
.translationZ(FOCUS_TRANSLATION_Z)
.setDuration(ANIMATION_DURATION)
.setInterpolator(OvershootInterpolator(1.2f))
.start()
view.setBackgroundColor(ContextCompat.getColor(view.context, R.color.focused_bg))
} else {
view.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationZ(0f)
.setDuration(ANIMATION_DURATION)
.setInterpolator(OvershootInterpolator(1.2f))
.start()
view.setBackgroundColor(ContextCompat.getColor(view.context, R.color.default_background))
}
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.badgeImage = null
cardView.mainImage = null
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val content = item as Content
val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources
cardView.titleText = content.title
cardView.contentText = content.category
val width = res.getDimensionPixelSize(R.dimen.card_width)
val height = res.getDimensionPixelSize(R.dimen.card_height)
cardView.setMainImageDimensions(width, height)
if (content.thumbnail.isNotBlank()) {
Glide.with(cardView.context)
.load(content.thumbnail)
.error(R.drawable.default_background)
.placeholder(R.drawable.default_background)
.centerCrop()
.into(cardView.mainImageView)
} else {
cardView.mainImageView.setImageResource(R.drawable.default_background)
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.badgeImage = null
cardView.mainImage = null
}
}
class CategoryCardPresenter : Presenter() {

View File

@@ -1,6 +1,9 @@
package com.example.tvmon.ui.presenter
import android.view.View
import android.view.ViewGroup
import android.view.animation.OvershootInterpolator
import androidx.core.content.ContextCompat
import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter
import com.example.tvmon.R
@@ -10,31 +13,62 @@ class EpisodePresenter(
private val watchedEpisodeUrl: String? = null
) : Presenter() {
companion object {
private const val FOCUS_SCALE = 1.08f
private const val FOCUS_TRANSLATION_Z = 8f
private const val ANIMATION_DURATION = 150L
}
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply {
isFocusable = true
isFocusableInTouchMode = true
setBackgroundColor(parent.context.getColor(R.color.default_background))
setBackgroundColor(ContextCompat.getColor(context, R.color.default_background))
setOnFocusChangeListener { view, hasFocus ->
animateFocus(view, hasFocus)
}
}
return ViewHolder(cardView)
}
private fun animateFocus(view: View, hasFocus: Boolean) {
if (hasFocus) {
view.animate()
.scaleX(FOCUS_SCALE)
.scaleY(FOCUS_SCALE)
.translationZ(FOCUS_TRANSLATION_Z)
.setDuration(ANIMATION_DURATION)
.setInterpolator(OvershootInterpolator(1.2f))
.start()
view.setBackgroundColor(ContextCompat.getColor(view.context, R.color.focused_bg))
} else {
view.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationZ(0f)
.setDuration(ANIMATION_DURATION)
.setInterpolator(OvershootInterpolator(1.2f))
.start()
view.setBackgroundColor(ContextCompat.getColor(view.context, R.color.default_background))
}
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val episode = item as Episode
val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources
val ctx = cardView.context
cardView.titleText = episode.number
cardView.contentText = episode.title
if (episode.url == watchedEpisodeUrl) {
cardView.setBackgroundColor(res.getColor(R.color.accent))
cardView.setBackgroundColor(ContextCompat.getColor(ctx, R.color.accent))
} else {
cardView.setBackgroundColor(res.getColor(R.color.default_background))
cardView.setBackgroundColor(ContextCompat.getColor(ctx, R.color.default_background))
}
val width = res.getDimensionPixelSize(R.dimen.episode_card_width)
val height = res.getDimensionPixelSize(R.dimen.episode_card_height)
val width = ctx.resources.getDimensionPixelSize(R.dimen.episode_card_width)
val height = ctx.resources.getDimensionPixelSize(R.dimen.episode_card_height)
cardView.setMainImageDimensions(width, height)
cardView.mainImageView.setImageResource(R.drawable.episode_placeholder)

View File

@@ -1,10 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#1F1F1F</color>
<!-- Netflix 2025 Color Scheme -->
<color name="primary">#000000</color>
<color name="primary_dark">#000000</color>
<color name="accent">#FF6B6B</color>
<color name="default_background">#282828</color>
<color name="category_background">#3A3A3A</color>
<color name="detail_background">#1F1F1F</color>
<color name="accent">#E50914</color>
<color name="netflix_red">#E50914</color>
<color name="netflix_black">#000000</color>
<color name="netflix_dark_gray">#141414</color>
<color name="netflix_medium_gray">#333333</color>
<color name="netflix_light_gray">#666666</color>
<color name="default_background">#141414</color>
<color name="category_background">#1F1F1F</color>
<color name="detail_background">#000000</color>
<color name="search_opaque">#AAFFFFFF</color>
<color name="focused_card_border">#FFFFFF</color>
<color name="card_overlay">#B3000000</color>
<color name="focused_bg">#333333</color>
<color name="card_focused_bg">#2A2A2A</color>
</resources>

View File

@@ -1,14 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="card_width">180dp</dimen>
<dimen name="card_height">240dp</dimen>
<!-- Netflix-style larger cards -->
<dimen name="card_width">200dp</dimen>
<dimen name="card_height">280dp</dimen>
<dimen name="category_card_width">240dp</dimen>
<dimen name="category_card_height">160dp</dimen>
<dimen name="episode_card_width">200dp</dimen>
<dimen name="episode_card_height">120dp</dimen>
<dimen name="episode_card_width">220dp</dimen>
<dimen name="episode_card_height">130dp</dimen>
<dimen name="cast_card_width">120dp</dimen>
<dimen name="cast_card_height">160dp</dimen>
<!-- Netflix-style spacing -->
<dimen name="row_vertical_spacing">28dp</dimen>
<dimen name="card_horizontal_spacing">12dp</dimen>
<dimen name="screen_margin">48dp</dimen>
</resources>

View File

@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Netflix 2025 Theme - Dark cinematic style -->
<style name="Theme.Tvmon" parent="Theme.Leanback">
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/accent</item>
<item name="android:windowBackground">@color/default_background</item>
<item name="android:windowBackground">@color/netflix_black</item>
</style>
<style name="Theme.Tvmon.Playback" parent="Theme.Leanback">
@@ -17,6 +18,6 @@
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/accent</item>
<item name="android:windowBackground">@color/default_background</item>
<item name="android:windowBackground">@color/netflix_black</item>
</style>
</resources>