Initial commit

This commit is contained in:
Michatec
2025-04-27 15:07:05 +02:00
commit 2162c9fb40
157 changed files with 12179 additions and 0 deletions

View File

@@ -0,0 +1,748 @@
/*
* CollectionAdapter.kt
* Implements the CollectionAdapter class
* A CollectionAdapter is a custom adapter providing station card views for a RecyclerView
*
* This file is part of
* TRANSISTOR - Radio App for Android
*
* Copyright (c) 2015-22 - Y20K.org
* Licensed under the MIT-License
* http://opensource.org/licenses/MIT
*/
package com.michatec.radio.collection
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView
import androidx.constraintlayout.widget.Group
import androidx.core.net.toUri
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.findNavController
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import com.michatec.radio.Keys
import com.michatec.radio.R
import com.michatec.radio.core.Collection
import com.michatec.radio.core.Station
import com.michatec.radio.helpers.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import java.util.*
/*
* CollectionAdapter class
*/
class CollectionAdapter(
private val context: Context,
private val collectionAdapterListener: CollectionAdapterListener
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), UpdateHelper.UpdateHelperListener {
/* Main class variables */
private lateinit var collectionViewModel: CollectionViewModel
private var collection: Collection = Collection()
private var editStationsEnabled: Boolean = PreferencesHelper.loadEditStationsEnabled()
private var editStationStreamsEnabled: Boolean = PreferencesHelper.loadEditStreamUrisEnabled()
private var expandedStationUuid: String = PreferencesHelper.loadStationListStreamUuid()
private var expandedStationPosition: Int = -1
var isExpandedForEdit: Boolean = false
/* Listener Interface */
interface CollectionAdapterListener {
fun onPlayButtonTapped(stationUuid: String)
fun onAddNewButtonTapped()
fun onChangeImageButtonTapped(stationUuid: String)
}
/* Overrides onAttachedToRecyclerView */
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
// create view model and observe changes in collection view model
collectionViewModel =
ViewModelProvider(context as AppCompatActivity)[CollectionViewModel::class.java]
observeCollectionViewModel(context as LifecycleOwner)
// start listening for changes in shared preferences
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
}
/* Overrides onDetachedFromRecyclerView */
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
// stop listening for changes in shared preferences
PreferencesHelper.unregisterPreferenceChangeListener(sharedPreferenceChangeListener)
}
/* Overrides onCreateViewHolder */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
Keys.VIEW_TYPE_ADD_NEW -> {
// get view, put view into holder and return
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.card_add_new_station, parent, false)
AddNewViewHolder(v)
}
else -> {
// get view, put view into holder and return
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.card_station, parent, false)
StationViewHolder(v)
}
}
}
/* Implement the method to handle item move */
fun onItemMove(fromPosition: Int, toPosition: Int) {
// Do nothing if in "edit" mode
if (isExpandedForEdit) {
return
}
val stationList = collection.stations
val stationCount = stationList.size
if (fromPosition !in 0 until stationCount || toPosition !in 0 until stationCount) {
return
}
val fromStation = stationList[fromPosition]
val toStation = stationList[toPosition]
if (fromStation.starred != toStation.starred) {
// Prevent moving a starred item into non-starred area or vice versa
return
}
// Move within the same group (either starred or non-starred)
Collections.swap(stationList, fromPosition, toPosition)
// Update the value of expandedStationPosition if necessary
expandedStationPosition = if (fromPosition == expandedStationPosition) toPosition else expandedStationPosition
// Notify the adapter about the item move
notifyItemMoved(fromPosition, toPosition)
}
/* Implement the method to handle item dismissal */
fun onItemDismiss(position: Int) {
// Remove the item at the given position from your data collection
collection.stations.removeAt(position)
notifyItemRemoved(position)
}
/* Method for saving the collection after the drag-and-drop operation */
fun saveCollectionAfterDragDrop() {
// Save the collection after the dragging is completed
CollectionHelper.saveCollection(context, collection)
}
/* Overrides onBindViewHolder */
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
// CASE ADD NEW CARD
is AddNewViewHolder -> {
// get reference to StationViewHolder
val addNewViewHolder: AddNewViewHolder = holder
addNewViewHolder.addNewStationView.setOnClickListener {
// show the add station dialog
collectionAdapterListener.onAddNewButtonTapped()
}
addNewViewHolder.settingsButtonView.setOnClickListener {
it.findNavController().navigate(R.id.settings_destination)
}
}
// CASE STATION CARD
is StationViewHolder -> {
// get station from position
val station: Station = collection.stations[position]
// get reference to StationViewHolder
val stationViewHolder: StationViewHolder = holder
// set up station views
setStarredIcon(stationViewHolder, station)
setStationName(stationViewHolder, station)
setStationImage(stationViewHolder, station)
setStationButtons(stationViewHolder, station)
setEditViews(stationViewHolder, station)
// show / hide edit views
when (expandedStationPosition) {
// show edit views
position -> {
stationViewHolder.stationNameView.isVisible = false
stationViewHolder.playButtonView.isGone = true
stationViewHolder.stationStarredView.isGone = true
stationViewHolder.editViews.isVisible = true
if (editStationStreamsEnabled) {
stationViewHolder.stationUriEditView.isVisible = true
stationViewHolder.stationUriEditView.imeOptions =
EditorInfo.IME_ACTION_DONE
} else {
stationViewHolder.stationUriEditView.isGone = true
stationViewHolder.stationNameEditView.imeOptions =
EditorInfo.IME_ACTION_DONE
}
}
// hide edit views
else -> {
stationViewHolder.stationNameView.isVisible = true
//stationViewHolder.playButtonView.isVisible = true
stationViewHolder.stationStarredView.isVisible = station.starred
stationViewHolder.editViews.isGone = true
stationViewHolder.stationUriEditView.isGone = true
}
}
}
}
}
/* Overrides onStationUpdated from UpdateHelperListener */
override fun onStationUpdated(
collection: Collection,
positionPriorUpdate: Int,
positionAfterUpdate: Int
) {
// check if position has changed after update and move stations around if necessary
if (positionPriorUpdate != positionAfterUpdate && positionPriorUpdate != -1 && positionAfterUpdate != -1) {
notifyItemMoved(positionPriorUpdate, positionAfterUpdate)
notifyItemChanged(positionPriorUpdate)
}
// update station (e.g. name)
notifyItemChanged(positionAfterUpdate)
}
/* Sets the station name view */
private fun setStationName(stationViewHolder: StationViewHolder, station: Station) {
stationViewHolder.stationNameView.text = station.name
}
/* Sets the edit views */
private fun setEditViews(stationViewHolder: StationViewHolder, station: Station) {
stationViewHolder.stationNameEditView.setText(station.name, TextView.BufferType.EDITABLE)
stationViewHolder.stationUriEditView.setText(
station.getStreamUri(),
TextView.BufferType.EDITABLE
)
stationViewHolder.stationUriEditView.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
handleStationUriInput(stationViewHolder, s, station.getStreamUri())
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
stationViewHolder.cancelButton.setOnClickListener {
val position: Int = stationViewHolder.adapterPosition
toggleEditViews(position, station.uuid)
UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView)
}
stationViewHolder.saveButton.setOnClickListener {
val position: Int = stationViewHolder.adapterPosition
toggleEditViews(position, station.uuid)
saveStation(
station,
position,
stationViewHolder.stationNameEditView.text.toString(),
stationViewHolder.stationUriEditView.text.toString()
)
UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView)
}
stationViewHolder.placeOnHomeScreenButton.setOnClickListener {
val position: Int = stationViewHolder.adapterPosition
ShortcutHelper.placeShortcut(context, station)
toggleEditViews(position, station.uuid)
UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView)
}
stationViewHolder.stationImageChangeView.setOnClickListener {
val position: Int = stationViewHolder.adapterPosition
collectionAdapterListener.onChangeImageButtonTapped(station.uuid)
stationViewHolder.adapterPosition
toggleEditViews(position, station.uuid)
UiHelper.hideSoftKeyboard(context, stationViewHolder.stationNameEditView)
}
}
/* Shows / hides the edit view for a station */
private fun toggleEditViews(position: Int, stationUuid: String) {
when (stationUuid) {
// CASE: this station's edit view is already expanded
expandedStationUuid -> {
isExpandedForEdit = false
// reset currently expanded info (both uuid and position)
saveStationListExpandedState()
// update station view
notifyItemChanged(position)
}
// CASE: this station's edit view is not yet expanded
else -> {
isExpandedForEdit = true
// remember previously expanded position
val previousExpandedStationPosition: Int = expandedStationPosition
// if station was expanded - collapse it
if (previousExpandedStationPosition > -1 && previousExpandedStationPosition < collection.stations.size)
notifyItemChanged(previousExpandedStationPosition)
// store current station as the expanded one
saveStationListExpandedState(position, stationUuid)
// update station view
notifyDataSetChanged()
}
}
}
/* Toggles the starred icon */
private fun setStarredIcon(stationViewHolder: StationViewHolder, station: Station) {
when (station.starred) {
true -> {
if (station.imageColor != -1) {
// stationViewHolder.stationCardView.setCardBackgroundColor(station.imageColor)
stationViewHolder.stationStarredView.setColorFilter(station.imageColor)
}
stationViewHolder.stationStarredView.isVisible = true
}
false -> stationViewHolder.stationStarredView.isGone = true
}
}
/* Sets the station image view */
private fun setStationImage(stationViewHolder: StationViewHolder, station: Station) {
if (station.imageColor != -1) {
stationViewHolder.stationImageView.setBackgroundColor(station.imageColor)
}
stationViewHolder.stationImageView.setImageBitmap(
ImageHelper.getStationImage(
context,
station.smallImage
)
)
stationViewHolder.stationImageView.contentDescription =
"${context.getString(R.string.descr_player_station_image)}: ${station.name}"
}
/* Sets up a station's play and edit buttons */
private fun setStationButtons(stationViewHolder: StationViewHolder, station: Station) {
when (station.isPlaying) {
true -> stationViewHolder.playButtonView.visibility = View.VISIBLE
false -> stationViewHolder.playButtonView.visibility = View.INVISIBLE
}
stationViewHolder.stationCardView.setOnClickListener {
collectionAdapterListener.onPlayButtonTapped(station.uuid)
}
stationViewHolder.playButtonView.setOnClickListener {
collectionAdapterListener.onPlayButtonTapped(station.uuid)
}
stationViewHolder.stationNameView.setOnClickListener {
collectionAdapterListener.onPlayButtonTapped(station.uuid)
}
stationViewHolder.stationStarredView.setOnClickListener {
collectionAdapterListener.onPlayButtonTapped(station.uuid)
}
stationViewHolder.stationImageView.setOnClickListener {
collectionAdapterListener.onPlayButtonTapped(station.uuid)
}
stationViewHolder.playButtonView.setOnLongClickListener {
if (editStationsEnabled) {
val position: Int = stationViewHolder.adapterPosition
toggleEditViews(position, station.uuid)
return@setOnLongClickListener true
} else {
return@setOnLongClickListener false
}
}
stationViewHolder.stationNameView.setOnLongClickListener {
if (editStationsEnabled) {
val position: Int = stationViewHolder.adapterPosition
toggleEditViews(position, station.uuid)
return@setOnLongClickListener true
} else {
return@setOnLongClickListener false
}
}
stationViewHolder.stationStarredView.setOnLongClickListener {
if (editStationsEnabled) {
val position: Int = stationViewHolder.adapterPosition
toggleEditViews(position, station.uuid)
return@setOnLongClickListener true
} else {
return@setOnLongClickListener false
}
}
stationViewHolder.stationImageView.setOnLongClickListener {
if (editStationsEnabled) {
val position: Int = stationViewHolder.adapterPosition
toggleEditViews(position, station.uuid)
return@setOnLongClickListener true
} else {
return@setOnLongClickListener false
}
}
}
/* Checks if stream uri input is valid */
private fun handleStationUriInput(
stationViewHolder: StationViewHolder,
s: Editable?,
streamUri: String
) {
if (editStationStreamsEnabled) {
val input: String = s.toString()
if (input == streamUri) {
// enable save button
stationViewHolder.saveButton.isEnabled = true
} else {
// 1. disable save button
stationViewHolder.saveButton.isEnabled = false
// 2. check for valid station uri - and re-enable button
if (input.startsWith("http")) {
// detect content type on background thread
CoroutineScope(IO).launch {
val deferred: Deferred<NetworkHelper.ContentType> =
async(Dispatchers.Default) {
NetworkHelper.detectContentTypeSuspended(input)
}
// wait for result
val contentType: String =
deferred.await().type.lowercase(Locale.getDefault())
// CASE: stream address detected
if (Keys.MIME_TYPES_MPEG.contains(contentType) or
Keys.MIME_TYPES_OGG.contains(contentType) or
Keys.MIME_TYPES_AAC.contains(contentType) or
Keys.MIME_TYPES_HLS.contains(contentType)
) {
// re-enable save button
withContext(Main) {
stationViewHolder.saveButton.isEnabled = true
}
}
}
}
}
}
}
/* Overrides onBindViewHolder */
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: List<Any>
) {
if (payloads.isEmpty()) {
// call regular onBindViewHolder method
onBindViewHolder(holder, position)
} else if (holder is StationViewHolder) {
// get station from position
collection.stations[holder.getAdapterPosition()]
for (data in payloads) {
when (data as Int) {
Keys.HOLDER_UPDATE_COVER -> {
// todo implement
}
Keys.HOLDER_UPDATE_NAME -> {
// todo implement
}
Keys.HOLDER_UPDATE_PLAYBACK_STATE -> {
// todo implement
}
Keys.HOLDER_UPDATE_PLAYBACK_PROGRESS -> {
// todo implement
}
Keys.HOLDER_UPDATE_DOWNLOAD_STATE -> {
// todo implement
}
}
}
}
}
/* Overrides getItemViewType */
override fun getItemViewType(position: Int): Int {
return when (isPositionFooter(position)) {
true -> Keys.VIEW_TYPE_ADD_NEW
false -> Keys.VIEW_TYPE_STATION
}
}
/* Overrides getItemCount */
override fun getItemCount(): Int {
// +1 ==> the add station card
return collection.stations.size + 1
}
/* Removes a station from collection */
fun removeStation(context: Context, position: Int) {
val newCollection = collection.deepCopy()
// delete images assets
CollectionHelper.deleteStationImages(context, newCollection.stations[position])
// remove station from collection
newCollection.stations.removeAt(position)
collection = newCollection
// update list
notifyItemRemoved(position)
// save collection and broadcast changes
CollectionHelper.saveCollection(context, newCollection)
}
/* Toggles starred status of a station */
fun toggleStarredStation(context: Context, position: Int) {
// update view (reset "swipe" state of station card)
notifyItemChanged(position)
// mark starred
val stationUuid: String = collection.stations[position].uuid
collection.stations[position].apply { starred = !starred }
// sort collection
collection = CollectionHelper.sortCollection(collection)
// update list
notifyItemMoved(position, CollectionHelper.getStationPosition(collection, stationUuid))
// save collection and broadcast changes
CollectionHelper.saveCollection(context, collection)
}
/* Saves edited station */
private fun saveStation(
station: Station,
position: Int,
stationName: String,
streamUri: String
) {
// update station name and stream uri
collection.stations.forEach {
if (it.uuid == station.uuid) {
if (stationName.isNotEmpty()) {
it.name = stationName
it.nameManuallySet = true
}
if (streamUri.isNotEmpty()) {
it.streamUris[0] = streamUri
}
}
}
// sort and save collection
collection = CollectionHelper.sortCollection(collection)
// update list
val newPosition: Int = CollectionHelper.getStationPosition(collection, station.uuid)
if (position != newPosition && newPosition != -1) {
notifyItemMoved(position, newPosition)
notifyItemChanged(position)
}
// save collection and broadcast changes
CollectionHelper.saveCollection(context, collection)
}
// /* Initiates update of a station's information */ // todo move to CollectionHelper
// private fun updateStation(context: Context, station: Station) {
// if (station.radioBrowserStationUuid.isNotEmpty()) {
// // get updated station from radio browser - results are handled by onRadioBrowserSearchResults
// val radioBrowserSearch: RadioBrowserSearch = RadioBrowserSearch(context, this)
// radioBrowserSearch.searchStation(context, station.radioBrowserStationUuid, Keys.SEARCH_TYPE_BY_UUID)
// } else if (station.remoteStationLocation.isNotEmpty()) {
// // download playlist // todo check content type detection is necessary here
// DownloadHelper.downloadPlaylists(context, arrayOf(station.remoteStationLocation))
// } else {
// Log.w(TAG, "Unable to update station: ${station.name}.")
// }
// }
/* Determines if position is last */
private fun isPositionFooter(position: Int): Boolean {
return position == collection.stations.size
}
/* Updates the station list - redraws the views with changed content */
@SuppressLint("NotifyDataSetChanged")
private fun updateRecyclerView(oldCollection: Collection, newCollection: Collection) {
collection = newCollection
if (oldCollection.stations.size == 0 && newCollection.stations.size > 0) {
// data set has been initialized - redraw the whole list
notifyDataSetChanged()
} else {
// calculate differences between current collection and new collection - and inform this adapter about the changes
val diffResult =
DiffUtil.calculateDiff(CollectionDiffCallback(oldCollection, newCollection), true)
diffResult.dispatchUpdatesTo(this@CollectionAdapter)
}
}
/* Updates and saves state of expanded station edit view in list */
private fun saveStationListExpandedState(
position: Int = -1,
stationStreamUri: String = String()
) {
expandedStationUuid = stationStreamUri
expandedStationPosition = position
PreferencesHelper.saveStationListStreamUuid(expandedStationUuid)
}
/* Observe view model of station collection*/
private fun observeCollectionViewModel(owner: LifecycleOwner) {
collectionViewModel.collectionLiveData.observe(owner) { newCollection ->
updateRecyclerView(collection, newCollection)
}
}
/*
* Defines the listener for changes in shared preferences
*/
private val sharedPreferenceChangeListener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
when (key) {
Keys.PREF_EDIT_STATIONS -> editStationsEnabled =
PreferencesHelper.loadEditStationsEnabled()
Keys.PREF_EDIT_STREAMS_URIS -> editStationStreamsEnabled =
PreferencesHelper.loadEditStreamUrisEnabled()
}
}
/*
* End of declaration
*/
/*
* Inner class: ViewHolder for the Add New Station action
*/
private inner class AddNewViewHolder(listItemAddNewLayout: View) :
RecyclerView.ViewHolder(listItemAddNewLayout) {
val addNewStationView: ExtendedFloatingActionButton =
listItemAddNewLayout.findViewById(R.id.card_add_new_station)
val settingsButtonView: ExtendedFloatingActionButton =
listItemAddNewLayout.findViewById(R.id.card_settings)
}
/*
* End of inner class
*/
/*
* Inner class: ViewHolder for a station
*/
private inner class StationViewHolder(stationCardLayout: View) :
RecyclerView.ViewHolder(stationCardLayout) {
val stationCardView: CardView = stationCardLayout.findViewById(R.id.station_card)
val stationImageView: ImageView = stationCardLayout.findViewById(R.id.station_icon)
val stationNameView: TextView = stationCardLayout.findViewById(R.id.station_name)
val stationStarredView: ImageView = stationCardLayout.findViewById(R.id.starred_icon)
// val menuButtonView: ImageView = stationCardLayout.findViewById(R.id.menu_button)
val playButtonView: ImageView = stationCardLayout.findViewById(R.id.playback_button)
val editViews: Group = stationCardLayout.findViewById(R.id.default_edit_views)
val stationImageChangeView: ImageView =
stationCardLayout.findViewById(R.id.change_image_view)
val stationNameEditView: TextInputEditText =
stationCardLayout.findViewById(R.id.edit_station_name)
val stationUriEditView: TextInputEditText =
stationCardLayout.findViewById(R.id.edit_stream_uri)
val placeOnHomeScreenButton: MaterialButton =
stationCardLayout.findViewById(R.id.place_on_home_screen_button)
val cancelButton: MaterialButton = stationCardLayout.findViewById(R.id.cancel_button)
val saveButton: MaterialButton = stationCardLayout.findViewById(R.id.save_button)
}
/*
* End of inner class
*/
/*
* Inner class: DiffUtil.Callback that determines changes in data - improves list performance
*/
private inner class CollectionDiffCallback(
val oldCollection: Collection,
val newCollection: Collection
) : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldStation: Station = oldCollection.stations[oldItemPosition]
val newStation: Station = newCollection.stations[newItemPosition]
return oldStation.uuid == newStation.uuid
}
override fun getOldListSize(): Int {
return oldCollection.stations.size
}
override fun getNewListSize(): Int {
return newCollection.stations.size
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldStation: Station = oldCollection.stations[oldItemPosition]
val newStation: Station = newCollection.stations[newItemPosition]
// compare relevant contents of a station
if (oldStation.isPlaying != newStation.isPlaying) return false
if (oldStation.uuid != newStation.uuid) return false
if (oldStation.starred != newStation.starred) return false
if (oldStation.name != newStation.name) return false
if (oldStation.stream != newStation.stream) return false
if (oldStation.remoteImageLocation != newStation.remoteImageLocation) return false
if (oldStation.remoteStationLocation != newStation.remoteStationLocation) return false
if (!oldStation.streamUris.containsAll(newStation.streamUris)) return false
if (oldStation.imageColor != newStation.imageColor) return false
if (FileHelper.getFileSize(context, oldStation.image.toUri()) != FileHelper.getFileSize(
context,
newStation.image.toUri()
)
) return false
if (FileHelper.getFileSize(
context,
oldStation.smallImage.toUri()
) != FileHelper.getFileSize(context, newStation.smallImage.toUri())
) return false
// none of the above -> contents are the same
return true
}
}
/*
* End of inner class
*/
}