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 'androidx.tv:tv-material:1.0.0-alpha10'
implementation 'com.google.android.material:material:1.11.0' 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-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'

View File

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

View File

@@ -3,17 +3,30 @@ package com.example.tvmon
import android.content.Context import android.content.Context
import com.bumptech.glide.GlideBuilder import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule 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.InternalCacheDiskCacheFactory
import com.bumptech.glide.load.engine.cache.LruResourceCache import com.bumptech.glide.load.engine.cache.LruResourceCache
import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions
@GlideModule @GlideModule
class TvmonGlideModule : AppGlideModule() { class TvmonGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) { 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())) 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)) 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") Log.w(TAG, "setupDetails: Setting up details UI")
val presenterSelector = ClassPresenterSelector() val presenterSelector = ClassPresenterSelector()
val detailsPresenter = FullWidthDetailsOverviewRowPresenter(DetailsDescriptionPresenter()) 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) detailsPresenter.backgroundColor = ContextCompat.getColor(requireContext(), R.color.detail_background)
presenterSelector.addClassPresenter(DetailsOverviewRow::class.java, detailsPresenter) presenterSelector.addClassPresenter(DetailsOverviewRow::class.java, detailsPresenter)
presenterSelector.addClassPresenter(ListRow::class.java, ListRowPresenter()) presenterSelector.addClassPresenter(ListRow::class.java, ListRowPresenter())

View File

@@ -50,8 +50,8 @@ class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemV
Log.w(TAG, "setupUI: Setting up UI") Log.w(TAG, "setupUI: Setting up UI")
headersState = HEADERS_ENABLED headersState = HEADERS_ENABLED
title = getString(R.string.browse_title) title = getString(R.string.browse_title)
brandColor = ContextCompat.getColor(requireContext(), R.color.primary) brandColor = ContextCompat.getColor(requireContext(), R.color.accent)
searchAffordanceColor = ContextCompat.getColor(requireContext(), R.color.search_opaque) searchAffordanceColor = ContextCompat.getColor(requireContext(), R.color.accent)
adapter = rowsAdapter adapter = rowsAdapter
onItemViewClickedListener = this onItemViewClickedListener = this

View File

