/* * 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(), 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 */ @SuppressLint("NotifyDataSetChanged") 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 = 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 ) { 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 */ }