16 Commits

Author SHA1 Message Date
Michatec 9d47684f13 feat(ui): add television layouts and improve visualizer performance 2026-04-06 17:17:29 +02:00
Michatec 82993d7c97 feat(ui): add spectrum analyzer visualizer 2026-04-06 16:58:53 +02:00
Michatec 487195b716 build: rename target library to dsp in CMakeLists.txt 2026-04-06 15:20:20 +02:00
Michatec 12445a3918 build: rename native audio library from radio to dsp 2026-04-06 15:16:41 +02:00
Michatec bc38742eae perf(audio): adjust audio processing presets and limiters 2026-04-06 15:14:01 +02:00
Michatec 99499ad174 perf(audio): optimize signal processing with NEON and block-based gains 2026-04-06 14:29:08 +02:00
Michatec 0d35770375 feat(audio): add 10-band equalizer and audio presets 2026-04-06 13:27:53 +02:00
Michatec 0d0980a1ef refactor(radio): change Reverb indices to size_t 2026-04-05 19:14:57 +02:00
Michachatz ae215691ca Update radio.cpp 2026-04-05 19:09:43 +02:00
Michachatz 52f1a57de3 Update radio.cpp 2026-04-05 19:07:27 +02:00
Michatec 53abe918ca fix(player): improve cast player integration and service lifecycle management 2026-04-05 18:19:00 +02:00
Michatec bd3ad427fa ui(settings): move app version to top of preference screen 2026-04-05 17:24:39 +02:00
Michatec 7b2cfb4b17 feat(cast): implement Google Cast support and expanded controller activity 2026-04-05 17:15:48 +02:00
Michatec 0796bc8ef4 build(deps): bump version to 14.5 and update cast framework dependency 2026-04-05 14:47:51 +02:00
Michatec 1564fa3dc4 feat(audio): add native audio processing and Google Cast support 2026-04-05 14:38:05 +02:00
Michatec d40ae6b746 feat(audio): add native audio processing and Google Cast support 2026-04-05 14:01:47 +02:00
36 changed files with 1771 additions and 139 deletions
+1
View File
@@ -7,3 +7,4 @@
/captures /captures
/gradle/gradle-daemon-jvm.properties /gradle/gradle-daemon-jvm.properties
/.kotlin /.kotlin
/app/.cxx/
+15 -2
View File
@@ -19,9 +19,14 @@ android {
applicationId 'com.michatec.radio' applicationId 'com.michatec.radio'
minSdk 28 minSdk 28
targetSdk 36 targetSdk 36
versionCode 144 versionCode 145
versionName '14.4' versionName '14.5'
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 libs.play.services.cast.framework
// 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
+13 -28
View File
@@ -32,7 +32,6 @@
tools:targetApi="33"> tools:targetApi="33">
<!-- 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" />
@@ -40,6 +39,17 @@
android:name="com.google.android.gms.car.notification.SmallIcon" android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@mipmap/ic_launcher" /> android:resource="@mipmap/ic_launcher" />
<!-- GOOGLE CAST SUPPORT -->
<receiver android:name="androidx.mediarouter.media.MediaTransferReceiver" />
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.michatec.radio.CastOptionsProvider" />
<activity
android:name=".ExpandedControllerActivity"
android:exported="false"
android:launchMode="singleTask" />
<!-- Main activity for radio station playback on phone and TV --> <!-- Main activity for radio station playback on phone and TV -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -48,54 +58,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 +103,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 +117,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 +140,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 +148,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 +156,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"
+42
View File
@@ -0,0 +1,42 @@
# 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, the 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(dsp SHARED
dsp.cpp)
add_library(extra SHARED
extra.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(dsp
android
log)
target_link_libraries(extra
android
log)
+287
View File
@@ -0,0 +1,287 @@
#include <jni.h>
#include <vector>
#include <cmath>
#include <complex>
#include <array>
#if defined(__ARM_NEON)
#include <arm_neon.h>
#endif
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
static constexpr int FFT_SIZE = 512;
static constexpr int NUM_EQ_BANDS = 10;
static constexpr float INV_32768 = 1.0f / 32768.0f;
static constexpr float SQRT_2_INV = 0.70710678f;
static constexpr float DENORMAL_OFFSET = 1e-18f;
static constexpr std::array<float, NUM_EQ_BANDS> EQ_FREQUENCIES = {
31.25f, 62.5f, 125.0f, 250.0f, 500.0f,
1000.0f, 2000.0f, 4000.0f, 8000.0f, 16000.0f
};
struct alignas(16) BiquadBank {
alignas(16) std::array<float, NUM_EQ_BANDS> a0{}, a1{}, a2{}, b1{}, b2{};
alignas(16) std::array<float, NUM_EQ_BANDS> z1{}, z2{};
uint16_t activeMask = 0;
[[nodiscard]] inline bool hasActiveBands() const { return activeMask != 0; }
inline void setBandActive(int band, bool active) {
if (active) activeMask |= (1 << band);
else activeMask &= ~(1 << band);
}
inline void processBlock(float* __restrict__ data, int count) {
if (!this -> hasActiveBands()) return;
for (int i = 0; i < count; i++) {
float x = data[i];
#pragma GCC unroll 10
for (int b = 0; b < NUM_EQ_BANDS; b++) {
if (activeMask & (1 << b)) {
float y = x * a0[b] + z1[b];
z1[b] = x * a1[b] + z2[b] - b1[b] * y + DENORMAL_OFFSET;
z2[b] = x * a2[b] - b2[b] * y;
x = y;
}
}
data[i] = x;
}
}
void setPeakingEQ(int band, float sr, float f, float g, float bw) {
if (band < 0 || band >= NUM_EQ_BANDS) return;
const bool active = std::abs(g) > 0.1f;
setBandActive(band, active);
if (!active) return;
const float A = powf(10.0f, g / 40.0f);
const float w = 2.0f * static_cast<float>(M_PI) * f / sr;
const float alpha = sinf(w) * sinhf(logf(2.0f) / 2.0f * bw * w / sinf(w));
const float c = cosf(w);
const float a0_raw = 1.0f + alpha / A;
const float invA0 = 1.0f / a0_raw;
a0[band] = (1.0f + alpha * A) * invA0;
a1[band] = (-2.0f * c) * invA0;
a2[band] = (1.0f - alpha * A) * invA0;
b1[band] = (-2.0f * c) * invA0;
b2[band] = (1.0f - alpha / A) * invA0;
}
};
struct alignas(16) BassFilter {
alignas(16) float a0 = 1.2f, a1 = 1.2f, a2 = 1.2f, b1 = 0.0f, b2 = 0.0f;
alignas(16) float z1 = 0.0f, z2 = 0.0f;
bool active = false;
inline float process(float x) {
if (!active) return x;
float y = x * a0 + z1;
z1 = x * a1 + z2 - b1 * y + DENORMAL_OFFSET;
z2 = x * a2 - b2 * y;
if(y > 1.2f) y = 1.2f; else if(y < -1.2f) y = -1.2f;
return y;
}
void setLowShelf(float sr,float f,float g,float q){
active=std::abs(g)>0.01f;
if(!active) return;
float A=powf(10.0f,g/40.0f);
float w=2.0f*static_cast<float>(M_PI)*f/sr;
float alpha=sinf(w)/2.0f*sqrtf((A+1.0f/A)*(1.0f/q-1.0f)+2.0f);
float c=cosf(w),sqrtA=sqrtf(A);
float a0_raw=(A+1.0f)+(A-1.0f)*c+2.0f*sqrtA*alpha;
float invA0=1.0f/a0_raw;
a0=A*((A+1.0f)-(A-1.0f)*c+2.0f*sqrtA*alpha)*invA0;
a1=2.0f*A*((A-1.0f)-(A+1.0f)*c)*invA0;
a2=A*((A+1.0f)-(A-1.0f)*c-2.0f*sqrtA*alpha)*invA0;
b1=-2.0f*((A-1.0f)+(A+1.0f)*c)*invA0;
b2=((A+1.0f)+(A-1.0f)*c-2.0f*sqrtA*alpha)*invA0;
}
};
template<int SIZE>
struct CircularBuffer {
alignas(16) std::array<float, SIZE> data = {};
int pos = 0;
[[nodiscard]] inline float read() const { return data[pos]; }
inline void write(float v) { data[pos] = v; }
inline void advance() { pos = (pos + 1) % SIZE; }
};
class ReverbOptimized {
std::array<CircularBuffer<1116>, 4> combs;
std::array<CircularBuffer<556>, 2> allpasses;
std::array<float, 4> combFeedback = {0.841f, 0.815f, 0.796f, 0.771f};
float mix = 0.0f;
public:
inline void setMix(float m) { mix = m; }
inline float process(float x) {
if (mix < 0.01f) return x;
float out = 0.0f;
#pragma GCC unroll 4
for (int i = 0; i < 4; i++) {
float delayed = combs[i].read();
out += delayed;
combs[i].write(x + delayed * combFeedback[i] + DENORMAL_OFFSET);
combs[i].advance();
}
out *= 0.25f;
for (int i = 0; i < 2; i++) {
float bufOut = allpasses[i].read();
float xOut = -0.5f * out + bufOut;
allpasses[i].write(out + 0.5f * bufOut);
allpasses[i].advance();
out = xOut;
}
return x * (1.0f - mix) + out * mix;
}
inline void processBlock(float* __restrict__ left, float* __restrict__ right, int count) {
if (mix < 0.01f) return;
for (int i = 0; i < count; i++) {
left[i] = process(left[i]);
right[i] = process(right[i]);
}
}
};
class CompressorOptimized {
public:
float threshold = 0.3f, ratio = 4.0f, attack = 0.08f, release = 0.8f, sampleRate = 44100.0f;
private:
float envelopeL = 0.0f, envelopeR = 0.0f;
float attackCoef = 0.0f, releaseCoef = 0.0f;
bool coefficientsValid = false;
public:
inline void updateCoefficients() {
if (coefficientsValid) return;
attackCoef = expf(-1.0f / (attack * sampleRate));
releaseCoef = expf(-1.0f / (release * sampleRate));
coefficientsValid = true;
}
inline void processBlock(float* __restrict__ buffer, int count, float& envelope) {
updateCoefficients();
for(int i=0; i<count; i++){
float absInput = fabsf(buffer[i]);
envelope = (absInput > envelope) ? attackCoef*envelope + (1-attackCoef)*absInput : releaseCoef*envelope + (1-releaseCoef)*absInput;
float gain = (envelope>threshold)? (threshold + (envelope-threshold)/ratio)/(envelope+1e-9f) : 1.0f;
buffer[i]*=gain;
}
}
inline void process(float* __restrict__ left, float* __restrict__ right, int count) {
processBlock(left, count, envelopeL);
processBlock(right, count, envelopeR);
}
};
CompressorOptimized gCompressor;
ReverbOptimized gReverbL, gReverbR;
BiquadBank gEqL, gEqR;
BassFilter gBassL, gBassR;
bool gDrcEnabled = false, gEqEnabled = false, gBassBoostEnabled = false;
float gStereoWidth = 1.0f;
alignas(16) std::array<float, 4096> gLeftBuf, gRightBuf;
alignas(16) std::array<float, 256> gFFTData;
alignas(16) std::array<std::complex<float>, FFT_SIZE> gFFTWork;
inline void fastFFT(std::complex<float>* __restrict__ data, int n) {
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) std::swap(data[i], data[j]);
}
for (int len = 2; len <= n; len <<= 1) {
float ang = -2.0f * static_cast<float>(M_PI) / static_cast<float>(len);
std::complex<float> wlen(cosf(ang), sinf(ang));
for (int i = 0; i < n; i += len) {
std::complex<float> w(1.0f);
for (int j = 0; j < len / 2; j++) {
std::complex<float> u = data[i + j];
std::complex<float> v = data[i + j + len / 2] * w;
data[i + j] = u + v;
data[i + j + len / 2] = u - v;
w *= wlen;
}
}
}
}
inline float fastSoftClip(float x) {
float ax = fabsf(x);
float sign = x > 0 ? 1.0f : -1.0f;
if (ax > 1.0f) return sign;
return x * (1.5f - 0.5f * x * x);
}
extern "C" {
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setDrcEnabled(JNIEnv*, jobject, jboolean e) { gDrcEnabled = e; }
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setReverbMix(JNIEnv*, jobject, jfloat m) { gReverbL.setMix(m); gReverbR.setMix(m); }
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setEqBand(JNIEnv*, jobject, jint b, jfloat g) {
if (b >= 0 && b < NUM_EQ_BANDS) {
gEqL.setPeakingEQ(b, 44100.0f, EQ_FREQUENCIES[b], g, 1.0f);
gEqR.setPeakingEQ(b, 44100.0f, EQ_FREQUENCIES[b], g, 1.0f);
}
gEqEnabled = gEqL.hasActiveBands();
}
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setBassBoost(JNIEnv*, jobject, jfloat g) {
if (g > 0.01f) {
gBassL.setLowShelf(44100.0f, 150.0f, g, SQRT_2_INV);
gBassR.setLowShelf(44100.0f, 150.0f, g, SQRT_2_INV);
gBassBoostEnabled = true;
} else { gBassBoostEnabled = false; }
}
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setStereoWidth(JNIEnv*, jobject, jfloat w) { gStereoWidth = fmaxf(0.0f, fminf(w, 2.0f)); }
JNIEXPORT jfloatArray JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_getFftData(JNIEnv* env, jobject) {
jfloatArray arr = env->NewFloatArray(256);
env->SetFloatArrayRegion(arr, 0, 256, gFFTData.data());
return arr;
}
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_processAudioDirect(JNIEnv* env, jobject, jobject byteBuffer, jint size) {
auto* buffer = static_cast<jshort*>(env->GetDirectBufferAddress(byteBuffer));
if (!buffer) return;
int numFrames = (size / 2) / 2;
if (numFrames > 4096) numFrames = 4096;
for (int i = 0; i < numFrames; i++) {
gLeftBuf[i] = static_cast<float>(buffer[i * 2]) * INV_32768;
gRightBuf[i] = static_cast<float>(buffer[i * 2 + 1]) * INV_32768;
}
if (gEqEnabled) { gEqL.processBlock(gLeftBuf.data(), numFrames); gEqR.processBlock(gRightBuf.data(), numFrames); }
if (gBassBoostEnabled) {
for(int i=0; i<numFrames; i++) { gLeftBuf[i] = gBassL.process(gLeftBuf[i]); gRightBuf[i] = gBassR.process(gRightBuf[i]); }
}
gReverbL.processBlock(gLeftBuf.data(), gRightBuf.data(), numFrames);
if (gStereoWidth != 1.0f) {
float halfWidth = gStereoWidth * 0.5f;
for (int j = 0; j < numFrames; j++) {
float mid = (gLeftBuf[j] + gRightBuf[j]) * 0.5f;
float side = (gLeftBuf[j] - gRightBuf[j]) * halfWidth;
gLeftBuf[j] = mid + side; gRightBuf[j] = mid - side;
}
}
if (gDrcEnabled) gCompressor.process(gLeftBuf.data(), gRightBuf.data(), numFrames);
// FFT for visualization
for (int k = 0; k < FFT_SIZE; k++) {
gFFTWork[k] = (k < 256 && k < numFrames) ? std::complex<float>(gLeftBuf[k], 0.0f) : std::complex<float>(0.0f, 0.0f);
}
fastFFT(gFFTWork.data(), FFT_SIZE);
for (int k = 0; k < 256; k++) {
gFFTData[k] = std::abs(gFFTWork[k]) * 0.5f; // Increased scale
}
for (int k = 0; k < numFrames; k++) {
buffer[k * 2] = static_cast<jshort>(fastSoftClip(gLeftBuf[k]) * 32767.0f);
buffer[k * 2 + 1] = static_cast<jshort>(fastSoftClip(gRightBuf[k]) * 32767.0f);
}
}
}
+70
View File
@@ -0,0 +1,70 @@
#include <jni.h>
#include <android/native_window_jni.h>
#include <android/native_window.h>
#include <android/log.h>
#include <vector>
#include <algorithm>
extern "C" {
JNIEXPORT void JNICALL
Java_com_michatec_radio_helpers_ExtrasHelper_visualize(JNIEnv *env, jclass clazz, jobject surface, jfloatArray data) {
if (!surface) return;
ANativeWindow* window = ANativeWindow_fromSurface(env, surface);
if (!window) return;
jsize len = env->GetArrayLength(data);
if (len == 0) {
ANativeWindow_release(window);
return;
}
jfloat* body = env->GetFloatArrayElements(data, nullptr);
ANativeWindow_Buffer buffer;
ANativeWindow_setBuffersGeometry(window, 0, 0, WINDOW_FORMAT_RGBA_8888);
if (ANativeWindow_lock(window, &buffer, nullptr) == 0) {
auto* pixels = static_cast<uint32_t*>(buffer.bits);
// Clear background (Dark Grey)
for (int y = 0; y < buffer.height; y++) {
for (int x = 0; x < buffer.width; x++) {
pixels[y * buffer.stride + x] = 0xFF121212;
}
}
// Draw bars
int displayBins = std::min(static_cast<int>(len), 128);
float barWidth = static_cast<float>(buffer.width) / static_cast<float>(displayBins);
for (int i = 0; i < displayBins; i++) {
// Keep original order: bass (low freq) at left, treble (high freq) at right
float val = body[i];
float scaledVal = val * 5.0f;
int barHeight = static_cast<int>(scaledVal * static_cast<float>(buffer.height));
if (barHeight > buffer.height) barHeight = buffer.height;
if (barHeight < 12) barHeight = 12; // Min height
int startX = static_cast<int>(static_cast<float>(i) * barWidth);
int endX = static_cast<int>(static_cast<float>(i + 1) * barWidth);
int barBottom = buffer.height;
int barTop = barBottom - barHeight;
for (int x = startX; x <= endX; x++) {
if (x < 0 || x >= buffer.width) continue;
for (int y = barTop; y < barBottom; y++) {
pixels[y * buffer.stride + x] = 0xFFC5DA03;
}
}
}
ANativeWindow_unlockAndPost(window);
}
env->ReleaseFloatArrayElements(data, body, JNI_ABORT);
ANativeWindow_release(window);
}
} // extern "C"
@@ -0,0 +1,33 @@
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)
.setExpandedControllerActivityClassName(ExpandedControllerActivity::class.java.name)
.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
}
}
@@ -0,0 +1,68 @@
package com.michatec.radio
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SeekBarPreference
import com.michatec.radio.helpers.PreferencesHelper
/*
* EqualizerFragment class: Handles audio frequency settings with 10-band EQ
*/
class EqualizerFragment : PreferenceFragmentCompat() {
// EQ band frequencies matching radio.cpp
private val eqFrequencies = arrayOf("31 Hz", "62 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz")
private val eqKeys = arrayOf(
Keys.PREF_EQ_LOW, // Band 0: 31 Hz
Keys.PREF_EQ_BAND_1, // Band 1: 62 Hz
Keys.PREF_EQ_BAND_2, // Band 2: 125 Hz
Keys.PREF_EQ_BAND_3, // Band 3: 250 Hz
Keys.PREF_EQ_BAND_4, // Band 4: 500 Hz
Keys.PREF_EQ_BAND_5, // Band 5: 1 kHz
Keys.PREF_EQ_MID, // Band 6: 2 kHz
Keys.PREF_EQ_BAND_6, // Band 7: 4 kHz
Keys.PREF_EQ_BAND_7, // Band 8: 8 kHz
Keys.PREF_EQ_HIGH // Band 9: 16 kHz
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(activity as AppCompatActivity).supportActionBar?.title = getString(R.string.pref_equalizer_title)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val context = preferenceManager.context
val screen = preferenceManager.createPreferenceScreen(context)
// Reset Button
val resetPreference = Preference(context)
resetPreference.title = getString(R.string.pref_equalizer_reset_title)
resetPreference.setIcon(R.drawable.ic_refresh_24dp)
resetPreference.setOnPreferenceClickListener {
PreferencesHelper.resetEqualizer()
for (key in eqKeys) {
findPreference<SeekBarPreference>(key)?.value = 0
}
return@setOnPreferenceClickListener true
}
screen.addPreference(resetPreference)
// Create 10-band EQ
for (i in eqKeys.indices) {
val eqBand = SeekBarPreference(context)
eqBand.title = "Equalizer: ${eqFrequencies[i]}"
eqBand.key = eqKeys[i]
eqBand.setIcon(R.drawable.ic_music_note_24dp)
eqBand.min = -12
eqBand.max = 12
eqBand.showSeekBarValue = true
eqBand.setDefaultValue(0)
screen.addPreference(eqBand)
}
preferenceScreen = screen
}
}
@@ -0,0 +1,14 @@
package com.michatec.radio
import android.view.Menu
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.media.widget.ExpandedControllerActivity
class ExpandedControllerActivity : ExpandedControllerActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.expanded_controller, menu)
CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.media_route_menu_item)
return true
}
}
+32 -1
View File
@@ -27,6 +27,7 @@ object Keys {
const val EXTRA_START_LAST_PLAYED_STATION: String = "START_LAST_PLAYED_STATION" const val EXTRA_START_LAST_PLAYED_STATION: String = "START_LAST_PLAYED_STATION"
const val EXTRA_SLEEP_TIMER_REMAINING: String = "SLEEP_TIMER_REMAINING" const val EXTRA_SLEEP_TIMER_REMAINING: String = "SLEEP_TIMER_REMAINING"
const val EXTRA_METADATA_HISTORY: String = "METADATA_HISTORY" const val EXTRA_METADATA_HISTORY: String = "METADATA_HISTORY"
const val EXTRA_VISUALIZER_DATA: String = "VISUALIZER_DATA"
// arguments // arguments
const val ARG_UPDATE_COLLECTION: String = "ArgUpdateCollection" const val ARG_UPDATE_COLLECTION: String = "ArgUpdateCollection"
@@ -43,6 +44,7 @@ object Keys {
const val CMD_PLAY_STREAM: String = "PLAY_STREAM" const val CMD_PLAY_STREAM: String = "PLAY_STREAM"
const val CMD_REQUEST_SLEEP_TIMER_REMAINING: String = "REQUEST_SLEEP_TIMER_REMAINING" const val CMD_REQUEST_SLEEP_TIMER_REMAINING: String = "REQUEST_SLEEP_TIMER_REMAINING"
const val CMD_REQUEST_METADATA_HISTORY: String = "REQUEST_METADATA_HISTORY" const val CMD_REQUEST_METADATA_HISTORY: String = "REQUEST_METADATA_HISTORY"
const val CMD_GET_VISUALIZER_DATA: String = "GET_VISUALIZER_DATA"
// preferences // preferences
const val PREF_RADIO_BROWSER_API: String = "RADIO_BROWSER_API" const val PREF_RADIO_BROWSER_API: String = "RADIO_BROWSER_API"
@@ -60,6 +62,36 @@ 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"
const val PREF_EQUALIZER: String = "EQUALIZER_SETTINGS"
const val PREF_EQ_BAND_1: String = "EQ_BAND_1"
const val PREF_EQ_BAND_2: String = "EQ_BAND_2"
const val PREF_EQ_BAND_3: String = "EQ_BAND_3"
const val PREF_EQ_BAND_4: String = "EQ_BAND_4"
const val PREF_EQ_BAND_5: String = "EQ_BAND_5"
const val PREF_EQ_BAND_6: String = "EQ_BAND_6"
const val PREF_EQ_BAND_7: String = "EQ_BAND_7"
const val PREF_EQ_BAND_8: String = "EQ_BAND_8"
const val PREF_PRESET_SELECTED: String = "PRESET_SELECTED"
const val PREF_PRESET_EQ_BAND_0: String = "PRESET_EQ_BAND_0"
const val PREF_PRESET_EQ_BAND_1: String = "PRESET_EQ_BAND_1"
const val PREF_PRESET_EQ_BAND_2: String = "PRESET_EQ_BAND_2"
const val PREF_PRESET_EQ_BAND_3: String = "PRESET_EQ_BAND_3"
const val PREF_PRESET_EQ_BAND_4: String = "PRESET_EQ_BAND_4"
const val PREF_PRESET_EQ_BAND_5: String = "PRESET_EQ_BAND_5"
const val PREF_PRESET_EQ_BAND_6: String = "PRESET_EQ_BAND_6"
const val PREF_PRESET_EQ_BAND_7: String = "PRESET_EQ_BAND_7"
const val PREF_PRESET_EQ_BAND_8: String = "PRESET_EQ_BAND_8"
const val PREF_PRESET_EQ_BAND_9: String = "PRESET_EQ_BAND_9"
const val PREF_PRESET_BASS_BOOST: String = "PRESET_BASS_BOOST"
const val PREF_PRESET_REVERB: String = "PRESET_REVERB"
const val PREF_PRESET_DRC: String = "PRESET_DRC"
const val PREF_PRESET_STEREO_WIDTH: String = "PRESET_STEREO_WIDTH"
// 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 +116,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,7 +27,6 @@ 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()
@@ -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()
} }
@@ -402,6 +402,7 @@ class PlayerFragment : Fragment(),
/* Releases MediaController */ /* Releases MediaController */
private fun releaseController() { private fun releaseController() {
controller?.removeListener(playerListener)
MediaController.releaseFuture(controllerFuture) MediaController.releaseFuture(controllerFuture)
} }
@@ -809,7 +810,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)
@@ -10,12 +10,16 @@ import android.os.CountDownTimer
import android.util.Log import android.util.Log
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
import androidx.media3.cast.CastPlayer
import androidx.media3.common.* 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 +29,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,13 +39,15 @@ 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
/* Main class variables */ /* Main class variables */
private lateinit var player: Player private lateinit var player: Player
private lateinit var exoPlayer: ExoPlayer
private lateinit var castPlayer: CastPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var sleepTimer: CountDownTimer private lateinit var sleepTimer: CountDownTimer
var sleepTimerTimeRemaining: Long = 0L var sleepTimerTimeRemaining: Long = 0L
@@ -57,6 +60,9 @@ class PlayerService : MediaLibraryService() {
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 */
override fun onCreate() { override fun onCreate() {
@@ -76,6 +82,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()
} }
@@ -85,7 +96,11 @@ class PlayerService : MediaLibraryService() {
PreferencesHelper.saveIsPlaying(false) PreferencesHelper.saveIsPlaying(false)
player.removeListener(playerListener) player.removeListener(playerListener)
player.release() player.release()
exoPlayer.release()
castPlayer.release()
mediaLibrarySession.release() mediaLibrarySession.release()
// unregister preference change listener
PreferencesHelper.unregisterPreferenceChangeListener(this)
super.onDestroy() super.onDestroy()
} }
@@ -106,8 +121,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()
}
}
exoPlayer = ExoPlayer.Builder(this, renderersFactory).apply {
setAudioAttributes(audioAttributes, true)
setHandleAudioBecomingNoisy(true) setHandleAudioBecomingNoisy(true)
setLoadControl(createDefaultLoadControl(bufferSizeMultiplier)) setLoadControl(createDefaultLoadControl(bufferSizeMultiplier))
setMediaSourceFactory( setMediaSourceFactory(
@@ -119,8 +152,12 @@ class PlayerService : MediaLibraryService() {
exoPlayer.addAnalyticsListener(analyticsListener) exoPlayer.addAnalyticsListener(analyticsListener)
exoPlayer.addListener(playerListener) exoPlayer.addListener(playerListener)
// manually add seek to next and seek to previous since headphones issue them and they are translated to next and previous station // Initialize CastPlayer
player = object : ForwardingPlayer(exoPlayer) { castPlayer = CastPlayer.Builder(this).setLocalPlayer(exoPlayer).build()
// manually add seek to next and seek to previous since headphones issue them, and they are translated to next and previous station
// IMPORTANT: Use castPlayer here instead of exoPlayer so the session controls both local and remote playback
player = object : ForwardingPlayer(castPlayer) {
override fun getAvailableCommands(): Player.Commands { override fun getAvailableCommands(): Player.Commands {
return super.getAvailableCommands().buildUpon().add(COMMAND_SEEK_TO_NEXT) return super.getAvailableCommands().buildUpon().add(COMMAND_SEEK_TO_NEXT)
.add(COMMAND_SEEK_TO_PREVIOUS).build() .add(COMMAND_SEEK_TO_PREVIOUS).build()
@@ -134,6 +171,7 @@ class PlayerService : MediaLibraryService() {
return C.TIME_UNSET // this will hide progress bar for HLS stations in the notification return C.TIME_UNSET // this will hide progress bar for HLS stations in the notification
} }
} }
player.addListener(playerListener)
} }
@@ -246,6 +284,69 @@ class PlayerService : MediaLibraryService() {
} }
/* Applies audio effects based on preferences */
private fun applyAudioEffects() {
val selectedPreset = PreferencesHelper.loadSelectedPreset()
if (selectedPreset.isNotEmpty()) {
applyPreset(selectedPreset)
} else {
// Apply manual settings
nativeAudioProcessor.enableBassBoost(PreferencesHelper.loadBassBoost())
nativeAudioProcessor.setReverb(PreferencesHelper.loadReverb())
nativeAudioProcessor.enableDrc(PreferencesHelper.loadDrcEnabled())
nativeAudioProcessor.setWidth(1f)
// Apply all 10 EQ bands
val eqGains = FloatArray(10)
for (i in 0 until 10) {
eqGains[i] = PreferencesHelper.loadEqBand(i).toFloat()
}
nativeAudioProcessor.setEqAll(eqGains)
}
}
/* Applies a saved preset */
private fun applyPreset(presetName: String) {
when (presetName) {
getString(R.string.pref_preset_rock) -> nativeAudioProcessor.setPresetRock()
getString(R.string.pref_preset_pop) -> nativeAudioProcessor.setPresetPop()
getString(R.string.pref_preset_jazz) -> nativeAudioProcessor.setPresetJazz()
getString(R.string.pref_preset_flat) -> nativeAudioProcessor.setPresetFlat()
else -> {
// Custom preset - load from preferences
nativeAudioProcessor.enableDrc(PreferencesHelper.loadPresetDrc())
nativeAudioProcessor.setReverb(PreferencesHelper.loadPresetReverb())
nativeAudioProcessor.setWidth(PreferencesHelper.loadPresetStereoWidth())
nativeAudioProcessor.enableBassBoost(PreferencesHelper.loadPresetBassBoost())
val eqGains = FloatArray(10)
for (i in 0 until 10) {
eqGains[i] = PreferencesHelper.loadPresetEqBand(i).toFloat()
}
nativeAudioProcessor.setEqAll(eqGains)
}
}
}
/* 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,
Keys.PREF_EQ_BAND_1, Keys.PREF_EQ_BAND_2, Keys.PREF_EQ_BAND_3,
Keys.PREF_EQ_BAND_4, Keys.PREF_EQ_BAND_5, Keys.PREF_EQ_BAND_6,
Keys.PREF_EQ_BAND_7, Keys.PREF_EQ_BAND_8,
Keys.PREF_PRESET_SELECTED,
Keys.PREF_PRESET_EQ_BAND_0, Keys.PREF_PRESET_EQ_BAND_1, Keys.PREF_PRESET_EQ_BAND_2,
Keys.PREF_PRESET_EQ_BAND_3, Keys.PREF_PRESET_EQ_BAND_4, Keys.PREF_PRESET_EQ_BAND_5,
Keys.PREF_PRESET_EQ_BAND_6, Keys.PREF_PRESET_EQ_BAND_7, Keys.PREF_PRESET_EQ_BAND_8,
Keys.PREF_PRESET_EQ_BAND_9,
Keys.PREF_PRESET_BASS_BOOST, Keys.PREF_PRESET_REVERB,
Keys.PREF_PRESET_DRC, Keys.PREF_PRESET_STEREO_WIDTH -> {
applyAudioEffects()
}
}
}
/* /*
* Custom MediaSession Callback that handles player commands * Custom MediaSession Callback that handles player commands
*/ */
@@ -268,13 +369,13 @@ 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))
builder.add(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY)) builder.add(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY))
builder.add(SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY)) builder.add(SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY))
builder.add(SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY)) builder.add(SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY))
builder.add(SessionCommand(Keys.CMD_GET_VISUALIZER_DATA, Bundle.EMPTY))
return MediaSession.ConnectionResult.accept(builder.build(), connectionResult.availablePlayerCommands) return MediaSession.ConnectionResult.accept(builder.build(), connectionResult.availablePlayerCommands)
} }
@@ -361,6 +462,19 @@ class PlayerService : MediaLibraryService() {
) )
) )
} }
Keys.CMD_GET_VISUALIZER_DATA -> {
val resultBundle = Bundle()
resultBundle.putFloatArray(
Keys.EXTRA_VISUALIZER_DATA,
nativeAudioProcessor.getVisualizer()
)
return Futures.immediateFuture(
SessionResult(
SessionResult.RESULT_SUCCESS,
resultBundle
)
)
}
} }
return super.onCustomCommand(session, controller, customCommand, args) return super.onCustomCommand(session, controller, customCommand, args)
} }
@@ -426,35 +540,23 @@ class PlayerService : MediaLibraryService() {
isPlaying isPlaying
) )
if (isPlaying) { if (!isPlaying) {
// playback is active
} else {
// cancel sleep timer // cancel sleep timer
cancelSleepTimer() cancelSleepTimer()
// reset metadata // reset metadata
updateMetadata() updateMetadata()
// playback is not active // Check playback state to decide whether to stop the service
// Not playing because playback is paused, ended, suppressed, or the player
// is buffering, stopped or failed. Check player.getPlayWhenReady,
// player.getPlaybackState, player.getPlaybackSuppressionReason and
// player.getPlaybackError for details.
when (player.playbackState) { when (player.playbackState) {
// player is able to immediately play from its current position Player.STATE_ENDED, Player.STATE_IDLE -> {
stopSelf()
}
Player.STATE_READY -> { Player.STATE_READY -> {
// Playback is paused. For radio, we can stop the service to remove the notification.
stopSelf() stopSelf()
} }
// buffering - data needs to be loaded
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
stopSelf() // DO NOT stop the service while buffering (especially important for Cast)
}
// player finished playing all media
Player.STATE_ENDED -> {
stopSelf()
}
// initial state or player is stopped or playback failed
Player.STATE_IDLE -> {
stopSelf()
} }
} }
} }
@@ -463,14 +565,10 @@ class PlayerService : MediaLibraryService() {
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason) super.onPlayWhenReadyChanged(playWhenReady, reason)
if (!playWhenReady) { if (!playWhenReady) {
when (reason) { // Only stop if not buffering and not ready to play (i.e. truly stopped/paused)
Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM -> { if (player.playbackState != Player.STATE_BUFFERING) {
stopSelf() stopSelf()
} }
else -> {
stopSelf()
}
}
} }
} }
@@ -494,7 +592,7 @@ class PlayerService : MediaLibraryService() {
/* /*
* Custom LoadErrorHandlingPolicy that network drop outs * Custom LoadErrorHandlingPolicy that network drop-outs
*/ */
private val loadErrorHandlingPolicy: DefaultLoadErrorHandlingPolicy = object: DefaultLoadErrorHandlingPolicy() { private val loadErrorHandlingPolicy: DefaultLoadErrorHandlingPolicy = object: DefaultLoadErrorHandlingPolicy() {
override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long { override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
@@ -6,7 +6,6 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.View import android.view.View
@@ -18,6 +17,7 @@ import androidx.navigation.fragment.findNavController
import androidx.preference.* import androidx.preference.*
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.michatec.radio.dialogs.ErrorDialog import com.michatec.radio.dialogs.ErrorDialog
import com.michatec.radio.dialogs.PresetSelectionDialog
import com.michatec.radio.dialogs.ThemeSelectionDialog import com.michatec.radio.dialogs.ThemeSelectionDialog
import com.michatec.radio.dialogs.YesNoDialog import com.michatec.radio.dialogs.YesNoDialog
import com.michatec.radio.helpers.* import com.michatec.radio.helpers.*
@@ -31,7 +31,7 @@ import java.util.*
/* /*
* SettingsFragment class * SettingsFragment class
*/ */
class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListener, ThemeSelectionDialog.ThemeSelectionDialogListener { class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListener, ThemeSelectionDialog.ThemeSelectionDialogListener, PresetSelectionDialog.PresetSelectionDialogListener {
/* Define log tag */ /* Define log tag */
@@ -53,6 +53,9 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
val context = preferenceManager.context val context = preferenceManager.context
val screen = preferenceManager.createPreferenceScreen(context) val screen = preferenceManager.createPreferenceScreen(context)
// Load current preset once
val currentPreset = PreferencesHelper.loadSelectedPreset()
// set up "App Theme" preference // set up "App Theme" preference
val preferenceThemeSelection = Preference(activity as Context) val preferenceThemeSelection = Preference(activity as Context)
preferenceThemeSelection.title = getString(R.string.pref_theme_selection_title) preferenceThemeSelection.title = getString(R.string.pref_theme_selection_title)
@@ -186,6 +189,76 @@ 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.isEnabled = currentPreset.isEmpty()
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.isEnabled = currentPreset.isEmpty()
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.isEnabled = currentPreset.isEmpty()
preferenceDrc.summary = getString(R.string.pref_drc_summary)
preferenceDrc.setDefaultValue(true)
// set up "Preset Selection" preference
val preferencePresetSelection = Preference(context)
preferencePresetSelection.title = getString(R.string.pref_preset_selection_title)
preferencePresetSelection.setIcon(R.drawable.ic_music_note_24dp)
preferencePresetSelection.key = Keys.PREF_PRESET_SELECTED
val presetSummary = currentPreset.ifEmpty {
getString(R.string.pref_preset_none)
}
preferencePresetSelection.summary = "${getString(R.string.pref_preset_selection_summary)}: $presetSummary"
preferencePresetSelection.setOnPreferenceClickListener {
PresetSelectionDialog(this).show(activity as Context)
return@setOnPreferenceClickListener true
}
// Initialize EQ control states based on current preset
updateEqControlStates()
// set up "Equalizer" preference entry
val preferenceEqualizer = Preference(context)
preferenceEqualizer.title = getString(R.string.pref_equalizer_title)
preferenceEqualizer.setIcon(R.drawable.ic_music_note_24dp)
preferenceEqualizer.key = Keys.PREF_EQUALIZER
if (currentPreset.isEmpty()) {
preferenceEqualizer.summary = getString(R.string.pref_equalizer_summary)
preferenceEqualizer.isEnabled = true
} else {
preferenceEqualizer.summary = getString(R.string.pref_equalizer_summary_off)
preferenceEqualizer.isEnabled = false
}
preferenceEqualizer.setOnPreferenceClickListener {
findNavController().navigate(R.id.action_settings_to_equalizer)
return@setOnPreferenceClickListener true
}
val preferenceVisualizer = Preference(context)
preferenceVisualizer.title = getString(R.string.pref_visualizer_title)
preferenceVisualizer.setIcon(R.drawable.ic_music_note_24dp)
preferenceVisualizer.summary = getString(R.string.pref_visualizer_summary)
preferenceVisualizer.setOnPreferenceClickListener {
findNavController().navigate(R.id.action_settings_to_visualizer)
return@setOnPreferenceClickListener 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)
@@ -196,10 +269,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
val clip: ClipData = ClipData.newPlainText("simple text", preferenceAppVersion.summary) val clip: ClipData = ClipData.newPlainText("simple text", preferenceAppVersion.summary)
val cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(clip) cm.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// since API 33 (TIRAMISU) the OS displays its own notification when content is copied to the clipboard
Snackbar.make(requireView(), R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show() Snackbar.make(requireView(), R.string.toastmessage_copied_to_clipboard, Snackbar.LENGTH_LONG).show()
}
return@setOnPreferenceClickListener true return@setOnPreferenceClickListener true
} }
@@ -238,51 +308,56 @@ 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(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)
preferenceCategoryAudioEffects.addPreference(preferencePresetSelection)
preferenceCategoryAudioEffects.addPreference(preferenceEqualizer)
preferenceCategoryAudioEffects.addPreference(preferenceVisualizer)
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(preferenceGitHub)
preferenceCategoryLinks.addPreference(preferenceLicense)
preferenceScreen = screen preferenceScreen = screen
} }
@@ -307,6 +382,49 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
} }
/* Overrides onPresetSelectionDialog from PresetSelectionDialogListener */
override fun onPresetSelectionDialog(dialogResult: Boolean, selectedPreset: String) {
if (dialogResult) {
// update summary
val presetPreference = findPreference<Preference>(Keys.PREF_PRESET_SELECTED)
val presetSummary = selectedPreset.ifEmpty {
getString(R.string.pref_preset_none)
}
presetPreference?.summary = "${getString(R.string.pref_preset_selection_summary)}: $presetSummary"
// Enable/disable manual EQ controls based on preset selection
updateEqControlStates()
}
}
/* Updates the enabled/disabled state of EQ controls based on preset selection */
private fun updateEqControlStates() {
val currentPreset = PreferencesHelper.loadSelectedPreset()
val isPresetSelected = currentPreset.isNotEmpty()
// Update Bass Boost
findPreference<Preference>(Keys.PREF_BASS_BOOST)?.isEnabled = !isPresetSelected
// Update Reverb
findPreference<Preference>(Keys.PREF_REVERB)?.isEnabled = !isPresetSelected
// Update DRC
findPreference<Preference>(Keys.PREF_DRC)?.isEnabled = !isPresetSelected
// Update Equalizer with proper key
val preferenceEqualizer = findPreference<Preference>(Keys.PREF_EQUALIZER)
if (preferenceEqualizer != null) {
if (isPresetSelected) {
preferenceEqualizer.summary = getString(R.string.pref_equalizer_summary_off)
preferenceEqualizer.isEnabled = false
} else {
preferenceEqualizer.summary = getString(R.string.pref_equalizer_summary)
preferenceEqualizer.isEnabled = true
}
}
}
/* Overrides onYesNoDialog from YesNoDialogListener */ /* Overrides onYesNoDialog from YesNoDialogListener */
override fun onYesNoDialog( override fun onYesNoDialog(
type: Int, type: Int,
@@ -491,28 +609,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 +645,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()
}
}
} }
@@ -0,0 +1,122 @@
package com.michatec.radio
import android.content.ComponentName
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import androidx.preference.PreferenceFragmentCompat
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.michatec.radio.extensions.requestVisualizerData
import com.michatec.radio.helpers.ExtrasHelper
/*
* VisualizerFragment class: Handles audio visualization
*/
@OptIn(UnstableApi::class)
class VisualizerFragment : PreferenceFragmentCompat() {
private val TAG = "VisualizerFragment"
private lateinit var controllerFuture: ListenableFuture<MediaController>
private val controller: MediaController?
get() = if (this::controllerFuture.isInitialized && controllerFuture.isDone) {
try { controllerFuture.get() } catch (_: Exception) { null }
} else null
private var visualizerPref: ExtrasHelper.VisualizerPreference? = null
private val handler = Handler(Looper.getMainLooper())
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(activity as AppCompatActivity).supportActionBar?.title = getString(R.string.pref_visualizer_title)
(activity as AppCompatActivity).supportActionBar?.show()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val context = preferenceManager.context
val screen = preferenceManager.createPreferenceScreen(context)
visualizerPref = ExtrasHelper.VisualizerPreference(context)
visualizerPref?.key = "visualizer_key"
screen.addPreference(visualizerPref!!)
preferenceScreen = screen
}
override fun onStart() {
super.onStart()
initializeController()
}
override fun onStop() {
super.onStop()
releaseController()
}
override fun onResume() {
super.onResume()
startPolling()
}
override fun onPause() {
super.onPause()
stopPolling()
}
private fun initializeController() {
controllerFuture = MediaController.Builder(
requireContext(),
SessionToken(requireContext(), ComponentName(requireContext(), PlayerService::class.java))
).buildAsync()
controllerFuture.addListener({
Log.d(TAG, "MediaController connected: ${controller != null}")
}, MoreExecutors.directExecutor())
}
private fun releaseController() {
if (this::controllerFuture.isInitialized) {
MediaController.releaseFuture(controllerFuture)
}
}
private val pollRunnable = object : Runnable {
override fun run() {
val c = controller
if (c != null && c.isPlaying) {
val resultFuture = c.requestVisualizerData()
resultFuture.addListener({
try {
val result = resultFuture.get()
if (result.resultCode == androidx.media3.session.SessionResult.RESULT_SUCCESS) {
val data = result.extras.getFloatArray(Keys.EXTRA_VISUALIZER_DATA)
if (data != null && data.isNotEmpty()) {
visualizerPref?.update(data)
}
} else {
Log.e(TAG, "Custom command failed with result code: ${result.resultCode}")
}
} catch (e: Exception) {
Log.e(TAG, "Error fetching visualizer data", e)
}
}, MoreExecutors.directExecutor())
}
handler.postDelayed(this, 18) // ~60 FPS
}
}
private fun startPolling() {
handler.removeCallbacks(pollRunnable)
handler.post(pollRunnable)
}
private fun stopPolling() {
handler.removeCallbacks(pollRunnable)
}
}
@@ -0,0 +1,86 @@
package com.michatec.radio.dialogs
import android.content.Context
import android.view.LayoutInflater
import android.widget.RadioButton
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat.getString
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.michatec.radio.R
import com.michatec.radio.helpers.PreferencesHelper
/*
* PresetSelectionDialog class
*/
class PresetSelectionDialog(private var presetSelectionDialogListener: PresetSelectionDialogListener) {
/* Interface used to communicate back to activity */
interface PresetSelectionDialogListener {
fun onPresetSelectionDialog(dialogResult: Boolean, selectedPreset: String)
}
/* Main class variables */
private lateinit var dialog: AlertDialog
/* Construct and show dialog */
fun show(context: Context) {
// prepare dialog builder
val builder = MaterialAlertDialogBuilder(context)
// inflate custom layout
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(R.layout.dialog_preset_selection, null)
// find radio buttons
val radioGroup = view.findViewById<android.widget.RadioGroup>(R.id.preset_radio_group)
val radioNone = view.findViewById<RadioButton>(R.id.radio_preset_none)
val radioRock = view.findViewById<RadioButton>(R.id.radio_preset_rock)
val radioPop = view.findViewById<RadioButton>(R.id.radio_preset_pop)
val radioJazz = view.findViewById<RadioButton>(R.id.radio_preset_jazz)
val radioFlat = view.findViewById<RadioButton>(R.id.radio_preset_flat)
// set current selection
val currentPreset = PreferencesHelper.loadSelectedPreset()
when (currentPreset) {
"" -> radioNone.isChecked = true
getString(context, R.string.pref_preset_rock) -> radioRock.isChecked = true
getString(context, R.string.pref_preset_pop) -> radioPop.isChecked = true
getString(context, R.string.pref_preset_jazz) -> radioJazz.isChecked = true
getString(context, R.string.pref_preset_flat) -> radioFlat.isChecked = true
else -> radioNone.isChecked = true
}
// set up radio group listener
radioGroup.setOnCheckedChangeListener { _, checkedId ->
val selectedPreset = when (checkedId) {
R.id.radio_preset_none -> ""
R.id.radio_preset_rock -> getString(context, R.string.pref_preset_rock)
R.id.radio_preset_pop -> getString(context, R.string.pref_preset_pop)
R.id.radio_preset_jazz -> getString(context, R.string.pref_preset_jazz)
R.id.radio_preset_flat -> getString(context, R.string.pref_preset_flat)
else -> ""
}
// save preset selection to preferences
PreferencesHelper.saveSelectedPreset(selectedPreset)
// notify listener
presetSelectionDialogListener.onPresetSelectionDialog(true, selectedPreset)
// dismiss dialog
dialog.dismiss()
}
// set custom view
builder.setView(view)
// handle outside-click as cancel
builder.setOnCancelListener {
presetSelectionDialogListener.onPresetSelectionDialog(false, "")
}
// display dialog
dialog = builder.create()
dialog.show()
}
}
@@ -35,7 +35,7 @@ fun MediaController.requestSleepTimerRemaining(): ListenableFuture<SessionResult
} }
/* Request sleep timer remaining */ /* Request metadata history */
fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> { fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> {
return sendCustomCommand( return sendCustomCommand(
SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY), SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY),
@@ -43,6 +43,14 @@ fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> {
) )
} }
/* Request visualizer data */
fun MediaController.requestVisualizerData(): ListenableFuture<SessionResult> {
return sendCustomCommand(
SessionCommand(Keys.CMD_GET_VISUALIZER_DATA, Bundle.EMPTY),
Bundle.EMPTY
)
}
/* Starts playback with a new media item */ /* Starts playback with a new media item */
fun MediaController.play(context: Context, station: Station) { fun MediaController.play(context: Context, station: Station) {
@@ -638,18 +638,23 @@ object CollectionHelper {
}.build() }.build()
// build MediaMetadata // build MediaMetadata
val mediaMetadata = MediaMetadata.Builder().apply { val mediaMetadata = MediaMetadata.Builder().apply {
setTitle(station.name)
setArtist(station.name) setArtist(station.name)
//setTitle(station.name)
// Set artwork URI for casting (remote devices need a public URL)
if (station.remoteImageLocation.isNotEmpty()) {
setArtworkUri(station.remoteImageLocation.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)
setIsPlayable(true) setIsPlayable(true)
setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION)
}.build() }.build()
// build MediaItem and return it // build MediaItem and return it
return MediaItem.Builder().apply { return MediaItem.Builder().apply {
@@ -0,0 +1,117 @@
package com.michatec.radio.helpers
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import com.michatec.radio.R
class ExtrasHelper {
companion object {
private const val TAG = "ExtrasHelper"
init {
try {
System.loadLibrary("extra")
} catch (e: Exception) {
Log.e(TAG, "Failed to load extra library", e)
}
}
@JvmStatic
private external fun visualize(surface: Surface, data: FloatArray)
fun render(surface: Surface, data: FloatArray) {
if (!surface.isValid) return
try {
visualize(surface, data)
} catch (e: Exception) {
Log.e(TAG, "Native visualize failed", e)
}
}
}
class VisualizerPreference(context: Context, attrs: AttributeSet? = null) : Preference(context, attrs) {
private var visualizerView: VisualizerView? = null
init {
// We can use a standard layout and inject our view
layoutResource = R.layout.preference_visualizer
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
// Try to find the container in the inflated layout
var container = holder.findViewById(R.id.visualizer_container) as? ViewGroup
// Fallback: If not found by ID, maybe the root is the container?
if (container == null && holder.itemView is ViewGroup) {
container = holder.itemView as ViewGroup
}
if (container != null) {
if (visualizerView == null) {
visualizerView = VisualizerView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
}
val currentParent = visualizerView?.parent as? ViewGroup
if (currentParent != container) {
currentParent?.removeView(visualizerView)
// If we injected into a standard preference, don't clear everything, just add
if (container is FrameLayout || container.childCount == 0) {
container.removeAllViews()
}
container.addView(visualizerView)
}
} else {
Log.e("VisualizerPreference", "Could not find any container to attach VisualizerView!")
}
}
fun update(data: FloatArray) {
visualizerView?.update(data)
}
}
class VisualizerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback {
private var surface: Surface? = null
init {
Log.d("VisualizerView", "VisualizerView initialized")
holder.addCallback(this)
}
fun update(data: FloatArray) {
val s = surface
if (s != null && s.isValid) {
render(s, data)
}
}
override fun surfaceCreated(holder: SurfaceHolder) {
surface = holder.surface
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
surface = holder.surface
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
surface = null
}
}
}
@@ -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,130 @@
package com.michatec.radio.helpers
import android.util.Log
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 {
private const val TAG = "NativeAudioProcessor"
init {
try {
System.loadLibrary("dsp")
} catch (e: Exception) {
Log.e(TAG, "Failed to load dsp library", e)
}
}
}
private var directBuffer: ByteBuffer? = null
// ===== JNI =====
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 setStereoWidth(width: Float)
private external fun processAudioDirect(buf: ByteBuffer, size: Int)
private external fun getFftData(): FloatArray
// ===== API =====
fun enableDrc(enabled: Boolean) = setDrcEnabled(enabled)
fun setReverb(mix: Float) = setReverbMix(mix)
fun setEq(band: Int, gainDb: Float) = setEqBand(band, gainDb)
fun setEqAll(gains: FloatArray) {
gains.forEachIndexed { i, g -> setEq(i, g) }
}
fun enableBassBoost(gainDb: Float) = setBassBoost(gainDb)
fun setWidth(width: Float) = setStereoWidth(width)
fun getVisualizer(): FloatArray {
val raw = getFftData()
val out = FloatArray(raw.size)
for (i in raw.indices) out[i] = kotlin.math.log10(1f + raw[i])
return out
}
// ===== AudioProcessor Overrides =====
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat {
// Always try to support the input format if it is PCM 16-bit
if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT) {
Log.e(TAG, "Unsupported encoding: ${inputAudioFormat.encoding}")
throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat)
}
return inputAudioFormat
}
override fun queueInput(inputBuffer: ByteBuffer) {
val size = inputBuffer.remaining()
if (size == 0) return
// Always ensure we have a direct buffer for JNI
if (directBuffer == null || directBuffer!!.capacity() < size) {
directBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder())
}
directBuffer!!.clear()
inputBuffer.position()
directBuffer!!.put(inputBuffer)
directBuffer!!.flip()
// Process audio in JNI
processAudioDirect(directBuffer!!, size)
// Copy processed data back to output
val out = replaceOutputBuffer(size)
out.order(ByteOrder.nativeOrder())
directBuffer!!.position(0)
out.put(directBuffer!!)
out.flip()
}
override fun onReset() {
super.onReset()
directBuffer = null
}
// ===== Presets =====
fun setPresetRock() {
enableDrc(true)
setReverb(0.10f)
setWidth(1.1f)
setEqAll(floatArrayOf(2f, 1f, 0f, -1f, -1f, 0f, 1f, 2f, 2f, 3f))
enableBassBoost(0.6f)
}
fun setPresetPop() {
enableDrc(true)
setReverb(0.15f)
setWidth(1.05f)
setEqAll(floatArrayOf(0f, 1f, 1f, 1f, 0f, 0f, 1f, 2f, 2f, 1f))
enableBassBoost(0.5f)
}
fun setPresetJazz() {
enableDrc(false)
setReverb(0.15f)
setWidth(1.0f)
setEqAll(floatArrayOf(0f, 0f, 1f, 1f, 0f, 0f, 1f, 1f, 0f, 0f))
enableBassBoost(0.2f)
}
fun setPresetFlat() {
enableDrc(false)
setReverb(0f)
setWidth(1f)
setEqAll(FloatArray(10))
enableBassBoost(0f)
}
}
@@ -254,4 +254,95 @@ object PreferencesHelper {
) )
} }
/* Loads Bass Boost gain */
fun loadBassBoost(): Float {
return if (sharedPreferences.getBoolean(Keys.PREF_BASS_BOOST, false)) 0.4f else 0.0f
}
/* Loads Reverb mix */
fun loadReverb(): Float {
return if (sharedPreferences.getBoolean(Keys.PREF_REVERB, false)) 0.2f else 0.0f
}
/* Loads DRC enabled state */
fun loadDrcEnabled(): Boolean {
return sharedPreferences.getBoolean(Keys.PREF_DRC, false)
}
/* Loads all EQ bands (10 bands for full range) */
fun loadEqBand(band: Int): Int {
return when (band) {
0 -> sharedPreferences.getInt(Keys.PREF_EQ_LOW, 0)
1 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_1, 0)
2 -> sharedPreferences.getInt(Keys.PREF_EQ_MID, 0)
3 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_3, 0)
4 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_4, 0)
5 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_5, 0)
6 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_6, 0)
7 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_7, 0)
8 -> sharedPreferences.getInt(Keys.PREF_EQ_BAND_8, 0)
9 -> sharedPreferences.getInt(Keys.PREF_EQ_HIGH, 0)
else -> 0
}
}
/* Loads selected preset name */
fun loadSelectedPreset(): String {
return sharedPreferences.getString(Keys.PREF_PRESET_SELECTED, "") ?: ""
}
/* Saves selected preset name */
fun saveSelectedPreset(preset: String) {
sharedPreferences.edit { putString(Keys.PREF_PRESET_SELECTED, preset) }
}
/* Loads preset EQ band values */
fun loadPresetEqBand(band: Int): Int {
return when (band) {
0 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_0, 0)
1 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_1, 0)
2 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_2, 0)
3 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_3, 0)
4 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_4, 0)
5 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_5, 0)
6 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_6, 0)
7 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_7, 0)
8 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_8, 0)
9 -> sharedPreferences.getInt(Keys.PREF_PRESET_EQ_BAND_9, 0)
else -> 0
}
}
/* Loads preset Bass Boost */
fun loadPresetBassBoost(): Float {
return sharedPreferences.getFloat(Keys.PREF_PRESET_BASS_BOOST, 0f)
}
/* Loads preset Reverb */
fun loadPresetReverb(): Float {
return sharedPreferences.getFloat(Keys.PREF_PRESET_REVERB, 0f)
}
/* Loads preset DRC */
fun loadPresetDrc(): Boolean {
return sharedPreferences.getBoolean(Keys.PREF_PRESET_DRC, false)
}
/* Loads preset Stereo Width */
fun loadPresetStereoWidth(): Float {
return sharedPreferences.getFloat(Keys.PREF_PRESET_STEREO_WIDTH, 1f)
}
/* Resets Equalizer settings to default */
fun resetEqualizer() {
sharedPreferences.edit {
putInt(Keys.PREF_EQ_LOW, 0)
putInt(Keys.PREF_EQ_MID, 0)
putInt(Keys.PREF_EQ_HIGH, 0)
}
}
} }
@@ -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
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/dialog_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_selection_title"
android:textSize="24sp"
android:textStyle="bold"
android:paddingBottom="16dp" />
<RadioGroup
android:id="@+id/preset_radio_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/radio_preset_none"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_none"
android:textSize="20sp"
android:padding="12dp"
android:focusable="true"
android:clickable="true" />
<RadioButton
android:id="@+id/radio_preset_rock"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_rock"
android:textSize="20sp"
android:padding="12dp"
android:focusable="true"
android:clickable="true" />
<RadioButton
android:id="@+id/radio_preset_pop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_pop"
android:textSize="20sp"
android:padding="12dp"
android:focusable="true"
android:clickable="true" />
<RadioButton
android:id="@+id/radio_preset_jazz"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_jazz"
android:textSize="20sp"
android:padding="12dp"
android:focusable="true"
android:clickable="true" />
<RadioButton
android:id="@+id/radio_preset_flat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_flat"
android:textSize="20sp"
android:padding="12dp"
android:focusable="true"
android:clickable="true" />
</RadioGroup>
</LinearLayout>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<FrameLayout
android:id="@+id/visualizer_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FF000000"
android:focusable="true" />
</LinearLayout>
@@ -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"
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/dialog_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_selection_title"
android:textSize="18sp"
android:textStyle="bold"
android:paddingBottom="12dp" />
<RadioGroup
android:id="@+id/preset_radio_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/radio_preset_none"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_none"
android:textSize="16sp"
android:padding="8dp"/>
<RadioButton
android:id="@+id/radio_preset_rock"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_rock"
android:textSize="16sp"
android:padding="8dp"/>
<RadioButton
android:id="@+id/radio_preset_pop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_pop"
android:textSize="16sp"
android:padding="8dp"/>
<RadioButton
android:id="@+id/radio_preset_jazz"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_jazz"
android:textSize="16sp"
android:padding="8dp"/>
<RadioButton
android:id="@+id/radio_preset_flat"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_preset_flat"
android:textSize="16sp"
android:padding="8dp"/>
</RadioGroup>
</LinearLayout>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<FrameLayout
android:id="@+id/visualizer_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FF000000" />
</LinearLayout>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/media_route_menu_item"
android:title="@string/media_route_menu_title"
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
app:showAsAction="always" />
</menu>
+19 -1
View File
@@ -19,6 +19,24 @@
<fragment <fragment
android:id="@+id/settings_destination" android:id="@+id/settings_destination"
android:name="com.michatec.radio.SettingsFragment" android:name="com.michatec.radio.SettingsFragment"
android:label="Settings" /> android:label="Settings">
<action
android:id="@+id/action_settings_to_equalizer"
app:destination="@id/equalizer_destination" />
<action
android:id="@+id/action_settings_to_visualizer"
app:destination="@id/visualizer_destination" />
</fragment>
<!-- EQUALIZER -->
<fragment
android:id="@+id/equalizer_destination"
android:name="com.michatec.radio.EqualizerFragment"
android:label="Equalizer" />
<!-- VISUALIZER -->
<fragment
android:id="@+id/visualizer_destination"
android:name="com.michatec.radio.VisualizerFragment"
android:label="Visualizer" />
</navigation> </navigation>
+30
View File
@@ -117,4 +117,34 @@
<!-- 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">Dynamikkompression</string>
<string name="pref_drc_summary">Den Dynamikbereich für eine gleichbleibende Lautstärke komprimieren.</string>
<string name="pref_eq_low_title">Equalizer: 31 Hz</string>
<string name="pref_eq_mid_title">Equalizer: 2 kHz</string>
<string name="pref_eq_high_title">Equalizer: 16 kHz</string>
<string name="pref_eq_band_1_title">Equalizer: 62 Hz</string>
<string name="pref_eq_band_2_title">Equalizer: 125 Hz</string>
<string name="pref_eq_band_3_title">Equalizer: 250 Hz</string>
<string name="pref_eq_band_4_title">Equalizer: 500 Hz</string>
<string name="pref_eq_band_5_title">Equalizer: 1 kHz</string>
<string name="pref_eq_band_6_title">Equalizer: 4 kHz</string>
<string name="pref_eq_band_7_title">Equalizer: 8 kHz</string>
<string name="pref_equalizer_title">Equalizer</string>
<string name="pref_equalizer_summary">Passen Sie die Audio-Frequenzen an.</string>
<string name="pref_equalizer_summary_off">Das Anpassen von den Audio-Frequenzen ist deaktiviert.</string>
<string name="pref_equalizer_reset_title">Equalizer zurücksetzen</string>
<string name="pref_preset_selection_title">Preset auswählen</string>
<string name="pref_preset_selection_summary">Wählen Sie ein Klangprofil</string>
<string name="pref_preset_none">Keines (Manuell)</string>
<string name="pref_preset_rock">Rock</string>
<string name="pref_preset_pop">Pop</string>
<string name="pref_preset_jazz">Jazz</string>
<string name="pref_preset_flat">Flach</string>
<string name="pref_visualizer_title">Spektrumanzeige</string>
<string name="pref_visualizer_summary">Sehe die Spektrumanzeige.</string>
</resources> </resources>
+6
View File
@@ -27,4 +27,10 @@
<!-- Don't show light status bar --> <!-- Don't show light status bar -->
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
</style> </style>
<style name="CustomCastExpandedControllerStyle" parent="CastExpandedController">
<item name="android:windowBackground">#FFFFFF</item>
<item name="castBackground">#FFFFFF</item>
<item name="castButtonColor">#FF495D92</item>
</style>
</resources> </resources>
+31
View File
@@ -63,6 +63,34 @@
<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: 31 Hz</string>
<string name="pref_eq_mid_title">Equalizer: 125 Hz</string>
<string name="pref_eq_high_title">Equalizer: 4 kHz</string>
<string name="pref_eq_band_1_title">Equalizer: 62 Hz</string>
<string name="pref_eq_band_2_title">Equalizer: 250 Hz</string>
<string name="pref_eq_band_3_title">Equalizer: 500 Hz</string>
<string name="pref_eq_band_4_title">Equalizer: 1 kHz</string>
<string name="pref_eq_band_5_title">Equalizer: 2 kHz</string>
<string name="pref_eq_band_6_title">Equalizer: 8 kHz</string>
<string name="pref_eq_band_7_title">Equalizer: 16 kHz</string>
<string name="pref_equalizer_title">Equalizer</string>
<string name="pref_equalizer_summary">Adjust audio frequencies</string>
<string name="pref_equalizer_summary_off">Adjust audio frequencies is off.</string>
<string name="pref_equalizer_reset_title">Reset Equalizer</string>
<string name="pref_preset_selection_title">Select Preset</string>
<string name="pref_preset_selection_summary">Choose an audio preset</string>
<string name="pref_preset_none">None (Manual)</string>
<string name="pref_preset_rock">Rock</string>
<string name="pref_preset_pop">Pop</string>
<string name="pref_preset_jazz">Jazz</string>
<string name="pref_preset_flat">Flat</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>
@@ -144,4 +172,7 @@
<!-- Extras --> <!-- Extras -->
<string name="loading">Loading...</string> <string name="loading">Loading...</string>
<string name="media_route_menu_title">Cast</string>
<string name="pref_visualizer_title">Spectrum Analyzer</string>
<string name="pref_visualizer_summary">Show the Spectrum Analyzer.</string>
</resources> </resources>
+6
View File
@@ -74,4 +74,10 @@
<item name="cornerSizeBottomRight">16dp</item> <item name="cornerSizeBottomRight">16dp</item>
<item name="cornerFamilyBottomRight">rounded</item> <item name="cornerFamilyBottomRight">rounded</item>
</style> </style>
<style name="CustomCastExpandedControllerStyle" parent="CastExpandedController">
<item name="android:windowBackground">#FFFFFF</item>
<item name="castBackground">#FFFFFF</item>
<item name="castButtonColor">#FF495D92</item>
</style>
</resources> </resources>
+1 -1
View File
@@ -7,5 +7,5 @@ plugins {
} }
tasks.register('clean', Delete) { tasks.register('clean', Delete) {
delete rootProject.buildDir() delete layout.buildDirectory
} }
+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" }