From a2677a3294d50e107406196c80b51a715e1368bb Mon Sep 17 00:00:00 2001 From: tvmon-dev Date: Thu, 16 Apr 2026 13:26:47 +0900 Subject: [PATCH] Netflix 2025 UI/UX modernization: colors, themes, card animations, Glide TV optimization --- tvmon-app/app/build.gradle | 3 + .../com/example/tvmon/TvmonApplication.kt | 13 +- .../com/example/tvmon/TvmonGlideModule.kt | 19 +- .../tvmon/ui/detail/DetailsFragment.kt | 2 +- .../com/example/tvmon/ui/main/MainFragment.kt | 22 +- .../tvmon/ui/presenter/CardPresenters.kt | 219 +++++++++++------- .../tvmon/ui/presenter/EpisodePresenter.kt | 46 +++- tvmon-app/app/src/main/res/values/colors.xml | 20 +- tvmon-app/app/src/main/res/values/dimens.xml | 20 +- tvmon-app/app/src/main/res/values/themes.xml | 5 +- 10 files changed, 244 insertions(+), 125 deletions(-) diff --git a/tvmon-app/app/build.gradle b/tvmon-app/app/build.gradle index 2a79548..8aaf3a4 100644 --- a/tvmon-app/app/build.gradle +++ b/tvmon-app/app/build.gradle @@ -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' diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/TvmonApplication.kt b/tvmon-app/app/src/main/java/com/example/tvmon/TvmonApplication.kt index 4632c1d..16632c6 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/TvmonApplication.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/TvmonApplication.kt @@ -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") } -} +} \ No newline at end of file diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/TvmonGlideModule.kt b/tvmon-app/app/src/main/java/com/example/tvmon/TvmonGlideModule.kt index a7eca1b..fdacdd5 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/TvmonGlideModule.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/TvmonGlideModule.kt @@ -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 } \ No newline at end of file diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/detail/DetailsFragment.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/detail/DetailsFragment.kt index a66dc48..894a976 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/detail/DetailsFragment.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/detail/DetailsFragment.kt @@ -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()) diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt index aee7981..5a1191d 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt @@ -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") } diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt index b29a394..b41b211 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt @@ -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() { diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/EpisodePresenter.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/EpisodePresenter.kt index 180fe61..3c0d13c 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/EpisodePresenter.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/EpisodePresenter.kt @@ -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) diff --git a/tvmon-app/app/src/main/res/values/colors.xml b/tvmon-app/app/src/main/res/values/colors.xml index 60cd507..76ff718 100644 --- a/tvmon-app/app/src/main/res/values/colors.xml +++ b/tvmon-app/app/src/main/res/values/colors.xml @@ -1,10 +1,20 @@ - #1F1F1F + + #000000 #000000 - #FF6B6B - #282828 - #3A3A3A - #1F1F1F + #E50914 + #E50914 + #000000 + #141414 + #333333 + #666666 + #141414 + #1F1F1F + #000000 #AAFFFFFF + #FFFFFF + #B3000000 + #333333 + #2A2A2A diff --git a/tvmon-app/app/src/main/res/values/dimens.xml b/tvmon-app/app/src/main/res/values/dimens.xml index 60c9e0c..1b471ac 100644 --- a/tvmon-app/app/src/main/res/values/dimens.xml +++ b/tvmon-app/app/src/main/res/values/dimens.xml @@ -1,14 +1,20 @@ - 180dp - 240dp - + + 200dp + 280dp + 240dp 160dp - - 200dp - 120dp - + + 220dp + 130dp + 120dp 160dp + + + 28dp + 12dp + 48dp diff --git a/tvmon-app/app/src/main/res/values/themes.xml b/tvmon-app/app/src/main/res/values/themes.xml index 8050807..331d03e 100644 --- a/tvmon-app/app/src/main/res/values/themes.xml +++ b/tvmon-app/app/src/main/res/values/themes.xml @@ -1,10 +1,11 @@ +