feat(audio): add native audio processing and Google Cast support

This commit is contained in:
2026-04-05 14:01:47 +02:00
parent d270574365
commit d40ae6b746
20 changed files with 586 additions and 98 deletions
+2 -1
View File
@@ -6,4 +6,5 @@
/build /build
/captures /captures
/gradle/gradle-daemon-jvm.properties /gradle/gradle-daemon-jvm.properties
/.kotlin /.kotlin
/app/.cxx/
+13
View File
@@ -22,6 +22,11 @@ android {
versionCode 144 versionCode 144
versionName '14.4' versionName '14.4'
resourceConfigurations += ['en', 'de', 'el', 'nl', 'pl', 'ru','uk', 'ja', 'da', 'fr'] resourceConfigurations += ['en', 'de', 'el', 'nl', 'pl', 'ru','uk', 'ja', 'da', 'fr']
externalNativeBuild {
cmake {
cppFlags ''
}
}
} }
compileOptions { compileOptions {
@@ -49,6 +54,12 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
} }
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
version '3.22.1'
}
}
} }
dependencies { dependencies {
@@ -57,6 +68,7 @@ dependencies {
// Google Stuff // // Google Stuff //
implementation libs.material implementation libs.material
implementation libs.gson implementation libs.gson
implementation 'com.google.android.gms:play-services-cast-framework:21.5.0'
// AndroidX Stuff // // AndroidX Stuff //
implementation libs.core.ktx implementation libs.core.ktx
@@ -67,6 +79,7 @@ dependencies {
implementation libs.media3.exoplayer implementation libs.media3.exoplayer
implementation libs.media3.exoplayer.hls implementation libs.media3.exoplayer.hls
implementation libs.media3.session implementation libs.media3.session
implementation libs.media3.cast
implementation libs.media3.datasource.okhttp implementation libs.media3.datasource.okhttp
implementation libs.navigation.fragment.ktx implementation libs.navigation.fragment.ktx
implementation libs.navigation.ui.ktx implementation libs.navigation.ui.ktx
+7 -28
View File
@@ -31,8 +31,12 @@
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="33"> tools:targetApi="33">
<!-- GOOGLE CAST SUPPORT -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.michatec.radio.CastOptionsProvider" />
<!-- ANDROID AUTO SUPPORT --> <!-- ANDROID AUTO SUPPORT -->
<!-- https://developer.android.com/training/auto/audio/ -->
<meta-data <meta-data
android:name="com.google.android.gms.car.application" android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" /> android:resource="@xml/automotive_app_desc" />
@@ -48,54 +52,41 @@
android:theme="@style/SplashTheme" android:theme="@style/SplashTheme"
android:exported="true"> android:exported="true">
<!-- react to main intents -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<!-- react to be recognized as a music player -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MUSIC_PLAYER" /> <action android:name="android.intent.action.MUSIC_PLAYER" />
<category android:name="android.intent.category.CATEGORY_APP_MUSIC" /> <category android:name="android.intent.category.CATEGORY_APP_MUSIC" />
</intent-filter> </intent-filter>
<!-- react to voice searches, like "Play Security Now" -->
<intent-filter> <intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" /> <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
<!-- react to playlist-links based on file extension -->
<!-- This is intended as an App Link for specific extensions -->
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" /> <data android:scheme="http" />
<data android:scheme="https" /> <data android:scheme="https" />
<data android:host="*" <data android:host="*" tools:ignore="AppLinkUrlError" />
tools:ignore="AppLinkUrlError" />
<data android:pathPattern=".*\\.m3u" /> <data android:pathPattern=".*\\.m3u" />
<data android:pathPattern=".*\\.m3u8" /> <data android:pathPattern=".*\\.m3u8" />
<data android:pathPattern=".*\\.pls" /> <data android:pathPattern=".*\\.pls" />
</intent-filter> </intent-filter>
<!-- react to playlist-links based on mimetype -->
<!-- Note: MIME types prevent strict App Link verification, but are kept as requested -->
<intent-filter android:autoVerify="false"> <intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" /> <data android:scheme="http" />
<data android:scheme="https" /> <data android:scheme="https" />
<data android:host="*" <data android:host="*" tools:ignore="AppLinkUrlError" />
tools:ignore="AppLinkUrlError" />
<data android:mimeType="audio/x-scpls" /> <data android:mimeType="audio/x-scpls" />
<data android:mimeType="audio/mpegurl" /> <data android:mimeType="audio/mpegurl" />
<data android:mimeType="audio/x-mpegurl" /> <data android:mimeType="audio/x-mpegurl" />
@@ -106,11 +97,9 @@
<data android:mimeType="application/octet-stream" /> <data android:mimeType="application/octet-stream" />
</intent-filter> </intent-filter>
<!-- react to hls playlist-links based on mimetype -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" /> <data android:scheme="content" />
<data android:host="*" /> <data android:host="*" />
<data android:mimeType="audio/x-scpls" /> <data android:mimeType="audio/x-scpls" />
@@ -122,20 +111,16 @@
<data android:mimeType="application/vnd.apple.mpegurl.audio" /> <data android:mimeType="application/vnd.apple.mpegurl.audio" />
</intent-filter> </intent-filter>
<!-- react to "start player service" intents -->
<intent-filter> <intent-filter>
<action android:name="com.michatec.radio.action.START_PLAYER_SERVICE" /> <action android:name="com.michatec.radio.action.START_PLAYER_SERVICE" />
</intent-filter> </intent-filter>
<!-- App Shortcuts -->
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity>
<!-- Player Service -->
<service <service
android:name=".PlayerService" android:name=".PlayerService"
android:enabled="true" android:enabled="true"
@@ -149,8 +134,6 @@
</intent-filter> </intent-filter>
</service> </service>
<!-- handles completed downloads -->
<receiver <receiver
android:name=".helpers.DownloadFinishedReceiver" android:name=".helpers.DownloadFinishedReceiver"
android:exported="true"> android:exported="true">
@@ -159,8 +142,6 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- handles media buttons -->
<receiver <receiver
android:name="androidx.media.session.MediaButtonReceiver" android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true"> android:exported="true">
@@ -169,8 +150,6 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- file provider -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"
+38
View File
@@ -0,0 +1,38 @@
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html.
# For more examples on how to use CMake, see https://github.com/android/ndk-samples.
# Sets the minimum CMake version required for this project.
cmake_minimum_required(VERSION 3.22.1)
# Declares the project name. The project name can be accessed via ${ PROJECT_NAME},
# Since this is the top level CMakeLists.txt, the project name is also accessible
# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level
# build script scope).
project("radio")
# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.
#
# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
# is preferred for the same purpose.
#
# In order to load a library into your app from Java/Kotlin, you must call
# System.loadLibrary() and pass the name of the library defined here;
# for GameActivity/NativeActivity derived applications, the same library name must be
# used in the AndroidManifest.xml file.
add_library(${CMAKE_PROJECT_NAME} SHARED
# List C/C++ source files with relative paths to this CMakeLists.txt.
radio.cpp)
# Specifies libraries CMake should link to your target library. You
# can link libraries from various origins, such as libraries defined in this
# build script, prebuilt third-party libraries, or Android system libraries.
target_link_libraries(${CMAKE_PROJECT_NAME}
# List libraries link to the target library
android
log)
+192
View File
@@ -0,0 +1,192 @@
#include <jni.h>
#include <string>
#include <vector>
#include <cmath>
#include <algorithm>
#include <android/log.h>
// --- 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<float>(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<float>(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<float> 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<size_t>(pos)];
delayLine[static_cast<size_t>(pos)] = in + delayed * feedback;
pos = (pos + 1) % static_cast<int>(delayLine.size());
return in + delayed * mix;
}
};
// --- Global Engine State ---
Compressor gCompressor;
Reverb gReverb;
std::vector<Biquad> 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<size_t>(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<float> floatBuf(static_cast<size_t>(size));
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = static_cast<float>(buffer[i]) / 32768.0f;
// Apply EQ
if (gEqEnabled) {
for (auto &band : gEqBands) {
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = band.process(floatBuf[static_cast<size_t>(i)]);
}
}
// Apply Bass Boost
if (gBassBoostEnabled) {
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = gBassBoost.process(floatBuf[static_cast<size_t>(i)]);
}
// Apply Reverb
if (gReverbEnabled) {
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = gReverb.process(floatBuf[static_cast<size_t>(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<size_t>(i)]));
buffer[i] = static_cast<jshort>(out * 32767.0f);
}
env->ReleaseShortArrayElements(data, buffer, 0);
}
} // extern "C"
@@ -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<SessionProvider>? {
return null
}
}
+6 -1
View File
@@ -60,6 +60,12 @@ object Keys {
const val PREF_LARGE_BUFFER_SIZE: String = "LARGE_BUFFER_SIZE" const val PREF_LARGE_BUFFER_SIZE: String = "LARGE_BUFFER_SIZE"
const val PREF_EDIT_STATIONS: String = "EDIT_STATIONS" const val PREF_EDIT_STATIONS: String = "EDIT_STATIONS"
const val PREF_EDIT_STREAMS_URIS: String = "EDIT_STREAMS_URIS" 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 // default const values
const val DEFAULT_SIZE_OF_METADATA_HISTORY: Int = 25 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_REMOVE_STATION: Int = 2
const val DIALOG_UPDATE_STATION_IMAGES: Int = 4 const val DIALOG_UPDATE_STATION_IMAGES: Int = 4
const val DIALOG_RESTORE_COLLECTION: Int = 5 const val DIALOG_RESTORE_COLLECTION: Int = 5
const val DIALOG_THEME_SELECTION: Int = 6
// dialog results // dialog results
const val DIALOG_EMPTY_PAYLOAD_STRING: String = "" const val DIALOG_EMPTY_PAYLOAD_STRING: String = ""
@@ -27,13 +27,12 @@ class MainActivity : AppCompatActivity() {
/* Main class variables */ /* Main class variables */
private lateinit var appBarConfiguration: AppBarConfiguration private lateinit var appBarConfiguration: AppBarConfiguration
/* Overrides onCreate from AppCompatActivity */ /* Overrides onCreate from AppCompatActivity */
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
setTheme(R.style.AppTheme) setTheme(R.style.AppTheme)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Free Android // Free Android
FreeDroidWarn.showWarningOnUpgrade(this, BuildConfig.VERSION_CODE) FreeDroidWarn.showWarningOnUpgrade(this, BuildConfig.VERSION_CODE)
@@ -46,7 +45,8 @@ class MainActivity : AppCompatActivity() {
// set up action bar // set up action bar
setSupportActionBar(findViewById(R.id.main_toolbar)) setSupportActionBar(findViewById(R.id.main_toolbar))
val toolbar: Toolbar = 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 val navController = navHostFragment.navController
appBarConfiguration = AppBarConfiguration(navController.graph) appBarConfiguration = AppBarConfiguration(navController.graph)
NavigationUI.setupWithNavController(toolbar, navController, appBarConfiguration) NavigationUI.setupWithNavController(toolbar, navController, appBarConfiguration)
@@ -69,9 +69,7 @@ class MainActivity : AppCompatActivity() {
private fun hideLoadingOverlay() { private fun hideLoadingOverlay() {
findViewById<View>(R.id.loading_layout)?.let { overlay -> findViewById<View>(R.id.loading_layout)?.let { overlay ->
if (overlay.isVisible) { if (overlay.isVisible) {
overlay.animate() overlay.animate().alpha(0f).setDuration(500)
.alpha(0f)
.setDuration(500)
.withEndAction { overlay.visibility = View.GONE } .withEndAction { overlay.visibility = View.GONE }
} }
} }
@@ -90,7 +88,8 @@ class MainActivity : AppCompatActivity() {
/* Overrides onSupportNavigateUp from AppCompatActivity */ /* Overrides onSupportNavigateUp from AppCompatActivity */
override fun onSupportNavigateUp(): Boolean { 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 val navController = navHostFragment.navController
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp() return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
} }
@@ -809,7 +809,7 @@ class PlayerFragment : Fragment(),
/* /*
* Check for update on github * Check for update on GitHub
*/ */
private fun checkForUpdates() { private fun checkForUpdates() {
val url = getString(R.string.snackbar_github_update_check_url) val url = getString(R.string.snackbar_github_update_check_url)
@@ -14,8 +14,11 @@ import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.HttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener 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.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.upstream.DefaultAllocator import androidx.media3.exoplayer.upstream.DefaultAllocator
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy 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.Futures
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.michatec.radio.core.Collection import com.michatec.radio.core.Collection
import com.michatec.radio.helpers.AudioHelper import com.michatec.radio.helpers.*
import com.michatec.radio.helpers.CollectionHelper
import com.michatec.radio.helpers.FileHelper
import com.michatec.radio.helpers.PreferencesHelper
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import java.util.* import java.util.*
@@ -38,7 +38,7 @@ import java.util.*
* PlayerService class * PlayerService class
*/ */
@UnstableApi @UnstableApi
class PlayerService : MediaLibraryService() { class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenceChangeListener {
/* Define log tag */ /* Define log tag */
private val TAG: String = PlayerService::class.java.simpleName private val TAG: String = PlayerService::class.java.simpleName
@@ -56,6 +56,9 @@ class PlayerService : MediaLibraryService() {
private var playbackRestartCounter: Int = 0 private var playbackRestartCounter: Int = 0
private var playLastStation: Boolean = false private var playLastStation: Boolean = false
private var manuallyCancelledSleepTimer = false private var manuallyCancelledSleepTimer = false
// Native Audio Processor instance
private val nativeAudioProcessor = NativeAudioProcessor()
/* Overrides onCreate from Service */ /* Overrides onCreate from Service */
@@ -76,6 +79,11 @@ class PlayerService : MediaLibraryService() {
setMediaNotificationProvider(notificationProvider) setMediaNotificationProvider(notificationProvider)
// fetch the metadata history // fetch the metadata history
metadataHistory = PreferencesHelper.loadMetadataHistory() 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.removeListener(playerListener)
player.release() player.release()
mediaLibrarySession.release() mediaLibrarySession.release()
// unregister preference change listener
PreferencesHelper.unregisterPreferenceChangeListener(this)
super.onDestroy() super.onDestroy()
} }
@@ -106,8 +116,26 @@ class PlayerService : MediaLibraryService() {
/* Initializes the ExoPlayer */ /* Initializes the ExoPlayer */
private fun initializePlayer() { private fun initializePlayer() {
val exoPlayer: ExoPlayer = ExoPlayer.Builder(this).apply { val audioAttributes = AudioAttributes.Builder()
setAudioAttributes(AudioAttributes.DEFAULT, true) .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) setHandleAudioBecomingNoisy(true)
setLoadControl(createDefaultLoadControl(bufferSizeMultiplier)) setLoadControl(createDefaultLoadControl(bufferSizeMultiplier))
setMediaSourceFactory( 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 * Custom MediaSession Callback that handles player commands
*/ */
@@ -268,7 +318,6 @@ class PlayerService : MediaLibraryService() {
session: MediaSession, session: MediaSession,
controller: MediaSession.ControllerInfo controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult { ): MediaSession.ConnectionResult {
// add custom commands
val connectionResult: MediaSession.ConnectionResult = super.onConnect(session, controller) val connectionResult: MediaSession.ConnectionResult = super.onConnect(session, controller)
val builder: SessionCommands.Builder = connectionResult.availableSessionCommands.buildUpon() val builder: SessionCommands.Builder = connectionResult.availableSessionCommands.buildUpon()
builder.add(SessionCommand(Keys.CMD_START_SLEEP_TIMER, Bundle.EMPTY)) builder.add(SessionCommand(Keys.CMD_START_SLEEP_TIMER, Bundle.EMPTY))
@@ -186,6 +186,30 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
return@setOnPreferenceChangeListener true 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 // set up "App Version" preference
val preferenceAppVersion = Preference(context) val preferenceAppVersion = Preference(context)
preferenceAppVersion.title = getString(R.string.pref_app_version_title) preferenceAppVersion.title = getString(R.string.pref_app_version_title)
@@ -238,51 +262,52 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
// set preference categories // set preference categories
val preferenceCategoryGeneral = PreferenceCategory(activity as Context) val preferenceCategoryGeneral = PreferenceCategory(activity as Context)
preferenceCategoryGeneral.title = getString(R.string.pref_general_title) 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) val preferenceCategoryMaintenance = PreferenceCategory(activity as Context)
preferenceCategoryMaintenance.title = getString(R.string.pref_maintenance_title) preferenceCategoryMaintenance.title = getString(R.string.pref_maintenance_title)
preferenceCategoryMaintenance.contains(preferenceUpdateStationImages)
preferenceCategoryMaintenance.contains(preferenceUpdateCollection)
val preferenceCategoryImportExport = PreferenceCategory(activity as Context) val preferenceCategoryImportExport = PreferenceCategory(activity as Context)
preferenceCategoryImportExport.title = getString(R.string.pref_backup_import_export_title) 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) val preferenceCategoryAdvanced = PreferenceCategory(activity as Context)
preferenceCategoryAdvanced.title = getString(R.string.pref_advanced_title) preferenceCategoryAdvanced.title = getString(R.string.pref_advanced_title)
preferenceCategoryAdvanced.contains(preferenceBufferSize)
preferenceCategoryAdvanced.contains(preferenceEnableEditingGeneral)
preferenceCategoryAdvanced.contains(preferenceEnableEditingStreamUri)
val preferenceCategoryLinks = PreferenceCategory(context) val preferenceCategoryLinks = PreferenceCategory(context)
preferenceCategoryLinks.title = getString(R.string.pref_links_title) preferenceCategoryLinks.title = getString(R.string.pref_links_title)
preferenceCategoryLinks.contains(preferenceAppVersion)
preferenceCategoryLinks.contains(preferenceGitHub)
// setup preference screen // setup preference screen
screen.addPreference(preferenceAppVersion)
screen.addPreference(preferenceLicense)
screen.addPreference(preferenceCategoryGeneral) 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(preferenceCategoryMaintenance)
screen.addPreference(preferenceUpdateStationImages) preferenceCategoryMaintenance.addPreference(preferenceUpdateStationImages)
screen.addPreference(preferenceUpdateCollection) preferenceCategoryMaintenance.addPreference(preferenceUpdateCollection)
screen.addPreference(preferenceCategoryImportExport) screen.addPreference(preferenceCategoryImportExport)
screen.addPreference(preferenceM3uExport) preferenceCategoryImportExport.addPreference(preferenceM3uExport)
screen.addPreference(preferencePlsExport) preferenceCategoryImportExport.addPreference(preferencePlsExport)
screen.addPreference(preferenceBackupCollection) preferenceCategoryImportExport.addPreference(preferenceBackupCollection)
screen.addPreference(preferenceRestoreCollection) preferenceCategoryImportExport.addPreference(preferenceRestoreCollection)
screen.addPreference(preferenceCategoryAdvanced) screen.addPreference(preferenceCategoryAdvanced)
screen.addPreference(preferenceBufferSize) preferenceCategoryAdvanced.addPreference(preferenceBufferSize)
screen.addPreference(preferenceEnableEditingGeneral) preferenceCategoryAdvanced.addPreference(preferenceEnableEditingGeneral)
screen.addPreference(preferenceEnableEditingStreamUri) preferenceCategoryAdvanced.addPreference(preferenceEnableEditingStreamUri)
screen.addPreference(preferenceCategoryLinks) screen.addPreference(preferenceCategoryLinks)
screen.addPreference(preferenceGitHub) preferenceCategoryLinks.addPreference(preferenceAppVersion)
preferenceCategoryLinks.addPreference(preferenceGitHub)
preferenceCategoryLinks.addPreference(preferenceLicense)
preferenceScreen = screen 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 */ /* Opens up a file picker to select the backup location */
private fun openBackupCollectionDialog() { private fun openBackupCollectionDialog() {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { 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") 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()
}
}
} }
@@ -640,12 +640,18 @@ object CollectionHelper {
val mediaMetadata = MediaMetadata.Builder().apply { val mediaMetadata = MediaMetadata.Builder().apply {
setArtist(station.name) setArtist(station.name)
//setTitle(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 */ /* check for "file://" prevents a crash when an old backup was restored */
if (station.image.isNotEmpty() && station.image.startsWith("file://")) { if (station.image.isNotEmpty() && station.image.startsWith("file://")) {
//setArtworkUri(station.image.toUri())
setArtworkData(station.image.toUri().toFile().readBytes(), MediaMetadata.PICTURE_TYPE_FRONT_COVER) setArtworkData(station.image.toUri().toFile().readBytes(), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else { } else {
//setArtworkUri(Uri.parse(Keys.LOCATION_RESOURCES + R.raw.ic_default_station_image))
setArtworkData(ImageHelper.getStationImageAsByteArray(context), MediaMetadata.PICTURE_TYPE_FRONT_COVER) setArtworkData(ImageHelper.getStationImageAsByteArray(context), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} }
setIsBrowsable(false) setIsBrowsable(false)
@@ -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)
}
}
}
@@ -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())
}
}
@@ -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()
} }
@@ -19,9 +19,11 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.Group import androidx.constraintlayout.widget.Group
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.mediarouter.app.MediaRouteButton
import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.michatec.radio.Keys 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 sheetNextMetadataView: ImageButton? = rootView.findViewById(R.id.sheet_next_metadata_button)
private var sheetPreviousMetadataView: ImageButton? = rootView.findViewById(R.id.sheet_previous_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 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 sheetShareLinkButtonView: ImageView? = rootView.findViewById(R.id.sheet_share_link_button)
private var sheetBitrateView: TextView? = rootView.findViewById(R.id.sheet_bitrate_view) private var sheetBitrateView: TextView? = rootView.findViewById(R.id.sheet_bitrate_view)
var sheetSleepTimerStartButtonView: ImageButton? = rootView.findViewById(R.id.sleep_timer_start_button) var sheetSleepTimerStartButtonView: ImageButton? = rootView.findViewById(R.id.sleep_timer_start_button)
@@ -115,6 +118,11 @@ data class LayoutHolder(var rootView: View) {
return@setOnLongClickListener true return@setOnLongClickListener true
} }
// Set up MediaRouteButton (Google Cast)
mediaRouteButton?.let {
CastButtonFactory.setUpMediaRouteButton(rootView.context, it)
}
// set layout for player // set layout for player
setupBottomSheet() setupBottomSheet()
} }
@@ -123,8 +131,6 @@ data class LayoutHolder(var rootView: View) {
/* Updates the player views */ /* Updates the player views */
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
fun updatePlayerViews(context: Context, station: Station, isPlaying: Boolean) { fun updatePlayerViews(context: Context, station: Station, isPlaying: Boolean) {
// set default metadata views, when playback has stopped
if (!isPlaying) { if (!isPlaying) {
metadataView?.text = station.name metadataView?.text = station.name
sheetMetadataHistoryView?.text = station.name sheetMetadataHistoryView?.text = station.name
@@ -211,6 +211,18 @@
app:layout_constraintTop_toBottomOf="@+id/sheet_previous_metadata_button" app:layout_constraintTop_toBottomOf="@+id/sheet_previous_metadata_button"
app:srcCompat="@drawable/ic_copy_content_24dp" /> app:srcCompat="@drawable/ic_copy_content_24dp" />
<androidx.mediarouter.app.MediaRouteButton
android:id="@+id/media_route_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="@+id/copy_station_metadata_button"
app:layout_constraintStart_toEndOf="@+id/copy_station_metadata_button"
app:layout_constraintTop_toTopOf="@+id/copy_station_metadata_button"
app:mediaRouteButtonTint="@color/player_sheet_text_main" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/sheet_bitrate_view" android:id="@+id/sheet_bitrate_view"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -290,4 +302,4 @@
android:visibility="gone" android:visibility="gone"
app:constraint_referenced_ids="sleep_timer_remaining_time,sleep_timer_cancel_button" /> app:constraint_referenced_ids="sleep_timer_remaining_time,sleep_timer_cancel_button" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
+10
View File
@@ -55,6 +55,7 @@
<string name="pref_update_collection_summary">Die neueste Version aller Senderinformationen herunterladen.</string> <string name="pref_update_collection_summary">Die neueste Version aller Senderinformationen herunterladen.</string>
<string name="dialog_yes_no_message_update_collection">Die neueste Version aller Senderinformationen herunterladen?</string> <string name="dialog_yes_no_message_update_collection">Die neueste Version aller Senderinformationen herunterladen?</string>
<string name="dialog_yes_no_positive_button_update_collection">Aktualisieren</string> <string name="dialog_yes_no_positive_button_update_collection">Aktualisieren</string>
<string name="pref_eq_high_title">Equalizer: Hoch</string>
<string name="pref_advanced_title">Erweitert</string> <string name="pref_advanced_title">Erweitert</string>
<string name="pref_app_version_summary">Version</string> <string name="pref_app_version_summary">Version</string>
<string name="pref_app_version_title">App-Version</string> <string name="pref_app_version_title">App-Version</string>
@@ -117,4 +118,13 @@
<!-- Snackbars --> <!-- Snackbars -->
<string name="snackbar_show">Zeigen</string> <string name="snackbar_show">Zeigen</string>
<string name="snackbar_update_available">ist verfügbar!</string> <string name="snackbar_update_available">ist verfügbar!</string>
<string name="pref_audio_effects_title">Audio Effekte</string>
<string name="pref_bass_boost_title">Bass Boost</string>
<string name="pref_bass_boost_summary">Erhöhen Sie die Bassverstärkung.</string>
<string name="pref_reverb_title">Hall</string>
<string name="pref_reverb_summary">Reverb-Mix anpassen.</string>
<string name="pref_drc_title">Komprimierung des Dynamikbereichs</string>
<string name="pref_drc_summary">Den Dynamikbereich für ein gleichbleibendes Volumen komprimieren.</string>
<string name="pref_eq_low_title">Equalizer: Leicht</string>
<string name="pref_eq_mid_title">Equalizer: Mitte</string>
</resources> </resources>
+10
View File
@@ -63,6 +63,16 @@
<string name="pref_update_collection_summary">Download latest version of all station.</string> <string name="pref_update_collection_summary">Download latest version of all station.</string>
<string name="dialog_yes_no_message_update_collection">Download latest version of all station?</string> <string name="dialog_yes_no_message_update_collection">Download latest version of all station?</string>
<string name="dialog_yes_no_positive_button_update_collection">Update</string> <string name="dialog_yes_no_positive_button_update_collection">Update</string>
<string name="pref_audio_effects_title">Audio Effects</string>
<string name="pref_bass_boost_title">Bass Boost</string>
<string name="pref_bass_boost_summary">Increase bass gain.</string>
<string name="pref_reverb_title">Reverb</string>
<string name="pref_reverb_summary">Adjust reverb mix.</string>
<string name="pref_drc_title">Dynamic Range Compression</string>
<string name="pref_drc_summary">Compress dynamic range for consistent volume.</string>
<string name="pref_eq_low_title">Equalizer: Low</string>
<string name="pref_eq_mid_title">Equalizer: Mid</string>
<string name="pref_eq_high_title">Equalizer: High</string>
<string name="pref_advanced_title">Advanced</string> <string name="pref_advanced_title">Advanced</string>
<string name="pref_app_version_summary">Version</string> <string name="pref_app_version_summary">Version</string>
<string name="pref_app_version_title">App Version</string> <string name="pref_app_version_title">App Version</string>
+3
View File
@@ -15,6 +15,7 @@ paletteKtx = "1.0.0"
preferenceKtx = "1.2.1" preferenceKtx = "1.2.1"
volley = "1.2.1" volley = "1.2.1"
workRuntimeKtx = "2.11.2" workRuntimeKtx = "2.11.2"
playServicesCastFramework = "22.3.0"
[libraries] [libraries]
activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } 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 = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", 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-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-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" } navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
volley = { group = "com.android.volley", name = "volley", version.ref = "volley" } volley = { group = "com.android.volley", name = "volley", version.ref = "volley" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }