4 Commits

12 changed files with 400 additions and 49 deletions
+84 -39
View File
@@ -121,48 +121,90 @@ struct alignas(16) BassFilter {
}
};
template<int SIZE>
struct CircularBuffer {
alignas(16) std::array<float, SIZE> data = {};
int pos = 0;
[[nodiscard]] inline float read() const { return data[pos]; }
inline void write(float v) { data[pos] = v; }
inline void advance() { pos = (pos + 1) % SIZE; }
};
class ReverbOptimized {
std::array<CircularBuffer<1116>, 4> combs;
std::array<CircularBuffer<556>, 2> allpasses;
std::array<float, 4> combFeedback = {0.841f, 0.815f, 0.796f, 0.771f};
struct DelayLine {
float buffer[48000]{};
int size = 48000;
int pos = 0;
inline float read(float delaySamples) {
float readPos = static_cast<float>(pos) - delaySamples;
if (readPos < 0.0f) readPos += static_cast<float>(size);
int i1 = static_cast<int>(readPos);
int i2 = (i1 + 1) % size;
float frac = readPos - static_cast<float>(i1);
return buffer[i1] * (1.0f - frac) + buffer[i2] * frac;
}
inline void write(float x) {
buffer[pos] = x;
pos++;
if (pos >= size) pos = 0;
}
};
DelayLine delays[8];
float feedback[8] = {
0.78f, 0.80f, 0.82f, 0.84f,
0.76f, 0.79f, 0.81f, 0.83f
};
float baseDelay[8] = {
1423.0f, 1557.0f, 1617.0f, 1789.0f,
1867.0f, 1999.0f, 2137.0f, 2251.0f
};
float modPhase[8] = {};
float modSpeed[8] = {
0.10f, 0.12f, 0.09f, 0.11f,
0.13f, 0.08f, 0.14f, 0.07f
};
public:
std::atomic<float> mix{0.0f};
inline float process(float x) {
float m = mix.load(std::memory_order_acquire);
inline float processSample(float x) {
float m = mix.load(std::memory_order_relaxed);
if (m < 0.01f) return x;
float out = 0.0f;
#pragma GCC unroll 4
for (int i = 0; i < 4; i++) {
float delayed = combs[static_cast<size_t>(i)].read();
#pragma GCC unroll 8
for (int i = 0; i < 8; i++) {
modPhase[i] += modSpeed[i];
if (modPhase[i] > 2.0f * static_cast<float>(M_PI)) modPhase[i] -= 2.0f * static_cast<float>(M_PI);
float mod = sinf(modPhase[i]) * 5.0f;
float delayTime = baseDelay[i] + mod;
float delayed = delays[i].read(delayTime);
float input = x + delayed * feedback[i] + DENORMAL_OFFSET;
delays[i].write(input);
out += delayed;
combs[static_cast<size_t>(i)].write(x + delayed * combFeedback[static_cast<size_t>(i)] + DENORMAL_OFFSET);
combs[static_cast<size_t>(i)].advance();
}
out *= 0.25f;
for (int i = 0; i < 2; i++) {
float bufOut = allpasses[static_cast<size_t>(i)].read();
float xOut = -0.5f * out + bufOut;
allpasses[static_cast<size_t>(i)].write(out + 0.5f * bufOut);
allpasses[static_cast<size_t>(i)].advance();
out = xOut;
}
return x * (1.0f - m) + out * m;
return x * (1.0f - m) + (out * 0.125f) * m;
}
inline void processBlock(float* __restrict__ left, float* __restrict__ right, int count) {
float m = mix.load(std::memory_order_acquire);
float m = mix.load(std::memory_order_relaxed);
if (m < 0.01f) return;
for (int i = 0; i < count; i++) {
left[i] = process(left[i]);
right[i] = process(right[i]);
float l = processSample(left[i]);
float r = processSample(right[i]);
float wetL = l * 0.7f + r * 0.3f;
float wetR = r * 0.7f + l * 0.3f;
left[i] = wetL;
right[i] = wetR;
}
}
};
@@ -175,16 +217,14 @@ public:
private:
float envelopeL = 0.0f, envelopeR = 0.0f;
float attackCoef = 0.0f, releaseCoef = 0.0f;
bool coefficientsValid = false;
public:
inline void updateCoefficients() {
if (coefficientsValid) return;
float a = attack.load(std::memory_order_acquire);
float r = release.load(std::memory_order_acquire);
float sr = sampleRate.load(std::memory_order_acquire);
float a = attack.load(std::memory_order_relaxed);
float r = release.load(std::memory_order_relaxed);
float sr = sampleRate.load(std::memory_order_relaxed);
attackCoef = expf(-1.0f / (a * sr));
releaseCoef = expf(-1.0f / (r * sr));
coefficientsValid = true;
}
inline void processBlock(float* __restrict__ buffer, int count, float& envelope) {
updateCoefficients();
@@ -375,15 +415,20 @@ JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_proc
gRightBuf[static_cast<size_t>(i)] = static_cast<float>(buffer[i * 2 + 1]) * INV_32768;
}
bool eqEnabled = gEqEnabled.load(std::memory_order_acquire);
bool eqEnabled = gEqEnabled.load(std::memory_order_relaxed);
if (eqEnabled) {
for (int i = 0; i < numFrames; i++) {
float xL = gLeftBuf[static_cast<size_t>(i)];
float xR = gRightBuf[static_cast<size_t>(i)];
for (int b = 0; b < NUM_EQ_BANDS; b++) {
float g = gEqL[b].currentGain.load(std::memory_order_relaxed);
if (std::abs(g) < 0.01f) continue;
xL = gEqL[b].process(xL);
xR = gEqR[b].process(xR);
}
gLeftBuf[static_cast<size_t>(i)] = xL;
gRightBuf[static_cast<size_t>(i)] = xR;
}
@@ -396,7 +441,7 @@ JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_proc
gReverbL.processBlock(gLeftBuf.data(), gRightBuf.data(), numFrames);
float stereoWidth = gStereoWidth.load(std::memory_order_acquire);
float stereoWidth = gStereoWidth.load(std::memory_order_relaxed);
if (stereoWidth != 1.0f) {
float halfWidth = stereoWidth * 0.5f;
for (int j = 0; j < numFrames; j++) {
@@ -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
@@ -169,6 +169,9 @@ class CollectionAdapter(
addNewViewHolder.settingsButtonView.setOnClickListener {
it.findNavController().navigate(R.id.settings_destination)
}
addNewViewHolder.visualizerButtonView.setOnClickListener {
it.findNavController().navigate(R.id.visualizer_destination)
}
}
// CASE STATION CARD
is StationViewHolder -> {
@@ -187,6 +190,8 @@ class CollectionAdapter(
setPlaybackProgress(stationViewHolder, station)
setDownloadProgress(stationViewHolder, station)
stationViewHolder.playButtonView.isGone = true
// highlight if reordering
if (reorderStationUuid == station.uuid) {
stationViewHolder.stationCardView.setStrokeColor(
@@ -754,6 +759,8 @@ class CollectionAdapter(
listItemAddNewLayout.findViewById(R.id.card_add_new_station)
val settingsButtonView: ExtendedFloatingActionButton =
listItemAddNewLayout.findViewById(R.id.card_settings)
val visualizerButtonView: ExtendedFloatingActionButton =
listItemAddNewLayout.findViewById(R.id.card_visualizer)
}
/*
* End of inner class
@@ -772,7 +779,6 @@ class CollectionAdapter(
val bufferingProgress: ProgressBar = stationCardLayout.findViewById(R.id.buffering_progress)
val downloadProgress: ProgressBar = stationCardLayout.findViewById(R.id.download_progress)
// 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 =
@@ -97,7 +97,7 @@ class NativeAudioProcessor : BaseAudioProcessor() {
// ===== Presets =====
fun setPresetRock() {
enableDrc(true)
setReverb(0.10f)
setReverb(0.26f)
setWidth(1.1f)
setEqAll(floatArrayOf(2f, 1f, 0f, -1f, -1f, 0f, 1f, 2f, 2f, 3f))
enableBassBoost(0.9f)
@@ -105,7 +105,7 @@ class NativeAudioProcessor : BaseAudioProcessor() {
fun setPresetPop() {
enableDrc(true)
setReverb(0.10f)
setReverb(0.18f)
setWidth(1.05f)
setEqAll(floatArrayOf(0f, 1f, 1f, 1f, 0f, 0f, 1f, 2f, 2f, 1f))
enableBassBoost(0.6f)
@@ -263,7 +263,7 @@ object PreferencesHelper {
/* Loads Reverb mix */
fun loadReverb(): Float {
return if (sharedPreferences.getBoolean(Keys.PREF_REVERB, false)) 0.18f else 0.0f
return if (sharedPreferences.getBoolean(Keys.PREF_REVERB, false)) 0.3f else 0.0f
}
@@ -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"
@@ -22,6 +22,24 @@
app:strokeColor="@color/list_card_stroke_background"
app:strokeWidth="3dp" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/card_visualizer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="10dp"
android:layout_marginEnd="70dp"
android:layout_marginBottom="24dp"
android:focusable="true"
android:clickable="true"
android:stateListAnimator="@null"
app:backgroundTint="@color/list_card_background"
app:icon="@drawable/ic_music_note_24dp"
app:iconTint="@color/icon_default"
app:rippleColor="@color/list_card_stroke_background"
app:strokeColor="@color/list_card_stroke_background"
app:strokeWidth="3dp" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/card_settings"
android:layout_width="wrap_content"
@@ -13,6 +13,12 @@
<action
android:id="@+id/action_map_fragment_to_settings_fragment"
app:destination="@id/settings_destination" />
<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 -->
@@ -39,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>