diff --git a/.gitignore b/.gitignore
index e84f090..3400bb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@
/build
/captures
/gradle/gradle-daemon-jvm.properties
-/.kotlin
\ No newline at end of file
+/.kotlin
+/app/.cxx/
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 9a63b06..fd264be 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -22,6 +22,11 @@ android {
versionCode 144
versionName '14.4'
resourceConfigurations += ['en', 'de', 'el', 'nl', 'pl', 'ru','uk', 'ja', 'da', 'fr']
+ externalNativeBuild {
+ cmake {
+ cppFlags ''
+ }
+ }
}
compileOptions {
@@ -49,6 +54,12 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
+ externalNativeBuild {
+ cmake {
+ path file('src/main/cpp/CMakeLists.txt')
+ version '3.22.1'
+ }
+ }
}
dependencies {
@@ -57,6 +68,7 @@ dependencies {
// Google Stuff //
implementation libs.material
implementation libs.gson
+ implementation 'com.google.android.gms:play-services-cast-framework:21.5.0'
// AndroidX Stuff //
implementation libs.core.ktx
@@ -67,6 +79,7 @@ dependencies {
implementation libs.media3.exoplayer
implementation libs.media3.exoplayer.hls
implementation libs.media3.session
+ implementation libs.media3.cast
implementation libs.media3.datasource.okhttp
implementation libs.navigation.fragment.ktx
implementation libs.navigation.ui.ktx
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6f04711..50e5511 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -31,8 +31,12 @@
android:usesCleartextTraffic="true"
tools:targetApi="33">
+
+
+
-
@@ -48,54 +52,41 @@
android:theme="@style/SplashTheme"
android:exported="true">
-
-
-
-
-
-
-
-
+
-
-
-
-
-
+
@@ -106,11 +97,9 @@
-
-
@@ -122,20 +111,16 @@
-
-
-
-
-
-
@@ -159,8 +142,6 @@
-
-
@@ -169,8 +150,6 @@
-
-
+#include
+#include
+#include
+#include
+#include
+
+// --- DSP Classes ---
+
+/**
+ * Biquad Filter for EQ and Shelving
+ */
+class Biquad {
+public:
+ float a0 = 1.0f, a1 = 0.0f, a2 = 0.0f, b1 = 0.0f, b2 = 0.0f;
+ float z1 = 0.0f, z2 = 0.0f;
+
+ void setPeakingEQ(float sampleRate, float freq, float gainDb, float bandwidth) {
+ float a = powf(10.0f, gainDb / 40.0f);
+ float w0 = 2.0f * static_cast(M_PI) * freq / sampleRate;
+ float alpha = sinf(w0) * sinhf(logf(2.0f) / 2.0f * bandwidth * w0 / sinf(w0));
+
+ float b0 = 1.0f + alpha * a;
+ a1 = -2.0f * cosf(w0);
+ a2 = 1.0f - alpha * a;
+ float b0_inv = 1.0f / (1.0f + alpha / a);
+ b1 = -2.0f * cosf(w0) * b0_inv;
+ b2 = (1.0f - alpha / a) * b0_inv;
+ a0 = b0 * b0_inv;
+ a1 *= b0_inv;
+ a2 *= b0_inv;
+ }
+
+ void setLowShelf(float sampleRate, float frequency, float gainDb, float q) {
+ float a = powf(10.0f, gainDb / 40.0f);
+ float w0 = 2.0f * static_cast(M_PI) * frequency / sampleRate;
+ float alpha = sinf(w0) / 2.0f * sqrtf((a + 1.0f / a) * (1.0f / q - 1.0f) + 2.0f);
+ float cosW0 = cosf(w0);
+
+ float b0 = a * ((a + 1.0f) - (a - 1.0f) * cosW0 + 2.0f * sqrtf(a) * alpha);
+ a1 = 2.0f * a * ((a - 1.0f) - (a + 1.0f) * cosW0);
+ a2 = a * ((a + 1.0f) - (a - 1.0f) * cosW0 - 2.0f * sqrtf(a) * alpha);
+ float b0_inv = 1.0f / ((a + 1.0f) + (a - 1.0f) * cosW0 + 2.0f * sqrtf(a) * alpha);
+ b1 = -2.0f * ((a - 1.0f) + (a + 1.0f) * cosW0) * b0_inv;
+ b2 = ((a + 1.0f) + (a - 1.0f) * cosW0 - 2.0f * sqrtf(a) * alpha) * b0_inv;
+ a0 = b0 * b0_inv;
+ a1 *= b0_inv;
+ a2 *= b0_inv;
+ }
+
+ float process(float in) {
+ float out = in * a0 + z1;
+ z1 = in * a1 + z2 - b1 * out;
+ z2 = in * a2 - b2 * out;
+ return out;
+ }
+};
+
+/**
+ * Dynamic Range Compressor
+ */
+class Compressor {
+public:
+ float threshold = 0.3f;
+ float ratio = 4.0f;
+ float attack = 0.01f;
+ float release = 0.2f;
+ float sampleRate = 44100.0f;
+ float envelope = 0.0f;
+
+ void process(float* buffer, int size) {
+ float attackCoef = expf(-1.0f / (attack * sampleRate));
+ float releaseCoef = expf(-1.0f / (release * sampleRate));
+
+ for (int i = 0; i < size; ++i) {
+ float absInput = std::abs(buffer[i]);
+ if (absInput > envelope)
+ envelope = attackCoef * (envelope - absInput) + absInput;
+ else
+ envelope = releaseCoef * (envelope - absInput) + absInput;
+
+ if (envelope > threshold) {
+ float gainReduction = threshold + (envelope - threshold) / ratio;
+ buffer[i] *= (gainReduction / envelope);
+ }
+ }
+ }
+};
+
+/**
+ * Simple Reverb (Comb Filter based)
+ */
+class Reverb {
+public:
+ std::vector delayLine;
+ int pos = 0;
+ float feedback = 0.4f;
+ float mix = 0.0f;
+
+ Reverb() { delayLine.resize(4410, 0.0f); } // ~100ms
+
+ float process(float in) {
+ float delayed = delayLine[static_cast(pos)];
+ delayLine[static_cast(pos)] = in + delayed * feedback;
+ pos = (pos + 1) % static_cast(delayLine.size());
+ return in + delayed * mix;
+ }
+};
+
+// --- Global Engine State ---
+Compressor gCompressor;
+Reverb gReverb;
+std::vector gEqBands(10);
+Biquad gBassBoost;
+
+bool gDrcEnabled = false;
+bool gReverbEnabled = false;
+bool gEqEnabled = false;
+bool gBassBoostEnabled = false;
+
+extern "C" {
+
+JNIEXPORT void JNICALL
+Java_com_michatec_radio_helpers_NativeAudioProcessor_setDrcEnabled(JNIEnv *env, jobject thiz, jboolean enabled) {
+ gDrcEnabled = enabled;
+}
+
+JNIEXPORT void JNICALL
+Java_com_michatec_radio_helpers_NativeAudioProcessor_setReverbMix(JNIEnv *env, jobject thiz, jfloat mix) {
+ gReverb.mix = mix;
+ gReverbEnabled = (mix > 0.01f);
+}
+
+JNIEXPORT void JNICALL
+Java_com_michatec_radio_helpers_NativeAudioProcessor_setEqBand(JNIEnv *env, jobject thiz, jint band, jfloat gainDb) {
+ float freqs[] = {31.25f, 62.5f, 125.0f, 250.0f, 500.0f, 1000.0f, 2000.0f, 4000.0f, 8000.0f, 16000.0f};
+ if (band >= 0 && band < 10) {
+ gEqBands[static_cast(band)].setPeakingEQ(44100.0f, freqs[band], gainDb, 1.0f);
+ gEqEnabled = true;
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_michatec_radio_helpers_NativeAudioProcessor_setBassBoost(JNIEnv *env, jobject thiz, jfloat gainDb) {
+ if (gainDb > 0.0f) {
+ gBassBoost.setLowShelf(44100.0f, 150.0f, gainDb, 0.707f);
+ gBassBoostEnabled = true;
+ } else {
+ gBassBoostEnabled = false;
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_michatec_radio_helpers_NativeAudioProcessor_processAudio(JNIEnv *env, jobject thiz, jshortArray data, jint size) {
+ jshort *buffer = env->GetShortArrayElements(data, nullptr);
+ if (!buffer) return;
+
+ std::vector floatBuf(static_cast(size));
+ for (int i = 0; i < size; ++i) floatBuf[static_cast(i)] = static_cast(buffer[i]) / 32768.0f;
+
+ // Apply EQ
+ if (gEqEnabled) {
+ for (auto &band : gEqBands) {
+ for (int i = 0; i < size; ++i) floatBuf[static_cast(i)] = band.process(floatBuf[static_cast(i)]);
+ }
+ }
+
+ // Apply Bass Boost
+ if (gBassBoostEnabled) {
+ for (int i = 0; i < size; ++i) floatBuf[static_cast(i)] = gBassBoost.process(floatBuf[static_cast(i)]);
+ }
+
+ // Apply Reverb
+ if (gReverbEnabled) {
+ for (int i = 0; i < size; ++i) floatBuf[static_cast(i)] = gReverb.process(floatBuf[static_cast(i)]);
+ }
+
+ // Apply Compressor (at the end to prevent clipping)
+ if (gDrcEnabled) {
+ gCompressor.process(floatBuf.data(), size);
+ }
+
+ // Back to short
+ for (int i = 0; i < size; ++i) {
+ float out = std::max(-1.0f, std::min(1.0f, floatBuf[static_cast(i)]));
+ buffer[i] = static_cast(out * 32767.0f);
+ }
+
+ env->ReleaseShortArrayElements(data, buffer, 0);
+}
+
+} // extern "C"
\ No newline at end of file
diff --git a/app/src/main/java/com/michatec/radio/CastOptionsProvider.kt b/app/src/main/java/com/michatec/radio/CastOptionsProvider.kt
new file mode 100644
index 0000000..10ee668
--- /dev/null
+++ b/app/src/main/java/com/michatec/radio/CastOptionsProvider.kt
@@ -0,0 +1,32 @@
+package com.michatec.radio
+
+import android.content.Context
+import com.google.android.gms.cast.CastMediaControlIntent
+import com.google.android.gms.cast.framework.CastOptions
+import com.google.android.gms.cast.framework.OptionsProvider
+import com.google.android.gms.cast.framework.SessionProvider
+import com.google.android.gms.cast.framework.media.CastMediaOptions
+import com.google.android.gms.cast.framework.media.NotificationOptions
+
+@Suppress("unused")
+class CastOptionsProvider : OptionsProvider {
+ override fun getCastOptions(context: Context): CastOptions {
+ val notificationOptions = NotificationOptions.Builder()
+ .setTargetActivityClassName(MainActivity::class.java.name)
+ .build()
+ val mediaOptions = CastMediaOptions.Builder()
+ .setNotificationOptions(notificationOptions)
+ .build()
+
+ return CastOptions.Builder()
+ .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
+ .setResumeSavedSession(true)
+ .setStopReceiverApplicationWhenEndingSession(true)
+ .setCastMediaOptions(mediaOptions)
+ .build()
+ }
+
+ override fun getAdditionalSessionProviders(context: Context): List? {
+ return null
+ }
+}
diff --git a/app/src/main/java/com/michatec/radio/Keys.kt b/app/src/main/java/com/michatec/radio/Keys.kt
index b8224e8..f0edb8a 100644
--- a/app/src/main/java/com/michatec/radio/Keys.kt
+++ b/app/src/main/java/com/michatec/radio/Keys.kt
@@ -60,6 +60,12 @@ object Keys {
const val PREF_LARGE_BUFFER_SIZE: String = "LARGE_BUFFER_SIZE"
const val PREF_EDIT_STATIONS: String = "EDIT_STATIONS"
const val PREF_EDIT_STREAMS_URIS: String = "EDIT_STREAMS_URIS"
+ const val PREF_BASS_BOOST: String = "BASS_BOOST"
+ const val PREF_REVERB: String = "REVERB"
+ const val PREF_DRC: String = "DRC"
+ const val PREF_EQ_LOW: String = "EQ_LOW"
+ const val PREF_EQ_MID: String = "EQ_MID"
+ const val PREF_EQ_HIGH: String = "EQ_HIGH"
// default const values
const val DEFAULT_SIZE_OF_METADATA_HISTORY: Int = 25
@@ -84,7 +90,6 @@ object Keys {
const val DIALOG_REMOVE_STATION: Int = 2
const val DIALOG_UPDATE_STATION_IMAGES: Int = 4
const val DIALOG_RESTORE_COLLECTION: Int = 5
- const val DIALOG_THEME_SELECTION: Int = 6
// dialog results
const val DIALOG_EMPTY_PAYLOAD_STRING: String = ""
diff --git a/app/src/main/java/com/michatec/radio/MainActivity.kt b/app/src/main/java/com/michatec/radio/MainActivity.kt
index 874de8a..ff0091e 100644
--- a/app/src/main/java/com/michatec/radio/MainActivity.kt
+++ b/app/src/main/java/com/michatec/radio/MainActivity.kt
@@ -27,13 +27,12 @@ class MainActivity : AppCompatActivity() {
/* Main class variables */
private lateinit var appBarConfiguration: AppBarConfiguration
-
/* Overrides onCreate from AppCompatActivity */
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
setTheme(R.style.AppTheme)
super.onCreate(savedInstanceState)
-
+
// Free Android
FreeDroidWarn.showWarningOnUpgrade(this, BuildConfig.VERSION_CODE)
@@ -46,7 +45,8 @@ class MainActivity : AppCompatActivity() {
// set up action bar
setSupportActionBar(findViewById(R.id.main_toolbar))
val toolbar: Toolbar = findViewById(R.id.main_toolbar)
- val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment
val navController = navHostFragment.navController
appBarConfiguration = AppBarConfiguration(navController.graph)
NavigationUI.setupWithNavController(toolbar, navController, appBarConfiguration)
@@ -69,9 +69,7 @@ class MainActivity : AppCompatActivity() {
private fun hideLoadingOverlay() {
findViewById(R.id.loading_layout)?.let { overlay ->
if (overlay.isVisible) {
- overlay.animate()
- .alpha(0f)
- .setDuration(500)
+ overlay.animate().alpha(0f).setDuration(500)
.withEndAction { overlay.visibility = View.GONE }
}
}
@@ -90,7 +88,8 @@ class MainActivity : AppCompatActivity() {
/* Overrides onSupportNavigateUp from AppCompatActivity */
override fun onSupportNavigateUp(): Boolean {
- val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment
val navController = navHostFragment.navController
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
}
diff --git a/app/src/main/java/com/michatec/radio/PlayerFragment.kt b/app/src/main/java/com/michatec/radio/PlayerFragment.kt
index 655e248..70f4088 100644
--- a/app/src/main/java/com/michatec/radio/PlayerFragment.kt
+++ b/app/src/main/java/com/michatec/radio/PlayerFragment.kt
@@ -809,7 +809,7 @@ class PlayerFragment : Fragment(),
/*
- * Check for update on github
+ * Check for update on GitHub
*/
private fun checkForUpdates() {
val url = getString(R.string.snackbar_github_update_check_url)
diff --git a/app/src/main/java/com/michatec/radio/PlayerService.kt b/app/src/main/java/com/michatec/radio/PlayerService.kt
index 44a6e5a..0be2697 100644
--- a/app/src/main/java/com/michatec/radio/PlayerService.kt
+++ b/app/src/main/java/com/michatec/radio/PlayerService.kt
@@ -14,8 +14,11 @@ import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.HttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
+import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
+import androidx.media3.exoplayer.audio.AudioSink
+import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.upstream.DefaultAllocator
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
@@ -25,10 +28,7 @@ import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.michatec.radio.core.Collection
-import com.michatec.radio.helpers.AudioHelper
-import com.michatec.radio.helpers.CollectionHelper
-import com.michatec.radio.helpers.FileHelper
-import com.michatec.radio.helpers.PreferencesHelper
+import com.michatec.radio.helpers.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.Main
import java.util.*
@@ -38,7 +38,7 @@ import java.util.*
* PlayerService class
*/
@UnstableApi
-class PlayerService : MediaLibraryService() {
+class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenceChangeListener {
/* Define log tag */
private val TAG: String = PlayerService::class.java.simpleName
@@ -56,6 +56,9 @@ class PlayerService : MediaLibraryService() {
private var playbackRestartCounter: Int = 0
private var playLastStation: Boolean = false
private var manuallyCancelledSleepTimer = false
+
+ // Native Audio Processor instance
+ private val nativeAudioProcessor = NativeAudioProcessor()
/* Overrides onCreate from Service */
@@ -76,6 +79,11 @@ class PlayerService : MediaLibraryService() {
setMediaNotificationProvider(notificationProvider)
// fetch the metadata history
metadataHistory = PreferencesHelper.loadMetadataHistory()
+
+ // register preference change listener
+ PreferencesHelper.registerPreferenceChangeListener(this)
+ // apply initial audio effects
+ applyAudioEffects()
}
@@ -86,6 +94,8 @@ class PlayerService : MediaLibraryService() {
player.removeListener(playerListener)
player.release()
mediaLibrarySession.release()
+ // unregister preference change listener
+ PreferencesHelper.unregisterPreferenceChangeListener(this)
super.onDestroy()
}
@@ -106,8 +116,26 @@ class PlayerService : MediaLibraryService() {
/* Initializes the ExoPlayer */
private fun initializePlayer() {
- val exoPlayer: ExoPlayer = ExoPlayer.Builder(this).apply {
- setAudioAttributes(AudioAttributes.DEFAULT, true)
+ val audioAttributes = AudioAttributes.Builder()
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
+ .setUsage(C.USAGE_MEDIA)
+ .build()
+
+ // Create a RenderersFactory that injects the NativeAudioProcessor
+ val renderersFactory = object : DefaultRenderersFactory(this) {
+ override fun buildAudioSink(
+ context: Context,
+ enableFloatOutput: Boolean,
+ enableAudioTrackPlaybackParams: Boolean
+ ): AudioSink? {
+ return DefaultAudioSink.Builder(context)
+ .setAudioProcessors(arrayOf(nativeAudioProcessor))
+ .build()
+ }
+ }
+
+ val exoPlayer: ExoPlayer = ExoPlayer.Builder(this, renderersFactory).apply {
+ setAudioAttributes(audioAttributes, true)
setHandleAudioBecomingNoisy(true)
setLoadControl(createDefaultLoadControl(bufferSizeMultiplier))
setMediaSourceFactory(
@@ -246,6 +274,28 @@ class PlayerService : MediaLibraryService() {
}
+ /* Applies audio effects based on preferences */
+ private fun applyAudioEffects() {
+ nativeAudioProcessor.enableBassBoost(PreferencesHelper.loadBassBoost())
+ nativeAudioProcessor.setReverb(PreferencesHelper.loadReverb())
+ nativeAudioProcessor.enableDrc(PreferencesHelper.loadDrcEnabled())
+ nativeAudioProcessor.setEq(0, PreferencesHelper.loadEqLow())
+ nativeAudioProcessor.setEq(1, PreferencesHelper.loadEqMid())
+ nativeAudioProcessor.setEq(2, PreferencesHelper.loadEqHigh())
+ }
+
+
+ /* Overrides onSharedPreferenceChanged from SharedPreferences.OnSharedPreferenceChangeListener */
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
+ when (key) {
+ Keys.PREF_BASS_BOOST, Keys.PREF_REVERB, Keys.PREF_DRC,
+ Keys.PREF_EQ_LOW, Keys.PREF_EQ_MID, Keys.PREF_EQ_HIGH -> {
+ applyAudioEffects()
+ }
+ }
+ }
+
+
/*
* Custom MediaSession Callback that handles player commands
*/
@@ -268,7 +318,6 @@ class PlayerService : MediaLibraryService() {
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
- // add custom commands
val connectionResult: MediaSession.ConnectionResult = super.onConnect(session, controller)
val builder: SessionCommands.Builder = connectionResult.availableSessionCommands.buildUpon()
builder.add(SessionCommand(Keys.CMD_START_SLEEP_TIMER, Bundle.EMPTY))
diff --git a/app/src/main/java/com/michatec/radio/SettingsFragment.kt b/app/src/main/java/com/michatec/radio/SettingsFragment.kt
index 8ba9674..685f486 100644
--- a/app/src/main/java/com/michatec/radio/SettingsFragment.kt
+++ b/app/src/main/java/com/michatec/radio/SettingsFragment.kt
@@ -186,6 +186,30 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
return@setOnPreferenceChangeListener true
}
+ // set up "Bass Boost" preference
+ val preferenceBassBoost = MarqueeSwitchPreference(context)
+ preferenceBassBoost.title = getString(R.string.pref_bass_boost_title)
+ preferenceBassBoost.setIcon(R.drawable.ic_music_note_24dp)
+ preferenceBassBoost.key = Keys.PREF_BASS_BOOST
+ preferenceBassBoost.summary = getString(R.string.pref_bass_boost_summary)
+ preferenceBassBoost.setDefaultValue(false)
+
+ // set up "Reverb" preference
+ val preferenceReverb = MarqueeSwitchPreference(context)
+ preferenceReverb.title = getString(R.string.pref_reverb_title)
+ preferenceReverb.setIcon(R.drawable.ic_music_note_24dp)
+ preferenceReverb.key = Keys.PREF_REVERB
+ preferenceReverb.summary = getString(R.string.pref_reverb_summary)
+ preferenceReverb.setDefaultValue(false)
+
+ // set up "DRC" preference
+ val preferenceDrc = MarqueeSwitchPreference(context)
+ preferenceDrc.title = getString(R.string.pref_drc_title)
+ preferenceDrc.setIcon(R.drawable.ic_music_note_24dp)
+ preferenceDrc.key = Keys.PREF_DRC
+ preferenceDrc.summary = getString(R.string.pref_drc_summary)
+ preferenceDrc.setDefaultValue(true)
+
// set up "App Version" preference
val preferenceAppVersion = Preference(context)
preferenceAppVersion.title = getString(R.string.pref_app_version_title)
@@ -238,51 +262,52 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
// set preference categories
val preferenceCategoryGeneral = PreferenceCategory(activity as Context)
preferenceCategoryGeneral.title = getString(R.string.pref_general_title)
- preferenceCategoryGeneral.contains(preferenceThemeSelection)
+
+ val preferenceCategoryAudioEffects = PreferenceCategory(context)
+ preferenceCategoryAudioEffects.title = getString(R.string.pref_audio_effects_title)
val preferenceCategoryMaintenance = PreferenceCategory(activity as Context)
preferenceCategoryMaintenance.title = getString(R.string.pref_maintenance_title)
- preferenceCategoryMaintenance.contains(preferenceUpdateStationImages)
- preferenceCategoryMaintenance.contains(preferenceUpdateCollection)
val preferenceCategoryImportExport = PreferenceCategory(activity as Context)
preferenceCategoryImportExport.title = getString(R.string.pref_backup_import_export_title)
- preferenceCategoryImportExport.contains(preferenceM3uExport)
- preferenceCategoryImportExport.contains(preferencePlsExport)
- preferenceCategoryImportExport.contains(preferenceBackupCollection)
- preferenceCategoryImportExport.contains(preferenceRestoreCollection)
val preferenceCategoryAdvanced = PreferenceCategory(activity as Context)
preferenceCategoryAdvanced.title = getString(R.string.pref_advanced_title)
- preferenceCategoryAdvanced.contains(preferenceBufferSize)
- preferenceCategoryAdvanced.contains(preferenceEnableEditingGeneral)
- preferenceCategoryAdvanced.contains(preferenceEnableEditingStreamUri)
val preferenceCategoryLinks = PreferenceCategory(context)
preferenceCategoryLinks.title = getString(R.string.pref_links_title)
- preferenceCategoryLinks.contains(preferenceAppVersion)
- preferenceCategoryLinks.contains(preferenceGitHub)
// setup preference screen
- screen.addPreference(preferenceAppVersion)
- screen.addPreference(preferenceLicense)
screen.addPreference(preferenceCategoryGeneral)
- screen.addPreference(preferenceThemeSelection)
+ preferenceCategoryGeneral.addPreference(preferenceThemeSelection)
+
+ screen.addPreference(preferenceCategoryAudioEffects)
+ preferenceCategoryAudioEffects.addPreference(preferenceBassBoost)
+ preferenceCategoryAudioEffects.addPreference(preferenceReverb)
+ preferenceCategoryAudioEffects.addPreference(preferenceDrc)
+
screen.addPreference(preferenceCategoryMaintenance)
- screen.addPreference(preferenceUpdateStationImages)
- screen.addPreference(preferenceUpdateCollection)
+ preferenceCategoryMaintenance.addPreference(preferenceUpdateStationImages)
+ preferenceCategoryMaintenance.addPreference(preferenceUpdateCollection)
+
screen.addPreference(preferenceCategoryImportExport)
- screen.addPreference(preferenceM3uExport)
- screen.addPreference(preferencePlsExport)
- screen.addPreference(preferenceBackupCollection)
- screen.addPreference(preferenceRestoreCollection)
+ preferenceCategoryImportExport.addPreference(preferenceM3uExport)
+ preferenceCategoryImportExport.addPreference(preferencePlsExport)
+ preferenceCategoryImportExport.addPreference(preferenceBackupCollection)
+ preferenceCategoryImportExport.addPreference(preferenceRestoreCollection)
+
screen.addPreference(preferenceCategoryAdvanced)
- screen.addPreference(preferenceBufferSize)
- screen.addPreference(preferenceEnableEditingGeneral)
- screen.addPreference(preferenceEnableEditingStreamUri)
+ preferenceCategoryAdvanced.addPreference(preferenceBufferSize)
+ preferenceCategoryAdvanced.addPreference(preferenceEnableEditingGeneral)
+ preferenceCategoryAdvanced.addPreference(preferenceEnableEditingStreamUri)
+
screen.addPreference(preferenceCategoryLinks)
- screen.addPreference(preferenceGitHub)
+ preferenceCategoryLinks.addPreference(preferenceAppVersion)
+ preferenceCategoryLinks.addPreference(preferenceGitHub)
+ preferenceCategoryLinks.addPreference(preferenceLicense)
+
preferenceScreen = screen
}
@@ -491,28 +516,6 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
}
- /* Opens up a file picker to select the save location */
- private fun openSavePlsDialog() {
- val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = Keys.MIME_TYPE_PLS
-
- val timeStamp: String
- val dateFormat = SimpleDateFormat("_yyyy-MM-dd'T'HH_mm", Locale.US)
- timeStamp = dateFormat.format(Date())
-
- putExtra(Intent.EXTRA_TITLE, "collection$timeStamp.pls")
- }
- // file gets saved in the ActivityResult
- try {
- requestSavePlsLauncher.launch(intent)
- } catch (exception: Exception) {
- Log.e(TAG, "Unable to save PLS.\n$exception")
- Snackbar.make(requireView(), R.string.toastmessage_install_file_helper, Snackbar.LENGTH_LONG).show()
- }
- }
-
-
/* Opens up a file picker to select the backup location */
private fun openBackupCollectionDialog() {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
@@ -549,4 +552,24 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
Log.e(TAG, "Unable to open file picker for ZIP.\n$exception")
}
}
+
+ private fun openSavePlsDialog() {
+ val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = Keys.MIME_TYPE_PLS
+
+ val timeStamp: String
+ val dateFormat = SimpleDateFormat("_yyyy-MM-dd'T'HH_mm", Locale.US)
+ timeStamp = dateFormat.format(Date())
+
+ putExtra(Intent.EXTRA_TITLE, "collection$timeStamp.pls")
+ }
+ // file gets saved in the ActivityResult
+ try {
+ requestSavePlsLauncher.launch(intent)
+ } catch (exception: Exception) {
+ Log.e(TAG, "Unable to save PLS.\n$exception")
+ Snackbar.make(requireView(), R.string.toastmessage_install_file_helper, Snackbar.LENGTH_LONG).show()
+ }
+ }
}
diff --git a/app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt b/app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt
index 3a22317..a865ac3 100644
--- a/app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt
+++ b/app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt
@@ -640,12 +640,18 @@ object CollectionHelper {
val mediaMetadata = MediaMetadata.Builder().apply {
setArtist(station.name)
//setTitle(station.name)
+
+ // Set artwork URI for casting (TV needs a public URL)
+ val artworkUrl = station.remoteImageLocation.ifEmpty {
+ // Placeholder PNG image for stations without remote image
+ "https://raw.githubusercontent.com/google/material-design-icons/master/png/av/radio/materialicons/48dp/2x/baseline_radio_black_48dp.png"
+ }
+ setArtworkUri(artworkUrl.toUri())
+
/* check for "file://" prevents a crash when an old backup was restored */
if (station.image.isNotEmpty() && station.image.startsWith("file://")) {
- //setArtworkUri(station.image.toUri())
setArtworkData(station.image.toUri().toFile().readBytes(), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else {
- //setArtworkUri(Uri.parse(Keys.LOCATION_RESOURCES + R.raw.ic_default_station_image))
setArtworkData(ImageHelper.getStationImageAsByteArray(context), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
}
setIsBrowsable(false)
diff --git a/app/src/main/java/com/michatec/radio/helpers/MarqueeSwitchPreference.kt b/app/src/main/java/com/michatec/radio/helpers/MarqueeSwitchPreference.kt
new file mode 100644
index 0000000..5d28241
--- /dev/null
+++ b/app/src/main/java/com/michatec/radio/helpers/MarqueeSwitchPreference.kt
@@ -0,0 +1,25 @@
+package com.michatec.radio.helpers
+
+import android.content.Context
+import android.text.TextUtils
+import android.widget.TextView
+import androidx.preference.PreferenceViewHolder
+import androidx.preference.SwitchPreferenceCompat
+
+/*
+ * Custom SwitchPreferenceCompat that enables marquee (scrolling text) for the title
+ */
+class MarqueeSwitchPreference(context: Context) : SwitchPreferenceCompat(context) {
+
+ override fun onBindViewHolder(holder: PreferenceViewHolder) {
+ super.onBindViewHolder(holder)
+ val title = holder.findViewById(android.R.id.title) as? TextView
+ title?.apply {
+ ellipsize = TextUtils.TruncateAt.MARQUEE
+ setSingleLine(true)
+ marqueeRepeatLimit = -1 // Repeat indefinitely
+ isSelected = true // Required for marquee to start
+ setHorizontallyScrolling(true)
+ }
+ }
+}
diff --git a/app/src/main/java/com/michatec/radio/helpers/NativeAudioProcessor.kt b/app/src/main/java/com/michatec/radio/helpers/NativeAudioProcessor.kt
new file mode 100644
index 0000000..fbf45a4
--- /dev/null
+++ b/app/src/main/java/com/michatec/radio/helpers/NativeAudioProcessor.kt
@@ -0,0 +1,62 @@
+package com.michatec.radio.helpers
+
+import androidx.annotation.OptIn
+import androidx.media3.common.C
+import androidx.media3.common.audio.AudioProcessor
+import androidx.media3.common.audio.AudioProcessor.AudioFormat
+import androidx.media3.common.audio.BaseAudioProcessor
+import androidx.media3.common.util.UnstableApi
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+@OptIn(UnstableApi::class)
+class NativeAudioProcessor : BaseAudioProcessor() {
+
+ companion object {
+ init {
+ System.loadLibrary("radio")
+ }
+ }
+
+ // JNI Methods
+ private external fun setDrcEnabled(enabled: Boolean)
+ private external fun setReverbMix(mix: Float)
+ private external fun setEqBand(band: Int, gainDb: Float)
+ private external fun setBassBoost(gainDb: Float)
+ private external fun processAudio(data: ShortArray, size: Int)
+
+ // Public API
+ fun enableDrc(enabled: Boolean) = setDrcEnabled(enabled)
+ fun setReverb(mix: Float) = setReverbMix(mix)
+ fun setEq(band: Int, gainDb: Float) = setEqBand(band, gainDb)
+ fun enableBassBoost(gainDb: Float) = setBassBoost(gainDb)
+
+ override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat {
+ if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT) {
+ throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat)
+ }
+ return inputAudioFormat
+ }
+
+ override fun queueInput(inputBuffer: ByteBuffer) {
+ val remaining = inputBuffer.remaining()
+ if (remaining == 0) return
+
+ val shortArraySize = remaining / 2
+ val shortArray = ShortArray(shortArraySize)
+
+ // Input-Daten lesen
+ inputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer().get(shortArray)
+
+ // Native Verarbeitung
+ processAudio(shortArray, shortArraySize)
+
+ // Buffer der Basisklasse anfordern und befüllen
+ val outputBuffer = replaceOutputBuffer(remaining)
+ outputBuffer.asShortBuffer().put(shortArray)
+ outputBuffer.limit(remaining) // Markiert das Ende der geschriebenen Daten
+
+ // Input-Buffer als verarbeitet markieren
+ inputBuffer.position(inputBuffer.limit())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/michatec/radio/helpers/PreferencesHelper.kt b/app/src/main/java/com/michatec/radio/helpers/PreferencesHelper.kt
index 94b74aa..cd2eafc 100644
--- a/app/src/main/java/com/michatec/radio/helpers/PreferencesHelper.kt
+++ b/app/src/main/java/com/michatec/radio/helpers/PreferencesHelper.kt
@@ -254,4 +254,27 @@ object PreferencesHelper {
)
}
+
+ /* Loads Bass Boost gain */
+ fun loadBassBoost(): Float {
+ return if (sharedPreferences.getBoolean(Keys.PREF_BASS_BOOST, false)) 5.0f else 0.0f
+ }
+
+
+ /* Loads Reverb mix */
+ fun loadReverb(): Float {
+ return if (sharedPreferences.getBoolean(Keys.PREF_REVERB, false)) 0.3f else 0.0f
+ }
+
+
+ /* Loads DRC enabled state */
+ fun loadDrcEnabled(): Boolean {
+ return sharedPreferences.getBoolean(Keys.PREF_DRC, false)
+ }
+
+ /* Loads EQ gains */
+ fun loadEqLow(): Float = sharedPreferences.getInt(Keys.PREF_EQ_LOW, 0).toFloat()
+ fun loadEqMid(): Float = sharedPreferences.getInt(Keys.PREF_EQ_MID, 0).toFloat()
+ fun loadEqHigh(): Float = sharedPreferences.getInt(Keys.PREF_EQ_HIGH, 0).toFloat()
+
}
diff --git a/app/src/main/java/com/michatec/radio/ui/LayoutHolder.kt b/app/src/main/java/com/michatec/radio/ui/LayoutHolder.kt
index 43128ee..e142e53 100644
--- a/app/src/main/java/com/michatec/radio/ui/LayoutHolder.kt
+++ b/app/src/main/java/com/michatec/radio/ui/LayoutHolder.kt
@@ -19,9 +19,11 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.Group
import androidx.core.view.isGone
import androidx.core.view.isVisible
+import androidx.mediarouter.app.MediaRouteButton
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
import com.michatec.radio.Keys
@@ -60,6 +62,7 @@ data class LayoutHolder(var rootView: View) {
private var sheetNextMetadataView: ImageButton? = rootView.findViewById(R.id.sheet_next_metadata_button)
private var sheetPreviousMetadataView: ImageButton? = rootView.findViewById(R.id.sheet_previous_metadata_button)
private var sheetCopyMetadataButtonView: ImageButton? = rootView.findViewById(R.id.copy_station_metadata_button)
+ private var mediaRouteButton: MediaRouteButton? = rootView.findViewById(R.id.media_route_button)
private var sheetShareLinkButtonView: ImageView? = rootView.findViewById(R.id.sheet_share_link_button)
private var sheetBitrateView: TextView? = rootView.findViewById(R.id.sheet_bitrate_view)
var sheetSleepTimerStartButtonView: ImageButton? = rootView.findViewById(R.id.sleep_timer_start_button)
@@ -115,6 +118,11 @@ data class LayoutHolder(var rootView: View) {
return@setOnLongClickListener true
}
+ // Set up MediaRouteButton (Google Cast)
+ mediaRouteButton?.let {
+ CastButtonFactory.setUpMediaRouteButton(rootView.context, it)
+ }
+
// set layout for player
setupBottomSheet()
}
@@ -123,8 +131,6 @@ data class LayoutHolder(var rootView: View) {
/* Updates the player views */
@SuppressLint("DefaultLocale")
fun updatePlayerViews(context: Context, station: Station, isPlaying: Boolean) {
-
- // set default metadata views, when playback has stopped
if (!isPlaying) {
metadataView?.text = station.name
sheetMetadataHistoryView?.text = station.name
diff --git a/app/src/main/res/layout/bottom_sheet_playback_controls.xml b/app/src/main/res/layout/bottom_sheet_playback_controls.xml
index 4803f87..7691fdc 100644
--- a/app/src/main/res/layout/bottom_sheet_playback_controls.xml
+++ b/app/src/main/res/layout/bottom_sheet_playback_controls.xml
@@ -211,6 +211,18 @@
app:layout_constraintTop_toBottomOf="@+id/sheet_previous_metadata_button"
app:srcCompat="@drawable/ic_copy_content_24dp" />
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 2ebd2a4..f42a6e6 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -55,6 +55,7 @@
Die neueste Version aller Senderinformationen herunterladen.
Die neueste Version aller Senderinformationen herunterladen?
Aktualisieren
+ Equalizer: Hoch
Erweitert
Version
App-Version
@@ -117,4 +118,13 @@
Zeigen
ist verfügbar!
+ Audio Effekte
+ Bass Boost
+ Erhöhen Sie die Bassverstärkung.
+ Hall
+ Reverb-Mix anpassen.
+ Komprimierung des Dynamikbereichs
+ Den Dynamikbereich für ein gleichbleibendes Volumen komprimieren.
+ Equalizer: Leicht
+ Equalizer: Mitte
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ad059a6..9ca2fb7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -63,6 +63,16 @@
Download latest version of all station.
Download latest version of all station?
Update
+ Audio Effects
+ Bass Boost
+ Increase bass gain.
+ Reverb
+ Adjust reverb mix.
+ Dynamic Range Compression
+ Compress dynamic range for consistent volume.
+ Equalizer: Low
+ Equalizer: Mid
+ Equalizer: High
Advanced
Version
App Version
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9264eda..b177860 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -15,6 +15,7 @@ paletteKtx = "1.0.0"
preferenceKtx = "1.2.1"
volley = "1.2.1"
workRuntimeKtx = "2.11.2"
+playServicesCastFramework = "22.3.0"
[libraries]
activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
@@ -29,12 +30,14 @@ media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasourc
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
+media3-cast = { group = "androidx.media3", name = "media3-cast", version.ref = "media3" }
navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
volley = { group = "com.android.volley", name = "volley", version.ref = "volley" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" }
+play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCastFramework" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }