mirror of
https://github.com/Michatec/Radio.git
synced 2026-05-31 00:52:40 +02:00
feat(ui): add dedicated station search fragment for television platforms
This commit is contained in:
@@ -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<EditText>(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<RadioBrowserResult>) {
|
||||
if (results.isNotEmpty()) {
|
||||
searchResultAdapter.searchResults = results.map { it.toStation() }
|
||||
searchResultAdapter.notifyDataSetChanged()
|
||||
resetLayout(false)
|
||||
} else {
|
||||
showNoResultsError()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onDirectInputCheck(stationList: MutableList<Station>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
<Button
|
||||
@@ -47,6 +49,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:nextFocusLeft="@id/station_list"
|
||||
android:text="@string/dialog_generic_button_cancel" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
android:id="@+id/station_search_box_view"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:clickable="true"
|
||||
android:nextFocusRight="@id/dialog_negative_button"
|
||||
android:nextFocusDown="@id/station_search_result_list"
|
||||
app:iconifiedByDefault="false"
|
||||
app:layout_constraintEnd_toStartOf="@+id/guideline"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
@@ -44,6 +49,8 @@
|
||||
android:layout_marginTop="8dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="16dp"
|
||||
android:nextFocusRight="@id/dialog_negative_button"
|
||||
android:nextFocusUp="@id/station_search_box_view"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/guideline"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
@@ -73,6 +80,8 @@
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:nextFocusUp="@id/station_search_box_view"
|
||||
android:nextFocusLeft="@id/station_search_result_list"
|
||||
android:text="@string/dialog_find_station_button_add" />
|
||||
|
||||
<Button
|
||||
@@ -81,6 +90,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:nextFocusLeft="@id/station_search_result_list"
|
||||
android:text="@string/dialog_generic_button_cancel" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
android:layout_marginBottom="8dp"
|
||||
android:focusable="true"
|
||||
android:clickable="true"
|
||||
android:nextFocusRight="@+id/dialog_positive_button"
|
||||
android:nextFocusRight="@+id/dialog_negative_button"
|
||||
android:background="@drawable/selector_search_result_item">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
@@ -20,6 +20,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleLarge"
|
||||
android:textColor="@color/text_default"
|
||||
@@ -35,6 +36,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:ellipsize="marquee"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||
android:textColor="@color/text_lightweight"
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
<action
|
||||
android:id="@+id/action_map_fragment_to_visualizer_fragment"
|
||||
app:destination="@id/visualizer_destination" />
|
||||
<action
|
||||
android:id="@+id/action_map_fragment_to_player_to_add_station"
|
||||
app:destination="@id/add_station_destination" />
|
||||
</fragment>
|
||||
|
||||
<!-- SETTINGS -->
|
||||
@@ -42,4 +45,11 @@
|
||||
android:id="@+id/visualizer_destination"
|
||||
android:name="com.michatec.radio.VisualizerFragment"
|
||||
android:label="Visualizer" />
|
||||
|
||||
<!-- ADD STATION (TV) -->
|
||||
<fragment
|
||||
android:id="@+id/add_station_destination"
|
||||
android:name="com.michatec.radio.AddStationFragment"
|
||||
android:label="Add Station"
|
||||
tools:layout="@layout/dialog_find_station" />
|
||||
</navigation>
|
||||
|
||||
Reference in New Issue
Block a user