From 8c7a8ce7c4582cfa25d35aa701c6d1808217e323 Mon Sep 17 00:00:00 2001 From: Michatec Date: Tue, 7 Apr 2026 12:59:33 +0200 Subject: [PATCH] feat(ui): add dedicated station search fragment for television platforms --- .../com/michatec/radio/AddStationFragment.kt | 214 ++++++++++++++++++ .../java/com/michatec/radio/PlayerFragment.kt | 12 +- .../radio/search/SearchResultAdapter.kt | 38 +++- .../layout-television/dialog_add_station.xml | 3 + .../layout-television/dialog_find_station.xml | 10 + .../element_search_result.xml | 4 +- .../main/res/navigation/nav_graph_main.xml | 10 + 7 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/michatec/radio/AddStationFragment.kt diff --git a/app/src/main/java/com/michatec/radio/AddStationFragment.kt b/app/src/main/java/com/michatec/radio/AddStationFragment.kt new file mode 100644 index 0000000..6047c3d --- /dev/null +++ b/app/src/main/java/com/michatec/radio/AddStationFragment.kt @@ -0,0 +1,214 @@ +package com.michatec.radio + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.Button +import android.widget.EditText +import android.widget.ProgressBar +import androidx.appcompat.widget.SearchView +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.textview.MaterialTextView +import com.michatec.radio.collection.CollectionViewModel +import com.michatec.radio.core.Station +import com.michatec.radio.helpers.CollectionHelper +import com.michatec.radio.helpers.NetworkHelper +import com.michatec.radio.search.DirectInputCheck +import com.michatec.radio.search.RadioBrowserResult +import com.michatec.radio.search.RadioBrowserSearch +import com.michatec.radio.search.SearchResultAdapter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class AddStationFragment : Fragment(), + SearchResultAdapter.SearchResultAdapterListener, + RadioBrowserSearch.RadioBrowserSearchListener, + DirectInputCheck.DirectInputCheckListener { + + private lateinit var collectionViewModel: CollectionViewModel + private lateinit var stationSearchBoxView: SearchView + private lateinit var searchRequestProgressIndicator: ProgressBar + private lateinit var noSearchResultsTextView: MaterialTextView + private lateinit var stationSearchResultList: RecyclerView + private lateinit var positiveButton: Button + private lateinit var negativeButton: Button + private lateinit var searchResultAdapter: SearchResultAdapter + private lateinit var radioBrowserSearch: RadioBrowserSearch + private lateinit var directInputCheck: DirectInputCheck + private var station: Station = Station() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + // We reuse the dialog layout as it's already optimized for TV in layout-television + val view = inflater.inflate(R.layout.dialog_find_station, container, false) + + collectionViewModel = ViewModelProvider(requireActivity())[CollectionViewModel::class.java] + radioBrowserSearch = RadioBrowserSearch(this) + directInputCheck = DirectInputCheck(this) + + stationSearchBoxView = view.findViewById(R.id.station_search_box_view) + searchRequestProgressIndicator = view.findViewById(R.id.search_request_progress_indicator) + stationSearchResultList = view.findViewById(R.id.station_search_result_list) + noSearchResultsTextView = view.findViewById(R.id.no_results_text_view) + positiveButton = view.findViewById(R.id.dialog_positive_button) + negativeButton = view.findViewById(R.id.dialog_negative_button) + + setupRecyclerView() + setupSearchView() + + positiveButton.setOnClickListener { + addStationAndExit() + } + + negativeButton.setOnClickListener { + searchResultAdapter.stopPrePlayback() + findNavController().navigateUp() + } + + stationSearchBoxView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextChange(query: String): Boolean { + handleSearch(query) + return true + } + override fun onQueryTextSubmit(query: String): Boolean { + handleSearch(query) + return true + } + }) + + return view + } + + override fun onDestroy() { + super.onDestroy() + // Stop playback when fragment is destroyed (e.g. via back button) + if (this::searchResultAdapter.isInitialized) { + searchResultAdapter.stopPrePlayback() + } + } + + private fun setupRecyclerView() { + searchResultAdapter = SearchResultAdapter(this, listOf()) + stationSearchResultList.adapter = searchResultAdapter + stationSearchResultList.layoutManager = LinearLayoutManager(context) + stationSearchResultList.itemAnimator = DefaultItemAnimator() + } + + private fun setupSearchView() { + // TV specific: ensure keyboard opens when search view gets focus + stationSearchBoxView.setOnQueryTextFocusChangeListener { v, hasFocus -> + if (hasFocus) { + // Find the internal EditText of the SearchView + val searchEditText = v.findViewById(androidx.appcompat.R.id.search_src_text) + if (searchEditText != null) { + searchEditText.requestFocus() + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(searchEditText, InputMethodManager.SHOW_IMPLICIT) + } + } + } + + // Make the SearchView always expanded and ready for input + stationSearchBoxView.isIconified = false + } + + private fun handleSearch(query: String) { + if (query.isEmpty()) { + resetLayout(true) + return + } + showProgressIndicator() + if (query.startsWith("http")) { + directInputCheck.checkStationAddress(requireContext(), query) + } else { + radioBrowserSearch.searchStation(requireContext(), query, Keys.SEARCH_TYPE_BY_KEYWORD) + } + } + + private fun addStationAndExit() { + searchResultAdapter.stopPrePlayback() + val currentCollection = collectionViewModel.collectionLiveData.value ?: return + if (station.streamContent.isNotEmpty() && station.streamContent != Keys.MIME_TYPE_UNSUPPORTED) { + CollectionHelper.addStation(requireContext(), currentCollection, station) + findNavController().navigateUp() + } else { + CoroutineScope(IO).launch { + val contentType = NetworkHelper.detectContentType(station.getStreamUri()) + station.streamContent = contentType.type + withContext(Main) { + CollectionHelper.addStation(requireContext(), currentCollection, station) + findNavController().navigateUp() + } + } + } + } + + override fun onSearchResultTapped(result: Station) { + station = result + activateAddButton() + } + + override fun activateAddButton() { + positiveButton.isEnabled = true + } + + override fun deactivateAddButton() { + positiveButton.isEnabled = false + } + + @SuppressLint("NotifyDataSetChanged") + override fun onRadioBrowserSearchResults(results: Array) { + if (results.isNotEmpty()) { + searchResultAdapter.searchResults = results.map { it.toStation() } + searchResultAdapter.notifyDataSetChanged() + resetLayout(false) + } else { + showNoResultsError() + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun onDirectInputCheck(stationList: MutableList) { + if (stationList.isNotEmpty()) { + searchResultAdapter.searchResults = stationList + searchResultAdapter.notifyDataSetChanged() + resetLayout(false) + } else { + showNoResultsError() + } + } + + private fun resetLayout(clear: Boolean) { + positiveButton.isEnabled = false + searchRequestProgressIndicator.isGone = true + noSearchResultsTextView.isGone = true + if (clear) searchResultAdapter.resetSelection(true) + } + + private fun showProgressIndicator() { + searchRequestProgressIndicator.isVisible = true + noSearchResultsTextView.isGone = true + } + + private fun showNoResultsError() { + searchRequestProgressIndicator.isGone = true + noSearchResultsTextView.isVisible = true + } +} diff --git a/app/src/main/java/com/michatec/radio/PlayerFragment.kt b/app/src/main/java/com/michatec/radio/PlayerFragment.kt index 6fbc999..d4acff7 100644 --- a/app/src/main/java/com/michatec/radio/PlayerFragment.kt +++ b/app/src/main/java/com/michatec/radio/PlayerFragment.kt @@ -34,6 +34,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionResult import androidx.media3.session.SessionToken +import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.android.volley.Request @@ -342,7 +343,14 @@ class PlayerFragment : Fragment(), /* Overrides onAddNewButtonTapped from CollectionAdapterListener */ override fun onAddNewButtonTapped() { - FindStationDialog(activity as Activity, this as FindStationDialog.FindStationDialogListener).show() + // stop playback when adding a new station + controller?.stop() + + if (activity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_LEANBACK) == true) { + findNavController().navigate(R.id.action_map_fragment_to_player_to_add_station) + } else { + FindStationDialog(activity as Activity, this as FindStationDialog.FindStationDialogListener).show() + } } @@ -625,6 +633,8 @@ class PlayerFragment : Fragment(), } withContext(Main) { if (stationList.isNotEmpty()) { + // stop playback when adding a new station via intent + controller?.stop() AddStationDialog(activity as Activity, stationList, this@PlayerFragment as AddStationDialog.AddStationDialogListener).show() } else { // invalid address diff --git a/app/src/main/java/com/michatec/radio/search/SearchResultAdapter.kt b/app/src/main/java/com/michatec/radio/search/SearchResultAdapter.kt index bc9d425..3b55b56 100644 --- a/app/src/main/java/com/michatec/radio/search/SearchResultAdapter.kt +++ b/app/src/main/java/com/michatec/radio/search/SearchResultAdapter.kt @@ -8,15 +8,21 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import androidx.media3.common.MediaItem +import androidx.media3.common.* +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.recyclerview.widget.RecyclerView import com.google.android.material.textview.MaterialTextView import com.michatec.radio.R import com.michatec.radio.core.Station +import com.michatec.radio.helpers.NativeAudioProcessor /* @@ -31,6 +37,7 @@ class SearchResultAdapter( private var exoPlayer: ExoPlayer? = null private var paused: Boolean = false private var isItemSelected: Boolean = false + private var nativeAudioProcessor = NativeAudioProcessor() /* Listener Interface */ interface SearchResultAdapterListener { @@ -138,6 +145,7 @@ class SearchResultAdapter( } + @OptIn(UnstableApi::class) private fun performPrePlayback(context: Context, streamUri: String) { if (streamUri.contains(".m3u8")) { // release previous player if it exists @@ -151,8 +159,30 @@ class SearchResultAdapter( // release previous player if it exists stopPrePlayback() - // create a new instance of ExoPlayer - exoPlayer = ExoPlayer.Builder(context).build() + // set up audio attributes for the preview player + val audioAttributes = androidx.media3.common.AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build() + + // Create a RenderersFactory that injects the NativeAudioProcessor + val renderersFactory = object : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean + ): AudioSink? { + return DefaultAudioSink.Builder(context) + .setAudioProcessors(arrayOf(nativeAudioProcessor)) + .build() + } + } + + // create a new instance of ExoPlayer with focus handling + exoPlayer = ExoPlayer.Builder(context, renderersFactory) + .setAudioAttributes(audioAttributes, true) + .setHandleAudioBecomingNoisy(true) + .build() // create a MediaItem with the streamUri val mediaItem = MediaItem.fromUri(streamUri) @@ -199,7 +229,7 @@ class SearchResultAdapter( .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build() - val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) + val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setAudioAttributes(audioAttributes) .build() diff --git a/app/src/main/res/layout-television/dialog_add_station.xml b/app/src/main/res/layout-television/dialog_add_station.xml index 77f38d6..992db81 100644 --- a/app/src/main/res/layout-television/dialog_add_station.xml +++ b/app/src/main/res/layout-television/dialog_add_station.xml @@ -11,6 +11,7 @@ android:layout_height="0dp" android:clipToPadding="false" android:paddingBottom="16dp" + android:nextFocusRight="@id/dialog_negative_button" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/guideline" app:layout_constraintStart_toStartOf="parent" @@ -39,6 +40,7 @@ style="@style/Widget.Material3.Button" android:layout_width="match_parent" android:layout_height="wrap_content" + android:nextFocusLeft="@id/station_list" android:text="@string/dialog_find_station_button_add" />