Files
Radio/app/src/main/java/com/michatec/radio/ui/LayoutHolder.kt
T
Michatec 181ebd47df feat(ui): add custom theme color support and share app functionality
Introduce a new custom theme feature that allows users to personalize the application background color using RGB sliders, hex input, or a set of predefined palettes.

Key changes include:
* **Custom Theme Engine**: Added `CustomThemeFragment` and `ThemeHelper` to manage color selection and application. The UI dynamically updates the background color across the `MainActivity` and `PlayerFragment` when preferences change.
* **Android TV Support**: Provided a specialized layout for television devices (`fragment_custom_theme.xml` in `layout-television`) with optimized focus handling for D-pad navigation.
* **Share Feature**: Implemented a "Share App" preference in `SettingsFragment` that triggers a standard Android share intent and displays a thank-you notification upon use.
* **Localization**: Added Ukrainian language support and updated string resources for multiple locales (DE, DA, EL, FR, JA, NL, PL, RU) to include the new theme and share options.
* **Persistence**: Updated `PreferencesHelper` and `Keys` to store theme-related settings, including the enabled state, selected color, and predefined color index.
2026-06-01 19:32:46 +02:00

480 lines
20 KiB
Kotlin

package com.michatec.radio.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.AnimatedVectorDrawable
import android.view.View
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.Group
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.mediarouter.app.MediaRouteButton
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
import com.michatec.radio.Keys
import com.michatec.radio.R
import com.michatec.radio.core.Station
import com.michatec.radio.helpers.DateTimeHelper
import com.michatec.radio.helpers.ImageHelper
import com.michatec.radio.helpers.PreferencesHelper
import com.michatec.radio.helpers.ThemeHelper
import com.michatec.radio.helpers.UiHelper
import java.util.Locale
/*
* LayoutHolder class
*/
data class LayoutHolder(var rootView: View) {
/* Main class variables */
var recyclerView: RecyclerView = rootView.findViewById(R.id.station_list)
val layoutManager: LinearLayoutManager
var bottomSheet: ConstraintLayout? = rootView.findViewById(R.id.bottom_sheet)
//private var sheetMetadataViews: Group
private var sleepTimerRunningViews: Group? = rootView.findViewById(R.id.sleep_timer_running_views)
private var downloadProgressIndicator: ProgressBar? = rootView.findViewById(R.id.download_progress_indicator)
private var stationImageView: ImageView? = rootView.findViewById(R.id.station_icon)
private var stationNameView: TextView? = rootView.findViewById(R.id.player_station_name)
private var metadataView: TextView? = rootView.findViewById(R.id.player_station_metadata)
var playButtonView: ImageButton = rootView.findViewById(R.id.player_play_button)
var playerPrevButtonView: ImageButton? = rootView.findViewById(R.id.player_prev_button)
var playerNextButtonView: ImageButton? = rootView.findViewById(R.id.player_next_button)
private var bufferingIndicator: ProgressBar = rootView.findViewById(R.id.player_buffering_indicator)
private var sheetStreamingLinkHeadline: TextView? = rootView.findViewById(R.id.sheet_streaming_link_headline)
private var sheetStreamingLinkView: TextView? = rootView.findViewById(R.id.sheet_streaming_link)
private var sheetMetadataHistoryHeadline: TextView? = rootView.findViewById(R.id.sheet_metadata_headline)
private var sheetMetadataHistoryView: TextView? = rootView.findViewById(R.id.sheet_metadata_history)
private var sheetNextMetadataView: ImageButton? = rootView.findViewById(R.id.sheet_next_metadata_button)
private var sheetPreviousMetadataView: ImageButton? = rootView.findViewById(R.id.sheet_previous_metadata_button)
private var sheetCopyMetadataButtonView: ImageButton? = rootView.findViewById(R.id.copy_station_metadata_button)
private var mediaRouteButton: MediaRouteButton? = rootView.findViewById(R.id.media_route_button)
private var sheetShareLinkButtonView: ImageView? = rootView.findViewById(R.id.sheet_share_link_button)
private var sheetBitrateView: TextView? = rootView.findViewById(R.id.sheet_bitrate_view)
var sheetSleepTimerStartButtonView: ImageButton? = rootView.findViewById(R.id.sleep_timer_start_button)
var sheetSleepTimerCancelButtonView: ImageButton? = rootView.findViewById(R.id.sleep_timer_cancel_button)
private var sheetSleepTimerRemainingTimeView: TextView = rootView.findViewById(R.id.sleep_timer_remaining_time)
private var onboardingLayout: ConstraintLayout = rootView.findViewById(R.id.onboarding_layout)
private var bottomSheetBehavior: BottomSheetBehavior<ConstraintLayout>? = bottomSheet?.let { BottomSheetBehavior.from(it) }
private var metadataHistory: MutableList<String>
private var metadataHistoryPosition: Int
private var isBuffering: Boolean
/* Init block */
init {
// find views
//sheetMetadataViews = rootView.findViewById(R.id.sheet_metadata_views)
metadataHistory = PreferencesHelper.loadMetadataHistory()
metadataHistoryPosition = metadataHistory.size - 1
isBuffering = false
// set up RecyclerView
layoutManager = CustomLayoutManager(rootView.context)
recyclerView.layoutManager = layoutManager
recyclerView.itemAnimator = DefaultItemAnimator()
// set up metadata history next and previous buttons
sheetPreviousMetadataView?.setOnClickListener {
if (metadataHistory.isNotEmpty()) {
if (metadataHistoryPosition > 0) {
metadataHistoryPosition -= 1
} else {
metadataHistoryPosition = metadataHistory.size - 1
}
sheetMetadataHistoryView?.text = metadataHistory[metadataHistoryPosition]
}
}
sheetNextMetadataView?.setOnClickListener {
if (metadataHistory.isNotEmpty()) {
if (metadataHistoryPosition < metadataHistory.size - 1) {
metadataHistoryPosition += 1
} else {
metadataHistoryPosition = 0
}
sheetMetadataHistoryView?.text = metadataHistory[metadataHistoryPosition]
}
}
sheetMetadataHistoryView?.setOnLongClickListener {
copyMetadataHistoryToClipboard()
return@setOnLongClickListener true
}
sheetMetadataHistoryHeadline?.setOnLongClickListener {
copyMetadataHistoryToClipboard()
return@setOnLongClickListener true
}
// Set up MediaRouteButton (Google Cast)
mediaRouteButton?.let {
CastButtonFactory.setUpMediaRouteButton(rootView.context, it)
}
// Apply custom theme color
applyCustomTheme(rootView.context)
// set layout for player
setupBottomSheet()
}
/* Updates the player views */
fun updatePlayerViews(context: Context, station: Station, isPlaying: Boolean) {
if (!isPlaying) {
metadataView?.text = station.name
sheetMetadataHistoryView?.text = station.name
}
// update name
stationNameView?.text = station.name
// toggle text scrolling (marquee) if necessary
stationNameView?.isSelected = isPlaying
// reduce the shadow left and right because of scrolling (Marquee)
stationNameView?.setFadingEdgeLength(8)
// update cover
if (station.imageColor != -1) {
stationImageView?.setBackgroundColor(station.imageColor)
}
stationImageView?.setImageBitmap(ImageHelper.getStationImage(context, station.smallImage))
stationImageView?.contentDescription = "${context.getString(R.string.descr_player_station_image)}: ${station.name}"
// update streaming link
sheetStreamingLinkView?.text = station.getStreamUri()
val bitrateText: CharSequence = if (station.codec.isNotEmpty()) {
if (station.bitrate == 0) {
// show only the codec when the bitrate is at "0" from radio-browser.info API
station.codec
} else {
val kiloBytesPerSecond = station.bitrate / 8F
val dataRateString = if (kiloBytesPerSecond >= 1000) {
String.format(Locale.ROOT, "%.2f mb/s", kiloBytesPerSecond / 1000F)
} else {
String.format(Locale.ROOT, "%.0f kb/s", kiloBytesPerSecond)
}
// show the bitrate and codec if the result is available in the radio-browser.info API
buildString {
append(station.codec)
append(" | ")
append(station.bitrate)
append("kbps")
append(" | ")
append(dataRateString)
}
}
} else {
// do not show for M3U and PLS playlists as they do not include codec or bitrate
""
}
// update bitrate
sheetBitrateView?.text = bitrateText
// update custom theme
applyCustomTheme(context)
// update click listeners
sheetStreamingLinkHeadline?.setOnClickListener {
copyToClipboard(
context,
sheetStreamingLinkView?.text ?: ""
)
Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
}
sheetStreamingLinkView?.setOnClickListener {
copyToClipboard(
context,
sheetStreamingLinkView?.text ?: ""
)
Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
}
sheetMetadataHistoryHeadline?.setOnClickListener {
copyToClipboard(
context,
sheetMetadataHistoryView?.text ?: ""
)
Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
}
sheetMetadataHistoryView?.setOnClickListener {
copyToClipboard(
context,
sheetMetadataHistoryView?.text ?: ""
)
Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
}
sheetCopyMetadataButtonView?.setOnClickListener {
copyToClipboard(
context,
sheetMetadataHistoryView?.text ?: ""
)
Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
}
sheetBitrateView?.setOnClickListener {
copyToClipboard(
context,
sheetBitrateView?.text ?: ""
)
Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
}
sheetShareLinkButtonView?.setOnClickListener {
val share = Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TITLE, stationNameView?.text)
putExtra(Intent.EXTRA_TEXT, sheetStreamingLinkView?.text ?: "")
type = "text/plain"
}, null)
context.startActivity(share)
}
}
/* Copies given string to clipboard */
private fun copyToClipboard(context: Context, clipString: CharSequence) {
val clip: ClipData = ClipData.newPlainText("simple text", clipString)
val cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(clip)
Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
}
/* Copies collected metadata to clipboard */
private fun copyMetadataHistoryToClipboard() {
val metadataHistory: MutableList<String> = PreferencesHelper.loadMetadataHistory()
val stringBuilder: StringBuilder = StringBuilder()
metadataHistory.forEach { stringBuilder.append("${it.trim()}\n") }
copyToClipboard(rootView.context, stringBuilder.toString())
Snackbar.make(rootView, R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
}
/* Updates the metadata views */
fun updateMetadata(metadataHistoryList: MutableList<String>?) {
if (!metadataHistoryList.isNullOrEmpty()) {
metadataHistory = metadataHistoryList
if (metadataHistory.last() != metadataView?.text) {
metadataHistoryPosition = metadataHistory.size - 1
val metadataString = metadataHistory[metadataHistoryPosition]
metadataView?.text = metadataString
sheetMetadataHistoryView?.text = metadataString
}
}
}
/* Updates sleep timer views */
fun updateSleepTimer(context: Context, timeRemaining: Long = 0L) {
when (timeRemaining) {
0L -> {
sleepTimerRunningViews?.isGone = true
sheetSleepTimerRemainingTimeView.isVisible = false
}
else -> {
sleepTimerRunningViews?.isVisible = true
sheetSleepTimerRemainingTimeView.isVisible = true
val sleepTimerTimeRemaining = DateTimeHelper.convertToHoursMinutesSeconds(timeRemaining)
sheetSleepTimerRemainingTimeView.text = sleepTimerTimeRemaining
sheetSleepTimerRemainingTimeView.contentDescription = "${context.getString(R.string.descr_expanded_player_sleep_timer_remaining_time)}: $sleepTimerTimeRemaining"
stationNameView?.isSelected = false
}
}
}
/* Toggles play/pause button */
fun togglePlayButton(isPlaying: Boolean) {
if (isPlaying) {
playButtonView.setImageResource(R.drawable.ic_audio_waves_animated)
val animatedVectorDrawable = playButtonView.drawable as? AnimatedVectorDrawable
animatedVectorDrawable?.start()
sheetSleepTimerStartButtonView?.isVisible = true
} else {
playButtonView.setImageResource(R.drawable.ic_player_play_symbol_42dp)
sheetSleepTimerStartButtonView?.isVisible = false
}
}
/* Toggles buffering indicator */
fun showBufferingIndicator(buffering: Boolean) {
bufferingIndicator.isVisible = buffering
isBuffering = buffering
}
/* Applies custom theme color to the UI */
fun applyCustomTheme(context: Context) {
val enabled = PreferencesHelper.loadCustomThemeEnabled()
if (enabled) {
var customColor = PreferencesHelper.loadCustomThemeColor(context)
val index = PreferencesHelper.loadCustomThemeIndex()
if (index != -1) {
val colors = ThemeHelper.getPredefinedColors(context)
if (index < colors.size) {
customColor = colors[index]
}
}
rootView.setBackgroundColor(customColor)
recyclerView.setBackgroundColor(customColor)
} else {
rootView.setBackgroundResource(android.R.color.transparent)
recyclerView.setBackgroundResource(android.R.color.transparent)
}
}
/* Toggles visibility of the download progress indicator */
fun toggleDownloadProgressIndicator() {
when (PreferencesHelper.loadActiveDownloads()) {
Keys.ACTIVE_DOWNLOADS_EMPTY -> downloadProgressIndicator?.isGone = true
else -> downloadProgressIndicator?.isVisible = true
}
}
/* Toggles visibility of the onboarding screen */
fun toggleOnboarding(context: Context, collectionSize: Int): Boolean {
return if (collectionSize == 0 && PreferencesHelper.loadCollectionSize() <= 0) {
onboardingLayout.isVisible = true
hidePlayer(context)
true
} else {
onboardingLayout.isGone = true
showPlayer(context)
false
}
}
/* Initiates the rotation animation of the play button */
fun animatePlaybackButtonStateTransition(context: Context, isPlaying: Boolean) {
if (context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) {
// TV: Toggle play button immediately for snappier feel
togglePlayButton(isPlaying)
} else {
// Handy/Tablet: Rotate the play button
val rotateAnimation = AnimationUtils.loadAnimation(context, if (isPlaying) R.anim.rotate_clockwise_slow else R.anim.rotate_counterclockwise_fast)
rotateAnimation.setAnimationListener(createAnimationListener(isPlaying))
playButtonView.startAnimation(rotateAnimation)
}
}
/* Shows player */
fun showPlayer(context: Context): Boolean {
if (bottomSheetBehavior != null) {
UiHelper.setViewMargins(context, recyclerView, 0, 0, 0, Keys.BOTTOM_SHEET_PEEK_HEIGHT)
if (bottomSheetBehavior?.state == BottomSheetBehavior.STATE_HIDDEN && onboardingLayout.isGone) {
bottomSheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED
}
} else {
UiHelper.setViewMargins(context, recyclerView, 0, 0, 0, 0)
}
return true
}
/* Hides player */
private fun hidePlayer(context: Context): Boolean {
UiHelper.setViewMargins(context, recyclerView, 0, 0, 0, 0)
bottomSheetBehavior?.state = BottomSheetBehavior.STATE_HIDDEN
return true
}
/* Minimizes player sheet if expanded */
fun minimizePlayerIfExpanded(): Boolean {
return if (bottomSheetBehavior?.state == BottomSheetBehavior.STATE_EXPANDED) {
bottomSheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED
true
} else {
false
}
}
/* Creates AnimationListener for play button */
private fun createAnimationListener(isPlaying: Boolean): Animation.AnimationListener {
return object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {}
override fun onAnimationEnd(animation: Animation) {
// set up button symbol and playback indicator afterward
togglePlayButton(isPlaying)
}
override fun onAnimationRepeat(animation: Animation) {}
}
}
/* Sets up the player (BottomSheet) */
private fun setupBottomSheet() {
if (bottomSheetBehavior != null) {
// show / hide the small player
bottomSheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED
bottomSheetBehavior?.addBottomSheetCallback(object :
BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(view: View, slideOffset: Float) {
}
override fun onStateChanged(view: View, state: Int) {
when (state) {
BottomSheetBehavior.STATE_COLLAPSED -> Unit // do nothing
BottomSheetBehavior.STATE_DRAGGING -> Unit // do nothing
BottomSheetBehavior.STATE_EXPANDED -> Unit // do nothing
BottomSheetBehavior.STATE_HALF_EXPANDED -> Unit // do nothing
BottomSheetBehavior.STATE_SETTLING -> Unit // do nothing
BottomSheetBehavior.STATE_HIDDEN -> showPlayer(rootView.context)
}
}
})
// toggle collapsed state on tap
bottomSheet?.setOnClickListener { toggleBottomSheetState() }
stationImageView?.setOnClickListener { toggleBottomSheetState() }
stationNameView?.setOnClickListener { toggleBottomSheetState() }
metadataView?.setOnClickListener { toggleBottomSheetState() }
}
}
/* Toggle expanded/collapsed state of bottom sheet */
private fun toggleBottomSheetState() {
when (bottomSheetBehavior?.state) {
BottomSheetBehavior.STATE_COLLAPSED -> bottomSheetBehavior?.state =
BottomSheetBehavior.STATE_EXPANDED
else -> bottomSheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED
}
}
/*
* Inner class: Custom LinearLayoutManager
*/
private class CustomLayoutManager(context: Context) :
LinearLayoutManager(context, VERTICAL, false) {
override fun supportsPredictiveItemAnimations(): Boolean {
return true
}
}
/*
* End of inner class
*/
}