@@ -2,6 +2,8 @@ package com.example.tvmon.ui.presenter
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.OvershootInterpolator
import androidx.core.content.ContextCompat
import androidx.leanback.widget.ImageCardView import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter import androidx.leanback.widget.Presenter
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@@ -11,15 +13,46 @@ import com.example.tvmon.data.model.Category
class ContentCardPresenter : Presenter() { class ContentCardPresenter : 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 { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply { val cardView = ImageCardView(parent.context).apply {
isFocusable = true isFocusable = true
isFocusableInTouchMode = 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) 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) { override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val content = item as Content val content = item as Content
val cardView = viewHolder.view as ImageCardView val cardView = viewHolder.view as ImageCardView
@@ -53,36 +86,48 @@ class ContentCardPresenter : Presenter() {
class SearchCardPresenter : Presenter() { class SearchCardPresenter : Presenter() {
companion object {
private const val FOCUS_SCALE = 1.1f
private const val FOCUS_TRANSLATION_Z = 10f
private const val ANIMATION_DURATION = 100L
}
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply { val cardView = ImageCardView(parent.context).apply {
isFocusable = true isFocusable = true
isFocusableInTouchMode = true isFocusableInTouchMode = true
setBackgroundColor(parent.context.getColor(R.color.default_background)) setBackgroundColor(ContextCompat.getColor(context, R.color.default_background))
cardType = ImageCardView.CARD_TYPE_INFO_UNDER cardType = ImageCardView.CARD_TYPE_INFO_UNDER
infoVisibility = View.VISIBLE infoVisibility = View.VISIBLE
setOnFocusChangeListener { view, hasFocus ->
animateFocus(view, hasFocus)
}
}
return ViewHolder(cardView)
} }
cardView.setOnFocusChangeListener { v, hasFocus -> private fun animateFocus(view: View, hasFocus: Boolean) {
if (hasFocus) { if (hasFocus) {
v.animate() view.animate()
.scaleX(1.1f) .scaleX(FOCUS_SCALE)
.scaleY(1.1f) .scaleY(FOCUS_SCALE)
.translationZ(10f) .translationZ(FOCUS_TRANSLATION_Z)
.setDuration(100) .setDuration(ANIMATION_DURATION)
.setInterpolator(OvershootInterpolator(1.2f))
.start() .start()
view.setBackgroundColor(ContextCompat.getColor(view.context, R.color.focused_bg))
} else { } else {
v.animate() view.animate()
.scaleX(1.0f) .scaleX(1.0f)
.scaleY(1.0f) .scaleY(1.0f)
.translationZ(0f) .translationZ(0f)
.setDuration(100) .setDuration(ANIMATION_DURATION)
.setInterpolator(OvershootInterpolator(1.2f))
.start() .start()
view.setBackgroundColor(ContextCompat.getColor(view.context, R.color.default_background))
} }
} }
return ViewHolder(cardView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) { override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val content = item as Content val content = item as Content
val cardView = viewHolder.view as ImageCardView val cardView = viewHolder.view as ImageCardView

View File

@@ -1,6 +1,9 @@
package com.example.tvmon.ui.presenter package com.example.tvmon.ui.presenter
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.OvershootInterpolator
import androidx.core.content.ContextCompat
import androidx.leanback.widget.ImageCardView import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter import androidx.leanback.widget.Presenter
import com.example.tvmon.R import com.example.tvmon.R
@@ -10,31 +13,62 @@ class EpisodePresenter(
private val watchedEpisodeUrl: String? = null private val watchedEpisodeUrl: String? = null
) : Presenter() { ) : 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 { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply { val cardView = ImageCardView(parent.context).apply {
isFocusable = true isFocusable = true
isFocusableInTouchMode = 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) 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) { override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val episode = item as Episode val episode = item as Episode
val cardView = viewHolder.view as ImageCardView val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources val ctx = cardView.context
cardView.titleText = episode.number cardView.titleText = episode.number
cardView.contentText = episode.title cardView.contentText = episode.title
if (episode.url == watchedEpisodeUrl) { if (episode.url == watchedEpisodeUrl) {
cardView.setBackgroundColor(res.getColor(R.color.accent)) cardView.setBackgroundColor(ContextCompat.getColor(ctx, R.color.accent))
} else { } 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 width = ctx.resources.getDimensionPixelSize(R.dimen.episode_card_width)
val height = res.getDimensionPixelSize(R.dimen.episode_card_height) val height = ctx.resources.getDimensionPixelSize(R.dimen.episode_card_height)
cardView.setMainImageDimensions(width, height) cardView.setMainImageDimensions(width, height)
cardView.mainImageView.setImageResource(R.drawable.episode_placeholder) cardView.mainImageView.setImageResource(R.drawable.episode_placeholder)

View File

@@ -1,10 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="primary">#1F1F1F</color> <!-- Netflix 2025 Color Scheme -->
<color name="primary">#000000</color>
<color name="primary_dark">#000000</color> <color name="primary_dark">#000000</color>
<color name="accent">#FF6B6B</color> <color name="accent">#E50914</color>
<color name="default_background">#282828</color> <color name="netflix_red">#E50914</color>
<color name="category_background">#3A3A3A</color> <color name="netflix_black">#000000</color>
<color name="detail_background">#1F1F1F</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="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> </resources>

View File

@@ -1,14 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<dimen name="card_width">180dp</dimen> <!-- Netflix-style larger cards -->
<dimen name="card_height">240dp</dimen> <dimen name="card_width">200dp</dimen>
<dimen name="card_height">280dp</dimen>
<dimen name="category_card_width">240dp</dimen> <dimen name="category_card_width">240dp</dimen>
<dimen name="category_card_height">160dp</dimen> <dimen name="category_card_height">160dp</dimen>
<dimen name="episode_card_width">200dp</dimen> <dimen name="episode_card_width">220dp</dimen>
<dimen name="episode_card_height">120dp</dimen> <dimen name="episode_card_height">130dp</dimen>
<dimen name="cast_card_width">120dp</dimen> <dimen name="cast_card_width">120dp</dimen>
<dimen name="cast_card_height">160dp</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> </resources>

View File

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