From d40ae6b746c142cafc0802261d5c61f7feb006f2 Mon Sep 17 00:00:00 2001 From: Michatec Date: Sun, 5 Apr 2026 14:01:47 +0200 Subject: [PATCH] feat(audio): add native audio processing and Google Cast support --- .gitignore | 3 +- app/build.gradle | 13 ++ app/src/main/AndroidManifest.xml | 35 +--- app/src/main/cpp/CMakeLists.txt | 38 ++++ app/src/main/cpp/radio.cpp | 192 ++++++++++++++++++ .../com/michatec/radio/CastOptionsProvider.kt | 32 +++ app/src/main/java/com/michatec/radio/Keys.kt | 7 +- .../java/com/michatec/radio/MainActivity.kt | 13 +- .../java/com/michatec/radio/PlayerFragment.kt | 2 +- .../java/com/michatec/radio/PlayerService.kt | 65 +++++- .../com/michatec/radio/SettingsFragment.kt | 117 ++++++----- .../radio/helpers/CollectionHelper.kt | 10 +- .../radio/helpers/MarqueeSwitchPreference.kt | 25 +++ .../radio/helpers/NativeAudioProcessor.kt | 62 ++++++ .../radio/helpers/PreferencesHelper.kt | 23 +++ .../com/michatec/radio/ui/LayoutHolder.kt | 10 +- .../layout/bottom_sheet_playback_controls.xml | 14 +- app/src/main/res/values-de/strings.xml | 10 + app/src/main/res/values/strings.xml | 10 + gradle/libs.versions.toml | 3 + 20 files changed, 586 insertions(+), 98 deletions(-) create mode 100644 app/src/main/cpp/CMakeLists.txt create mode 100644 app/src/main/cpp/radio.cpp create mode 100644 app/src/main/java/com/michatec/radio/CastOptionsProvider.kt create mode 100644 app/src/main/java/com/michatec/radio/helpers/MarqueeSwitchPreference.kt create mode 100644 app/src/main/java/com/michatec/radio/helpers/NativeAudioProcessor.kt 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" }