29 Commits

Author SHA1 Message Date
Michatec cdf7668d43 refactor(dsp): improve reverb algorithm and update audio presets 2026-04-07 16:10:50 +02:00
Michatec f755dc5173 perf(dsp): optimize audio processing and atomic memory ordering 2026-04-07 15:55:16 +02:00
Michatec 8c7a8ce7c4 feat(ui): add dedicated station search fragment for television platforms 2026-04-07 12:59:33 +02:00
Michatec d1cc340417 feat(ui): add visualizer button to collection screen 2026-04-07 11:58:23 +02:00
Michatec e0d1770a19 feat(dsp): refine audio processing and visualizer rendering 2026-04-07 11:26:14 +02:00
Michachatz 2e8cc9b243 refactor(helpers): adjust the bass boost value 2026-04-07 01:09:13 +02:00
Michatec 9e1219549e docs(readme): add Cast to Devices support to the Android TV section 2026-04-06 23:33:25 +02:00
Michatec 17ba1c268a refactor(gradle): relocate ndkVersion to top-level build script 2026-04-06 23:21:45 +02:00
Michatec b328af5c3a build(gradle): set ndk version 29.0.14206865 in gradle build and ci workflow 2026-04-06 23:20:06 +02:00
Michatec d31d476cb5 ci: add ZIPALIGN env variable to gradle-publish workflow 2026-04-06 23:15:54 +02:00
Michatec 6bb34cd707 chore(config): add .vscode configuration folder 2026-04-06 23:11:30 +02:00
Michatec 883a4443e9 refactor(dsp): make DSP thread‑safe and add EQ interpolation 2026-04-06 22:58:45 +02:00
Michatec 8ba22a4c09 feat(audio): add runtime sample rate handling and thread safety 2026-04-06 22:44:57 +02:00
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
47 changed files with 2315 additions and 149 deletions
+3 -2
View File
@@ -12,6 +12,7 @@ env:
ANDROID_HOME: /usr/local/lib/android/sdk/
APK_PATH: app/build/outputs/apk/release/Radio.apk
APKSIGNER: /usr/local/lib/android/sdk/build-tools/34.0.0/apksigner
ZIPALIGN: /usr/local/lib/android/sdk/build-tools/34.0.0/zipalign
jobs:
build:
@@ -42,7 +43,7 @@ jobs:
#if: steps.cache-android-sdk.outputs.cache-hit != 'true'
uses: android-actions/setup-android@v4
with:
packages: ''
packages: 'ndk;29.0.14206865'
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
@@ -55,7 +56,7 @@ jobs:
- name: Zipalign APK
run: |
/usr/local/lib/android/sdk/build-tools/34.0.0/zipalign -v -p 4 ${{ env.APK_PATH }} app-release-aligned.apk
${{ env.ZIPALIGN }} -v -p 4 ${{ env.APK_PATH }} app-release-aligned.apk
- name: Sign APK
env:
+2 -1
View File
@@ -6,4 +6,5 @@
/build
/captures
/gradle/gradle-daemon-jvm.properties
/.kotlin
/.kotlin
/app/.cxx/
+16
View File
@@ -0,0 +1,16 @@
{
"configurations": [
{
"name": "Android",
"includePath": [
"${workspaceFolder}/**"
],
"defines": [
"_DEBUG",
"UNICODE",
"_UNICODE"
]
}
],
"version": 4
}
+3
View File
@@ -0,0 +1,3 @@
{
"C_Cpp.default.compilerPath": ""
}
+1 -1
View File
@@ -9,7 +9,7 @@
### ️ About Radio
**Radio is an application with a minimalist approach to listening to radio over the Internet.** <br>
**Radio only offers a very basic search option, and it imports audio streaming links when you tap them in a web browser.** <br>
**Radio now also supports Android TV (Beta).** <br>
**Radio now also supports Android TV (Beta) and Cast to Devices (Beta).** <br>
**Pull request are welcome at any time.**<br>
**Radio is free software. It is released under the [GPLv3 open source license](https://opensource.org/licenses/GPL-3.0).**
+16 -2
View File
@@ -19,9 +19,14 @@ android {
applicationId 'com.michatec.radio'
minSdk 28
targetSdk 36
versionCode 144
versionName '14.4'
versionCode 145
versionName '14.5'
resourceConfigurations += ['en', 'de', 'el', 'nl', 'pl', 'ru','uk', 'ja', 'da', 'fr']
externalNativeBuild {
cmake {
cppFlags ''
}
}
}
compileOptions {
@@ -49,6 +54,13 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
ndkVersion "29.0.14206865"
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
version '3.22.1'
}
}
}
dependencies {
@@ -57,6 +69,7 @@ dependencies {
// Google Stuff //
implementation libs.material
implementation libs.gson
implementation libs.play.services.cast.framework
// AndroidX Stuff //
implementation libs.core.ktx
@@ -67,6 +80,7 @@ dependencies {
implementation libs.media3.exoplayer
implementation libs.media3.exoplayer.hls
implementation libs.media3.session
implementation libs.media3.cast
implementation libs.media3.datasource.okhttp
implementation libs.navigation.fragment.ktx
implementation libs.navigation.ui.ktx
+13 -28
View File
@@ -32,7 +32,6 @@
tools:targetApi="33">
<!-- ANDROID AUTO SUPPORT -->
<!-- https://developer.android.com/training/auto/audio/ -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
@@ -40,6 +39,17 @@
android:name="com.google.android.gms.car.notification.SmallIcon"
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 -->
<activity
android:name=".MainActivity"
@@ -48,54 +58,41 @@
android:theme="@style/SplashTheme"
android:exported="true">
<!-- react to main intents -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- react to be recognized as a music player -->
<intent-filter>
<action android:name="android.intent.action.MUSIC_PLAYER" />
<category android:name="android.intent.category.CATEGORY_APP_MUSIC" />
</intent-filter>
<!-- react to voice searches, like "Play Security Now" -->
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</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">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:host="*" tools:ignore="AppLinkUrlError" />
<data android:pathPattern=".*\\.m3u" />
<data android:pathPattern=".*\\.m3u8" />
<data android:pathPattern=".*\\.pls" />
</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">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*"
tools:ignore="AppLinkUrlError" />
<data android:host="*" tools:ignore="AppLinkUrlError" />
<data android:mimeType="audio/x-scpls" />
<data android:mimeType="audio/mpegurl" />
<data android:mimeType="audio/x-mpegurl" />
@@ -106,11 +103,9 @@
<data android:mimeType="application/octet-stream" />
</intent-filter>
<!-- react to hls playlist-links based on mimetype -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:host="*" />
<data android:mimeType="audio/x-scpls" />
@@ -122,20 +117,16 @@
<data android:mimeType="application/vnd.apple.mpegurl.audio" />
</intent-filter>
<!-- react to "start player service" intents -->
<intent-filter>
<action android:name="com.michatec.radio.action.START_PLAYER_SERVICE" />
</intent-filter>
<!-- App Shortcuts -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<!-- Player Service -->
<service
android:name=".PlayerService"
android:enabled="true"
@@ -149,8 +140,6 @@
</intent-filter>
</service>
<!-- handles completed downloads -->
<receiver
android:name=".helpers.DownloadFinishedReceiver"
android:exported="true">
@@ -159,8 +148,6 @@
</intent-filter>
</receiver>
<!-- handles media buttons -->
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true">
@@ -169,8 +156,6 @@
</intent-filter>
</receiver>
<!-- file provider -->
<provider
android:name="androidx.core.content.FileProvider"
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)
+479
View File
@@ -0,0 +1,479 @@
#include <jni.h>
#include <vector>
#include <cmath>
#include <complex>
#include <array>
#include <atomic>
#if defined(__ARM_NEON)
#include <arm_neon.h>
#endif
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
static std::atomic<float> gSampleRate(44100.0f);
static constexpr int FFT_SIZE = 2048;
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 float INTERPOLATION_SPEED = 0.1f;
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) EqBandInterpolator {
std::atomic<float> targetGain{0.0f};
std::atomic<float> currentGain{0.0f};
float a0 = 1.0f, a1 = 0.0f, a2 = 0.0f, b1 = 0.0f, b2 = 0.0f;
float z1 = 0.0f, z2 = 0.0f;
bool active = false;
inline void setTargetGain(float g) { targetGain.store(g, std::memory_order_release); }
inline void updateInterpolation() {
float target = targetGain.load(std::memory_order_acquire);
float current = currentGain.load(std::memory_order_relaxed);
float diff = target - current;
if (std::abs(diff) > 0.001f) {
currentGain.store(current + diff * INTERPOLATION_SPEED, std::memory_order_release);
}
}
inline float process(float x) {
if (!active) return x;
updateInterpolation();
float g = currentGain.load(std::memory_order_acquire);
if (std::abs(g) < 0.01f) return x;
float y = x * a0 + z1;
z1 = x * a1 + z2 - b1 * y + DENORMAL_OFFSET;
z2 = x * a2 - b2 * y;
return y;
}
inline void setCoefficients(float sr, float f, float g, float bw) {
const bool isActive = std::abs(g) > 0.1f;
active = isActive;
if (!isActive) 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 = (1.0f + alpha * A) * invA0;
a1 = (-2.0f * c) * invA0;
a2 = (1.0f - alpha * A) * invA0;
b1 = (-2.0f * c) * invA0;
b2 = (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;
std::atomic<bool> active{false};
std::atomic<float> targetGain{0.0f};
std::atomic<float> currentGain{0.0f};
inline void updateInterpolation() {
float target = targetGain.load(std::memory_order_acquire);
float current = currentGain.load(std::memory_order_relaxed);
float diff = target - current;
if (std::abs(diff) > 0.001f) {
currentGain.store(current + diff * INTERPOLATION_SPEED, std::memory_order_release);
}
}
inline float process(float x) {
if (!active.load(std::memory_order_acquire)) return x;
updateInterpolation();
float g = currentGain.load(std::memory_order_acquire);
if (std::abs(g) < 0.01f) 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 setCoefficients(float sr, float f, float g, float q){
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;
}
void applyGain(float sr) {
float g = currentGain.load(std::memory_order_acquire);
setCoefficients(sr, 150.0f, g, SQRT_2_INV);
}
};
class ReverbOptimized {
struct DelayLine {
float buffer[48000]{};
int size = 48000;
int pos = 0;
inline float read(float delaySamples) {
float readPos = static_cast<float>(pos) - delaySamples;
if (readPos < 0.0f) readPos += static_cast<float>(size);
int i1 = static_cast<int>(readPos);
int i2 = (i1 + 1) % size;
float frac = readPos - static_cast<float>(i1);
return buffer[i1] * (1.0f - frac) + buffer[i2] * frac;
}
inline void write(float x) {
buffer[pos] = x;
pos++;
if (pos >= size) pos = 0;
}
};
DelayLine delays[8];
float feedback[8] = {
0.78f, 0.80f, 0.82f, 0.84f,
0.76f, 0.79f, 0.81f, 0.83f
};
float baseDelay[8] = {
1423.0f, 1557.0f, 1617.0f, 1789.0f,
1867.0f, 1999.0f, 2137.0f, 2251.0f
};
float modPhase[8] = {};
float modSpeed[8] = {
0.10f, 0.12f, 0.09f, 0.11f,
0.13f, 0.08f, 0.14f, 0.07f
};
public:
std::atomic<float> mix{0.0f};
inline float processSample(float x) {
float m = mix.load(std::memory_order_relaxed);
if (m < 0.01f) return x;
float out = 0.0f;
#pragma GCC unroll 8
for (int i = 0; i < 8; i++) {
modPhase[i] += modSpeed[i];
if (modPhase[i] > 2.0f * static_cast<float>(M_PI)) modPhase[i] -= 2.0f * static_cast<float>(M_PI);
float mod = sinf(modPhase[i]) * 5.0f;
float delayTime = baseDelay[i] + mod;
float delayed = delays[i].read(delayTime);
float input = x + delayed * feedback[i] + DENORMAL_OFFSET;
delays[i].write(input);
out += delayed;
}
return x * (1.0f - m) + (out * 0.125f) * m;
}
inline void processBlock(float* __restrict__ left, float* __restrict__ right, int count) {
float m = mix.load(std::memory_order_relaxed);
if (m < 0.01f) return;
for (int i = 0; i < count; i++) {
float l = processSample(left[i]);
float r = processSample(right[i]);
float wetL = l * 0.7f + r * 0.3f;
float wetR = r * 0.7f + l * 0.3f;
left[i] = wetL;
right[i] = wetR;
}
}
};
class CompressorOptimized {
public:
std::atomic<float> threshold{0.3f}, ratio{4.0f}, attack{0.08f}, release{0.8f};
std::atomic<float> sampleRate{44100.0f};
std::atomic<bool> enabled{false};
private:
float envelopeL = 0.0f, envelopeR = 0.0f;
float attackCoef = 0.0f, releaseCoef = 0.0f;
public:
inline void updateCoefficients() {
float a = attack.load(std::memory_order_relaxed);
float r = release.load(std::memory_order_relaxed);
float sr = sampleRate.load(std::memory_order_relaxed);
attackCoef = expf(-1.0f / (a * sr));
releaseCoef = expf(-1.0f / (r * sr));
}
inline void processBlock(float* __restrict__ buffer, int count, float& envelope) {
updateCoefficients();
float th = threshold.load(std::memory_order_acquire);
float rt = ratio.load(std::memory_order_acquire);
for(int i=0; i<count; i++){
float absInput = fabsf(buffer[i]);
envelope = (absInput > envelope) ? attackCoef*envelope + (1.0f-attackCoef)*absInput : releaseCoef*envelope + (1.0f-releaseCoef)*absInput;
float gain = (envelope>th)? (th + (envelope-th)/rt)/(envelope+1e-9f) : 1.0f;
buffer[i]*=gain;
}
}
inline void process(float* __restrict__ left, float* __restrict__ right, int count) {
if (!enabled.load(std::memory_order_acquire)) return;
processBlock(left, count, envelopeL);
processBlock(right, count, envelopeR);
}
};
static std::atomic<bool> gEqEnabled{false};
static std::atomic<float> gStereoWidth{1.0f};
alignas(16) std::array<float, 4096> gLeftBuf, gRightBuf;
alignas(16) std::array<float, 256> gFFTData;
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 void applyHannWindowToReal(std::complex<float>* __restrict__ data, int size) {
const auto fSizeMinus1 = static_cast<float>(size - 1);
for (int i = 0; i < size; i++) {
float window = 0.5f * (1.0f - cosf(2.0f * static_cast<float>(M_PI) * static_cast<float>(i) / fSizeMinus1));
data[i] = std::complex<float>(data[i].real() * window, data[i].imag());
}
}
inline float fastSoftClip(float x) {
float ax = fabsf(x);
float sign = x > 0.0f ? 1.0f : -1.0f;
if (ax > 1.0f) return sign;
return x * (1.5f - 0.5f * x * x);
}
static EqBandInterpolator gEqL[NUM_EQ_BANDS];
static EqBandInterpolator gEqR[NUM_EQ_BANDS];
static BassFilter gBassL, gBassR;
static CompressorOptimized gCompressor;
static ReverbOptimized gReverbL, gReverbR;
static std::array<std::complex<float>, FFT_SIZE> gFFTWork;
static int gEqUpdateCounter = 0;
inline void updateAllEqBands() {
float sr = gSampleRate.load(std::memory_order_acquire);
for (int b = 0; b < NUM_EQ_BANDS; b++) {
float g = gEqL[b].targetGain.load(std::memory_order_acquire);
gEqL[b].setCoefficients(sr, EQ_FREQUENCIES[static_cast<size_t>(b)], g, 1.0f);
gEqR[b].setCoefficients(sr, EQ_FREQUENCIES[static_cast<size_t>(b)], g, 1.0f);
}
bool anyActive = false;
for (auto const& band : gEqL) {
if (std::abs(band.targetGain.load(std::memory_order_acquire)) > 0.1f) {
anyActive = true;
break;
}
}
gEqEnabled.store(anyActive, std::memory_order_release);
}
extern "C" {
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setSampleRate(JNIEnv*, jobject, jfloat sr) {
gSampleRate.store(sr, std::memory_order_release);
gCompressor.sampleRate.store(sr, std::memory_order_release);
gBassL.applyGain(sr);
gBassR.applyGain(sr);
gEqUpdateCounter = 1;
}
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setDrcEnabled(JNIEnv*, jobject, jboolean e) {
gCompressor.enabled.store(e == JNI_TRUE, std::memory_order_release);
}
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setReverbMix(JNIEnv*, jobject, jfloat m) {
gReverbL.mix.store(m, std::memory_order_release);
gReverbR.mix.store(m, std::memory_order_release);
}
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setEqFull(JNIEnv* env, jobject thiz, jfloatArray gains) {
if (!gains) return;
jsize len = env->GetArrayLength(gains);
int bandsToUpdate = std::min(static_cast<int>(len), NUM_EQ_BANDS);
jfloat* gainsPtr = env->GetFloatArrayElements(gains, nullptr);
if (!gainsPtr) return;
for (int b = 0; b < bandsToUpdate; b++) {
gEqL[b].setTargetGain(gainsPtr[b]);
gEqR[b].setTargetGain(gainsPtr[b]);
}
gEqUpdateCounter = 1;
env->ReleaseFloatArrayElements(gains, gainsPtr, JNI_ABORT);
}
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setBassBoost(JNIEnv*, jobject, jfloat g) {
float scaledGain = g * 4.0f;
gBassL.targetGain.store(scaledGain, std::memory_order_release);
gBassR.targetGain.store(scaledGain, std::memory_order_release);
float sr = gSampleRate.load(std::memory_order_acquire);
gBassL.applyGain(sr);
gBassR.applyGain(sr);
if (std::abs(g) > 0.01f) {
gBassL.active.store(true, std::memory_order_release);
gBassR.active.store(true, std::memory_order_release);
} else {
gBassL.active.store(false, std::memory_order_release);
gBassR.active.store(false, std::memory_order_release);
}
}
JNIEXPORT void JNICALL Java_com_michatec_radio_helpers_NativeAudioProcessor_setStereoWidth(JNIEnv*, jobject, jfloat w) {
gStereoWidth.store(fmaxf(0.0f, fminf(w, 2.0f)), std::memory_order_release);
}
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;
}
inline void computeLogarithmicFFT(float* output, const std::complex<float>* input, int inputSize) {
float sr = gSampleRate.load(std::memory_order_acquire);
float binWidth = sr / (2.0f * static_cast<float>(inputSize));
constexpr int NUM_BANDS = 256;
constexpr float MIN_FREQ = 20.0f;
constexpr float MAX_FREQ = 20000.0f;
float logMin = logf(MIN_FREQ);
float logMax = logf(MAX_FREQ);
float logRange = logMax - logMin;
for (int b = 0; b < NUM_BANDS; b++) {
float f1 = expf(logMin + (logRange * static_cast<float>(b) / static_cast<float>(NUM_BANDS)));
float f2 = expf(logMin + (logRange * static_cast<float>(b + 1) / static_cast<float>(NUM_BANDS)));
int idx1 = static_cast<int>(f1 / binWidth);
int idx2 = static_cast<int>(f2 / binWidth);
idx1 = std::max(0, std::min(idx1, inputSize - 1));
idx2 = std::max(0, std::min(idx2, inputSize - 1));
float sum = 0.0f;
int count = idx2 - idx1 + 1;
for (int i = idx1; i <= idx2 && i < inputSize; i++) {
sum += std::abs(input[i]);
}
float avg = (count > 0) ? sum / static_cast<float>(count) : 0.0f;
output[b] = avg * 0.5f;
}
}
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;
if (gEqUpdateCounter > 0) {
updateAllEqBands();
gEqUpdateCounter--;
}
for (int i = 0; i < numFrames; i++) {
gLeftBuf[static_cast<size_t>(i)] = static_cast<float>(buffer[i * 2]) * INV_32768;
gRightBuf[static_cast<size_t>(i)] = static_cast<float>(buffer[i * 2 + 1]) * INV_32768;
}
bool eqEnabled = gEqEnabled.load(std::memory_order_relaxed);
if (eqEnabled) {
for (int i = 0; i < numFrames; i++) {
float xL = gLeftBuf[static_cast<size_t>(i)];
float xR = gRightBuf[static_cast<size_t>(i)];
for (int b = 0; b < NUM_EQ_BANDS; b++) {
float g = gEqL[b].currentGain.load(std::memory_order_relaxed);
if (std::abs(g) < 0.01f) continue;
xL = gEqL[b].process(xL);
xR = gEqR[b].process(xR);
}
gLeftBuf[static_cast<size_t>(i)] = xL;
gRightBuf[static_cast<size_t>(i)] = xR;
}
}
for(int i = 0; i < numFrames; i++) {
gLeftBuf[static_cast<size_t>(i)] = gBassL.process(gLeftBuf[static_cast<size_t>(i)]);
gRightBuf[static_cast<size_t>(i)] = gBassR.process(gRightBuf[static_cast<size_t>(i)]);
}
gReverbL.processBlock(gLeftBuf.data(), gRightBuf.data(), numFrames);
float stereoWidth = gStereoWidth.load(std::memory_order_relaxed);
if (stereoWidth != 1.0f) {
float halfWidth = stereoWidth * 0.5f;
for (int j = 0; j < numFrames; j++) {
float mid = (gLeftBuf[static_cast<size_t>(j)] + gRightBuf[static_cast<size_t>(j)]) * 0.5f;
float side = (gLeftBuf[static_cast<size_t>(j)] - gRightBuf[static_cast<size_t>(j)]) * halfWidth;
gLeftBuf[static_cast<size_t>(j)] = mid + side;
gRightBuf[static_cast<size_t>(j)] = mid - side;
}
}
gCompressor.process(gLeftBuf.data(), gRightBuf.data(), numFrames);
if (numFrames >= FFT_SIZE) {
for (int k = 0; k < FFT_SIZE; k++) {
gFFTWork[static_cast<size_t>(k)] = std::complex<float>(gLeftBuf[static_cast<size_t>(k)], 0.0f);
}
} else {
for (int k = 0; k < numFrames; k++) {
gFFTWork[static_cast<size_t>(k)] = std::complex<float>(gLeftBuf[static_cast<size_t>(k)], 0.0f);
}
for (int k = numFrames; k < FFT_SIZE; k++) {
gFFTWork[static_cast<size_t>(k)] = std::complex<float>(0.0f, 0.0f);
}
}
applyHannWindowToReal(gFFTWork.data(), FFT_SIZE);
fastFFT(gFFTWork.data(), FFT_SIZE);
computeLogarithmicFFT(gFFTData.data(), gFFTWork.data(), FFT_SIZE / 2);
for (int k = 0; k < numFrames; k++) {
buffer[k * 2] = static_cast<jshort>(fastSoftClip(gLeftBuf[static_cast<size_t>(k)]) * 32767.0f);
buffer[k * 2 + 1] = static_cast<jshort>(fastSoftClip(gRightBuf[static_cast<size_t>(k)]) * 32767.0f);
}
}
}
+86
View File
@@ -0,0 +1,86 @@
#include <jni.h>
#include <android/native_window_jni.h>
#include <android/native_window.h>
#include <android/log.h>
#include <vector>
#include <algorithm>
#include <cmath>
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++) {
uint32_t* row = pixels + (y * buffer.stride);
for (int x = 0; x < buffer.width; x++) {
row[x] = 0xFF121212;
}
}
// Draw bars - fewer bins = thicker bars
int displayBins = 40;
float barWidth = static_cast<float>(buffer.width) / static_cast<float>(displayBins);
int padding = static_cast<int>(barWidth * 0.2f);
if (padding < 1) padding = 1;
for (int i = 0; i < displayBins; i++) {
// Map display bin to data index
int dataIdx = (i * len) / displayBins;
float val = body[dataIdx];
// Use square root to compress the range (so peaks don't hit the top too easily)
// and a lower multiplier (0.4f) to reduce overall height
float scaledVal = sqrtf(val) * 0.5f;
int barHeight = static_cast<int>(scaledVal * static_cast<float>(buffer.height));
// Cap height at 75% to leave some room at the top
int maxH = static_cast<int>(static_cast<float>(buffer.height) * 0.75f);
if (barHeight > maxH) barHeight = maxH;
if (barHeight < 4) barHeight = 4; // Minimal visible line
int startX = static_cast<int>(static_cast<float>(i) * barWidth);
int endX = static_cast<int>(static_cast<float>(i + 1) * barWidth);
int drawStartX = startX + padding;
int drawEndX = endX - padding;
if (drawEndX <= drawStartX) drawEndX = drawStartX + 1;
int barBottom = buffer.height - 4; // Bottom margin
int barTop = barBottom - barHeight;
for (int x = drawStartX; x < drawEndX; x++) {
if (x < 0 || x >= buffer.width) continue;
for (int y = barTop; y < barBottom; y++) {
if (y < 0 || y >= buffer.height) continue;
// Using the same color, but now height is controlled
pixels[y * buffer.stride + x] = 0xFFC5DA03;
}
}
}
ANativeWindow_unlockAndPost(window);
}
env->ReleaseFloatArrayElements(data, body, JNI_ABORT);
ANativeWindow_release(window);
}
} // extern "C"
@@ -0,0 +1,214 @@
package com.michatec.radio
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import androidx.appcompat.widget.SearchView
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import com.michatec.radio.collection.CollectionViewModel
import com.michatec.radio.core.Station
import com.michatec.radio.helpers.CollectionHelper
import com.michatec.radio.helpers.NetworkHelper
import com.michatec.radio.search.DirectInputCheck
import com.michatec.radio.search.RadioBrowserResult
import com.michatec.radio.search.RadioBrowserSearch
import com.michatec.radio.search.SearchResultAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AddStationFragment : Fragment(),
SearchResultAdapter.SearchResultAdapterListener,
RadioBrowserSearch.RadioBrowserSearchListener,
DirectInputCheck.DirectInputCheckListener {
private lateinit var collectionViewModel: CollectionViewModel
private lateinit var stationSearchBoxView: SearchView
private lateinit var searchRequestProgressIndicator: ProgressBar
private lateinit var noSearchResultsTextView: MaterialTextView
private lateinit var stationSearchResultList: RecyclerView
private lateinit var positiveButton: Button
private lateinit var negativeButton: Button
private lateinit var searchResultAdapter: SearchResultAdapter
private lateinit var radioBrowserSearch: RadioBrowserSearch
private lateinit var directInputCheck: DirectInputCheck
private var station: Station = Station()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
// We reuse the dialog layout as it's already optimized for TV in layout-television
val view = inflater.inflate(R.layout.dialog_find_station, container, false)
collectionViewModel = ViewModelProvider(requireActivity())[CollectionViewModel::class.java]
radioBrowserSearch = RadioBrowserSearch(this)
directInputCheck = DirectInputCheck(this)
stationSearchBoxView = view.findViewById(R.id.station_search_box_view)
searchRequestProgressIndicator = view.findViewById(R.id.search_request_progress_indicator)
stationSearchResultList = view.findViewById(R.id.station_search_result_list)
noSearchResultsTextView = view.findViewById(R.id.no_results_text_view)
positiveButton = view.findViewById(R.id.dialog_positive_button)
negativeButton = view.findViewById(R.id.dialog_negative_button)
setupRecyclerView()
setupSearchView()
positiveButton.setOnClickListener {
addStationAndExit()
}
negativeButton.setOnClickListener {
searchResultAdapter.stopPrePlayback()
findNavController().navigateUp()
}
stationSearchBoxView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextChange(query: String): Boolean {
handleSearch(query)
return true
}
override fun onQueryTextSubmit(query: String): Boolean {
handleSearch(query)
return true
}
})
return view
}
override fun onDestroy() {
super.onDestroy()
// Stop playback when fragment is destroyed (e.g. via back button)
if (this::searchResultAdapter.isInitialized) {
searchResultAdapter.stopPrePlayback()
}
}
private fun setupRecyclerView() {
searchResultAdapter = SearchResultAdapter(this, listOf())
stationSearchResultList.adapter = searchResultAdapter
stationSearchResultList.layoutManager = LinearLayoutManager(context)
stationSearchResultList.itemAnimator = DefaultItemAnimator()
}
private fun setupSearchView() {
// TV specific: ensure keyboard opens when search view gets focus
stationSearchBoxView.setOnQueryTextFocusChangeListener { v, hasFocus ->
if (hasFocus) {
// Find the internal EditText of the SearchView
val searchEditText = v.findViewById<EditText>(androidx.appcompat.R.id.search_src_text)
if (searchEditText != null) {
searchEditText.requestFocus()
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(searchEditText, InputMethodManager.SHOW_IMPLICIT)
}
}
}
// Make the SearchView always expanded and ready for input
stationSearchBoxView.isIconified = false
}
private fun handleSearch(query: String) {
if (query.isEmpty()) {
resetLayout(true)
return
}
showProgressIndicator()
if (query.startsWith("http")) {
directInputCheck.checkStationAddress(requireContext(), query)
} else {
radioBrowserSearch.searchStation(requireContext(), query, Keys.SEARCH_TYPE_BY_KEYWORD)
}
}
private fun addStationAndExit() {
searchResultAdapter.stopPrePlayback()
val currentCollection = collectionViewModel.collectionLiveData.value ?: return
if (station.streamContent.isNotEmpty() && station.streamContent != Keys.MIME_TYPE_UNSUPPORTED) {
CollectionHelper.addStation(requireContext(), currentCollection, station)
findNavController().navigateUp()
} else {
CoroutineScope(IO).launch {
val contentType = NetworkHelper.detectContentType(station.getStreamUri())
station.streamContent = contentType.type
withContext(Main) {
CollectionHelper.addStation(requireContext(), currentCollection, station)
findNavController().navigateUp()
}
}
}
}
override fun onSearchResultTapped(result: Station) {
station = result
activateAddButton()
}
override fun activateAddButton() {
positiveButton.isEnabled = true
}
override fun deactivateAddButton() {
positiveButton.isEnabled = false
}
@SuppressLint("NotifyDataSetChanged")
override fun onRadioBrowserSearchResults(results: Array<RadioBrowserResult>) {
if (results.isNotEmpty()) {
searchResultAdapter.searchResults = results.map { it.toStation() }
searchResultAdapter.notifyDataSetChanged()
resetLayout(false)
} else {
showNoResultsError()
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onDirectInputCheck(stationList: MutableList<Station>) {
if (stationList.isNotEmpty()) {
searchResultAdapter.searchResults = stationList
searchResultAdapter.notifyDataSetChanged()
resetLayout(false)
} else {
showNoResultsError()
}
}
private fun resetLayout(clear: Boolean) {
positiveButton.isEnabled = false
searchRequestProgressIndicator.isGone = true
noSearchResultsTextView.isGone = true
if (clear) searchResultAdapter.resetSelection(true)
}
private fun showProgressIndicator() {
searchRequestProgressIndicator.isVisible = true
noSearchResultsTextView.isGone = true
}
private fun showNoResultsError() {
searchRequestProgressIndicator.isGone = true
noSearchResultsTextView.isVisible = true
}
}
@@ -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_SLEEP_TIMER_REMAINING: String = "SLEEP_TIMER_REMAINING"
const val EXTRA_METADATA_HISTORY: String = "METADATA_HISTORY"
const val EXTRA_VISUALIZER_DATA: String = "VISUALIZER_DATA"
// arguments
const val ARG_UPDATE_COLLECTION: String = "ArgUpdateCollection"
@@ -43,6 +44,7 @@ object Keys {
const val CMD_PLAY_STREAM: String = "PLAY_STREAM"
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_GET_VISUALIZER_DATA: String = "GET_VISUALIZER_DATA"
// preferences
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_EDIT_STATIONS: String = "EDIT_STATIONS"
const val PREF_EDIT_STREAMS_URIS: String = "EDIT_STREAMS_URIS"
const val PREF_BASS_BOOST: String = "BASS_BOOST"
const val PREF_REVERB: String = "REVERB"
const val PREF_DRC: String = "DRC"
const val PREF_EQ_LOW: String = "EQ_LOW"
const val PREF_EQ_MID: String = "EQ_MID"
const val PREF_EQ_HIGH: String = "EQ_HIGH"
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
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_UPDATE_STATION_IMAGES: Int = 4
const val DIALOG_RESTORE_COLLECTION: Int = 5
const val DIALOG_THEME_SELECTION: Int = 6
// dialog results
const val DIALOG_EMPTY_PAYLOAD_STRING: String = ""
@@ -27,13 +27,12 @@ class MainActivity : AppCompatActivity() {
/* Main class variables */
private lateinit var appBarConfiguration: AppBarConfiguration
/* Overrides onCreate from AppCompatActivity */
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
setTheme(R.style.AppTheme)
super.onCreate(savedInstanceState)
// Free Android
FreeDroidWarn.showWarningOnUpgrade(this, BuildConfig.VERSION_CODE)
@@ -46,7 +45,8 @@ class MainActivity : AppCompatActivity() {
// set up action bar
setSupportActionBar(findViewById(R.id.main_toolbar))
val toolbar: Toolbar = findViewById(R.id.main_toolbar)
val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment
val navController = navHostFragment.navController
appBarConfiguration = AppBarConfiguration(navController.graph)
NavigationUI.setupWithNavController(toolbar, navController, appBarConfiguration)
@@ -69,9 +69,7 @@ class MainActivity : AppCompatActivity() {
private fun hideLoadingOverlay() {
findViewById<View>(R.id.loading_layout)?.let { overlay ->
if (overlay.isVisible) {
overlay.animate()
.alpha(0f)
.setDuration(500)
overlay.animate().alpha(0f).setDuration(500)
.withEndAction { overlay.visibility = View.GONE }
}
}
@@ -90,7 +88,8 @@ class MainActivity : AppCompatActivity() {
/* Overrides onSupportNavigateUp from AppCompatActivity */
override fun onSupportNavigateUp(): Boolean {
val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.main_host_container) as NavHostFragment
val navController = navHostFragment.navController
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
}
@@ -34,6 +34,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionResult
import androidx.media3.session.SessionToken
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.android.volley.Request
@@ -342,7 +343,14 @@ class PlayerFragment : Fragment(),
/* Overrides onAddNewButtonTapped from CollectionAdapterListener */
override fun onAddNewButtonTapped() {
FindStationDialog(activity as Activity, this as FindStationDialog.FindStationDialogListener).show()
// stop playback when adding a new station
controller?.stop()
if (activity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_LEANBACK) == true) {
findNavController().navigate(R.id.action_map_fragment_to_player_to_add_station)
} else {
FindStationDialog(activity as Activity, this as FindStationDialog.FindStationDialogListener).show()
}
}
@@ -402,6 +410,7 @@ class PlayerFragment : Fragment(),
/* Releases MediaController */
private fun releaseController() {
controller?.removeListener(playerListener)
MediaController.releaseFuture(controllerFuture)
}
@@ -624,6 +633,8 @@ class PlayerFragment : Fragment(),
}
withContext(Main) {
if (stationList.isNotEmpty()) {
// stop playback when adding a new station via intent
controller?.stop()
AddStationDialog(activity as Activity, stationList, this@PlayerFragment as AddStationDialog.AddStationDialogListener).show()
} else {
// invalid address
@@ -809,7 +820,7 @@ class PlayerFragment : Fragment(),
/*
* Check for update on github
* Check for update on GitHub
*/
private fun checkForUpdates() {
val url = getString(R.string.snackbar_github_update_check_url)
@@ -10,12 +10,16 @@ import android.os.CountDownTimer
import android.util.Log
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
import androidx.media3.cast.CastPlayer
import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.HttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.upstream.DefaultAllocator
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
@@ -25,10 +29,7 @@ import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.michatec.radio.core.Collection
import com.michatec.radio.helpers.AudioHelper
import com.michatec.radio.helpers.CollectionHelper
import com.michatec.radio.helpers.FileHelper
import com.michatec.radio.helpers.PreferencesHelper
import com.michatec.radio.helpers.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.Main
import java.util.*
@@ -38,13 +39,15 @@ import java.util.*
* PlayerService class
*/
@UnstableApi
class PlayerService : MediaLibraryService() {
class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenceChangeListener {
/* Define log tag */
private val TAG: String = PlayerService::class.java.simpleName
/* Main class variables */
private lateinit var player: Player
private lateinit var exoPlayer: ExoPlayer
private lateinit var castPlayer: CastPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var sleepTimer: CountDownTimer
var sleepTimerTimeRemaining: Long = 0L
@@ -56,6 +59,9 @@ class PlayerService : MediaLibraryService() {
private var playbackRestartCounter: Int = 0
private var playLastStation: Boolean = false
private var manuallyCancelledSleepTimer = false
// Native Audio Processor instance
private val nativeAudioProcessor = NativeAudioProcessor()
/* Overrides onCreate from Service */
@@ -76,6 +82,11 @@ class PlayerService : MediaLibraryService() {
setMediaNotificationProvider(notificationProvider)
// fetch the metadata history
metadataHistory = PreferencesHelper.loadMetadataHistory()
// register preference change listener
PreferencesHelper.registerPreferenceChangeListener(this)
// apply initial audio effects
applyAudioEffects()
}
@@ -85,7 +96,11 @@ class PlayerService : MediaLibraryService() {
PreferencesHelper.saveIsPlaying(false)
player.removeListener(playerListener)
player.release()
exoPlayer.release()
castPlayer.release()
mediaLibrarySession.release()
// unregister preference change listener
PreferencesHelper.unregisterPreferenceChangeListener(this)
super.onDestroy()
}
@@ -106,8 +121,26 @@ class PlayerService : MediaLibraryService() {
/* Initializes the ExoPlayer */
private fun initializePlayer() {
val exoPlayer: ExoPlayer = ExoPlayer.Builder(this).apply {
setAudioAttributes(AudioAttributes.DEFAULT, true)
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
// Create a RenderersFactory that injects the NativeAudioProcessor
val renderersFactory = object : DefaultRenderersFactory(this) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean
): AudioSink? {
return DefaultAudioSink.Builder(context)
.setAudioProcessors(arrayOf(nativeAudioProcessor))
.build()
}
}
exoPlayer = ExoPlayer.Builder(this, renderersFactory).apply {
setAudioAttributes(audioAttributes, true)
setHandleAudioBecomingNoisy(true)
setLoadControl(createDefaultLoadControl(bufferSizeMultiplier))
setMediaSourceFactory(
@@ -119,8 +152,12 @@ class PlayerService : MediaLibraryService() {
exoPlayer.addAnalyticsListener(analyticsListener)
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
player = object : ForwardingPlayer(exoPlayer) {
// Initialize CastPlayer
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 {
return super.getAvailableCommands().buildUpon().add(COMMAND_SEEK_TO_NEXT)
.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
}
}
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
*/
@@ -268,13 +369,13 @@ class PlayerService : MediaLibraryService() {
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
// add custom commands
val connectionResult: MediaSession.ConnectionResult = super.onConnect(session, controller)
val builder: SessionCommands.Builder = connectionResult.availableSessionCommands.buildUpon()
builder.add(SessionCommand(Keys.CMD_START_SLEEP_TIMER, Bundle.EMPTY))
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_METADATA_HISTORY, Bundle.EMPTY))
builder.add(SessionCommand(Keys.CMD_GET_VISUALIZER_DATA, Bundle.EMPTY))
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)
}
@@ -426,35 +540,23 @@ class PlayerService : MediaLibraryService() {
isPlaying
)
if (isPlaying) {
// playback is active
} else {
if (!isPlaying) {
// cancel sleep timer
cancelSleepTimer()
// reset metadata
updateMetadata()
// playback is not active
// 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.
// Check playback state to decide whether to stop the service
when (player.playbackState) {
// player is able to immediately play from its current position
Player.STATE_ENDED, Player.STATE_IDLE -> {
stopSelf()
}
Player.STATE_READY -> {
// Playback is paused. For radio, we can stop the service to remove the notification.
stopSelf()
}
// buffering - data needs to be loaded
Player.STATE_BUFFERING -> {
stopSelf()
}
// player finished playing all media
Player.STATE_ENDED -> {
stopSelf()
}
// initial state or player is stopped or playback failed
Player.STATE_IDLE -> {
stopSelf()
// DO NOT stop the service while buffering (especially important for Cast)
}
}
}
@@ -463,13 +565,9 @@ class PlayerService : MediaLibraryService() {
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason)
if (!playWhenReady) {
when (reason) {
Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM -> {
stopSelf()
}
else -> {
stopSelf()
}
// Only stop if not buffering and not ready to play (i.e. truly stopped/paused)
if (player.playbackState != Player.STATE_BUFFERING) {
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() {
override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
@@ -6,7 +6,6 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.View
@@ -18,6 +17,7 @@ import androidx.navigation.fragment.findNavController
import androidx.preference.*
import com.google.android.material.snackbar.Snackbar
import com.michatec.radio.dialogs.ErrorDialog
import com.michatec.radio.dialogs.PresetSelectionDialog
import com.michatec.radio.dialogs.ThemeSelectionDialog
import com.michatec.radio.dialogs.YesNoDialog
import com.michatec.radio.helpers.*
@@ -31,7 +31,7 @@ import java.util.*
/*
* SettingsFragment class
*/
class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListener, ThemeSelectionDialog.ThemeSelectionDialogListener {
class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogListener, ThemeSelectionDialog.ThemeSelectionDialogListener, PresetSelectionDialog.PresetSelectionDialogListener {
/* Define log tag */
@@ -53,6 +53,9 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
val context = preferenceManager.context
val screen = preferenceManager.createPreferenceScreen(context)
// Load current preset once
val currentPreset = PreferencesHelper.loadSelectedPreset()
// set up "App Theme" preference
val preferenceThemeSelection = Preference(activity as Context)
preferenceThemeSelection.title = getString(R.string.pref_theme_selection_title)
@@ -186,6 +189,76 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
return@setOnPreferenceChangeListener true
}
// set up "Bass Boost" preference
val preferenceBassBoost = MarqueeSwitchPreference(context)
preferenceBassBoost.title = getString(R.string.pref_bass_boost_title)
preferenceBassBoost.setIcon(R.drawable.ic_music_note_24dp)
preferenceBassBoost.key = Keys.PREF_BASS_BOOST
preferenceBassBoost.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
val preferenceAppVersion = Preference(context)
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 cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
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
}
@@ -238,51 +308,56 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
// set preference categories
val preferenceCategoryGeneral = PreferenceCategory(activity as Context)
preferenceCategoryGeneral.title = getString(R.string.pref_general_title)
preferenceCategoryGeneral.contains(preferenceThemeSelection)
val preferenceCategoryAudioEffects = PreferenceCategory(context)
preferenceCategoryAudioEffects.title = getString(R.string.pref_audio_effects_title)
val preferenceCategoryMaintenance = PreferenceCategory(activity as Context)
preferenceCategoryMaintenance.title = getString(R.string.pref_maintenance_title)
preferenceCategoryMaintenance.contains(preferenceUpdateStationImages)
preferenceCategoryMaintenance.contains(preferenceUpdateCollection)
val preferenceCategoryImportExport = PreferenceCategory(activity as Context)
preferenceCategoryImportExport.title = getString(R.string.pref_backup_import_export_title)
preferenceCategoryImportExport.contains(preferenceM3uExport)
preferenceCategoryImportExport.contains(preferencePlsExport)
preferenceCategoryImportExport.contains(preferenceBackupCollection)
preferenceCategoryImportExport.contains(preferenceRestoreCollection)
val preferenceCategoryAdvanced = PreferenceCategory(activity as Context)
preferenceCategoryAdvanced.title = getString(R.string.pref_advanced_title)
preferenceCategoryAdvanced.contains(preferenceBufferSize)
preferenceCategoryAdvanced.contains(preferenceEnableEditingGeneral)
preferenceCategoryAdvanced.contains(preferenceEnableEditingStreamUri)
val preferenceCategoryLinks = PreferenceCategory(context)
preferenceCategoryLinks.title = getString(R.string.pref_links_title)
preferenceCategoryLinks.contains(preferenceAppVersion)
preferenceCategoryLinks.contains(preferenceGitHub)
// setup preference screen
screen.addPreference(preferenceAppVersion)
screen.addPreference(preferenceLicense)
screen.addPreference(preferenceCategoryGeneral)
screen.addPreference(preferenceThemeSelection)
preferenceCategoryGeneral.addPreference(preferenceThemeSelection)
screen.addPreference(preferenceCategoryAudioEffects)
preferenceCategoryAudioEffects.addPreference(preferenceBassBoost)
preferenceCategoryAudioEffects.addPreference(preferenceReverb)
preferenceCategoryAudioEffects.addPreference(preferenceDrc)
preferenceCategoryAudioEffects.addPreference(preferencePresetSelection)
preferenceCategoryAudioEffects.addPreference(preferenceEqualizer)
preferenceCategoryAudioEffects.addPreference(preferenceVisualizer)
screen.addPreference(preferenceCategoryMaintenance)
screen.addPreference(preferenceUpdateStationImages)
screen.addPreference(preferenceUpdateCollection)
preferenceCategoryMaintenance.addPreference(preferenceUpdateStationImages)
preferenceCategoryMaintenance.addPreference(preferenceUpdateCollection)
screen.addPreference(preferenceCategoryImportExport)
screen.addPreference(preferenceM3uExport)
screen.addPreference(preferencePlsExport)
screen.addPreference(preferenceBackupCollection)
screen.addPreference(preferenceRestoreCollection)
preferenceCategoryImportExport.addPreference(preferenceM3uExport)
preferenceCategoryImportExport.addPreference(preferencePlsExport)
preferenceCategoryImportExport.addPreference(preferenceBackupCollection)
preferenceCategoryImportExport.addPreference(preferenceRestoreCollection)
screen.addPreference(preferenceCategoryAdvanced)
screen.addPreference(preferenceBufferSize)
screen.addPreference(preferenceEnableEditingGeneral)
screen.addPreference(preferenceEnableEditingStreamUri)
preferenceCategoryAdvanced.addPreference(preferenceBufferSize)
preferenceCategoryAdvanced.addPreference(preferenceEnableEditingGeneral)
preferenceCategoryAdvanced.addPreference(preferenceEnableEditingStreamUri)
screen.addPreference(preferenceCategoryLinks)
screen.addPreference(preferenceGitHub)
preferenceCategoryLinks.addPreference(preferenceGitHub)
preferenceCategoryLinks.addPreference(preferenceLicense)
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 */
override fun onYesNoDialog(
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 */
private fun openBackupCollectionDialog() {
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")
}
}
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)
}
}
@@ -169,6 +169,9 @@ class CollectionAdapter(
addNewViewHolder.settingsButtonView.setOnClickListener {
it.findNavController().navigate(R.id.settings_destination)
}
addNewViewHolder.visualizerButtonView.setOnClickListener {
it.findNavController().navigate(R.id.visualizer_destination)
}
}
// CASE STATION CARD
is StationViewHolder -> {
@@ -187,6 +190,8 @@ class CollectionAdapter(
setPlaybackProgress(stationViewHolder, station)
setDownloadProgress(stationViewHolder, station)
stationViewHolder.playButtonView.isGone = true
// highlight if reordering
if (reorderStationUuid == station.uuid) {
stationViewHolder.stationCardView.setStrokeColor(
@@ -754,6 +759,8 @@ class CollectionAdapter(
listItemAddNewLayout.findViewById(R.id.card_add_new_station)
val settingsButtonView: ExtendedFloatingActionButton =
listItemAddNewLayout.findViewById(R.id.card_settings)
val visualizerButtonView: ExtendedFloatingActionButton =
listItemAddNewLayout.findViewById(R.id.card_visualizer)
}
/*
* End of inner class
@@ -772,7 +779,6 @@ class CollectionAdapter(
val bufferingProgress: ProgressBar = stationCardLayout.findViewById(R.id.buffering_progress)
val downloadProgress: ProgressBar = stationCardLayout.findViewById(R.id.download_progress)
// val menuButtonView: ImageView = stationCardLayout.findViewById(R.id.menu_button)
val playButtonView: ImageView = stationCardLayout.findViewById(R.id.playback_button)
val editViews: Group = stationCardLayout.findViewById(R.id.default_edit_views)
val stationImageChangeView: ImageView =
@@ -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> {
return sendCustomCommand(
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 */
fun MediaController.play(context: Context, station: Station) {
@@ -638,18 +638,23 @@ object CollectionHelper {
}.build()
// build MediaMetadata
val mediaMetadata = MediaMetadata.Builder().apply {
setTitle(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 */
if (station.image.isNotEmpty() && station.image.startsWith("file://")) {
//setArtworkUri(station.image.toUri())
setArtworkData(station.image.toUri().toFile().readBytes(), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else {
//setArtworkUri(Uri.parse(Keys.LOCATION_RESOURCES + R.raw.ic_default_station_image))
setArtworkData(ImageHelper.getStationImageAsByteArray(context), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
}
setIsBrowsable(false)
setIsPlayable(true)
setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION)
}.build()
// build MediaItem and return it
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,129 @@
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 setSampleRate(sampleRate: Float)
private external fun setDrcEnabled(enabled: Boolean)
private external fun setReverbMix(mix: Float)
private external fun setEqFull(gains: FloatArray)
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 setEqAll(gains: FloatArray) = setEqFull(gains)
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)
}
// Pass the actual sample rate to native
setSampleRate(inputAudioFormat.sampleRate.toFloat())
return inputAudioFormat
}
override fun queueInput(inputBuffer: ByteBuffer) {
val size = inputBuffer.remaining()
if (size == 0) return
val bufferToProcess: ByteBuffer
if (inputBuffer.isDirect) {
bufferToProcess = inputBuffer
} else {
if (directBuffer == null || directBuffer!!.capacity() < size) {
directBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder())
}
directBuffer!!.clear()
inputBuffer.position()
directBuffer!!.put(inputBuffer)
directBuffer!!.flip()
bufferToProcess = directBuffer!!
}
processAudioDirect(bufferToProcess, size)
val out = replaceOutputBuffer(size)
out.order(ByteOrder.nativeOrder())
bufferToProcess.position(0)
out.put(bufferToProcess)
out.flip()
}
override fun onReset() {
super.onReset()
directBuffer = null
}
// ===== Presets =====
fun setPresetRock() {
enableDrc(true)
setReverb(0.26f)
setWidth(1.1f)
setEqAll(floatArrayOf(2f, 1f, 0f, -1f, -1f, 0f, 1f, 2f, 2f, 3f))
enableBassBoost(0.9f)
}
fun setPresetPop() {
enableDrc(true)
setReverb(0.18f)
setWidth(1.05f)
setEqAll(floatArrayOf(0f, 1f, 1f, 1f, 0f, 0f, 1f, 2f, 2f, 1f))
enableBassBoost(0.6f)
}
fun setPresetJazz() {
enableDrc(false)
setReverb(0.15f)
setWidth(0.8f)
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)) 1f else 0.0f
}
/* Loads Reverb mix */
fun loadReverb(): Float {
return if (sharedPreferences.getBoolean(Keys.PREF_REVERB, false)) 0.3f else 0.0f
}
/* Loads DRC enabled state */
fun loadDrcEnabled(): Boolean {
return sharedPreferences.getBoolean(Keys.PREF_DRC, false)
}
/* Loads 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)
}
}
}
@@ -8,15 +8,21 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import com.michatec.radio.R
import com.michatec.radio.core.Station
import com.michatec.radio.helpers.NativeAudioProcessor
/*
@@ -31,6 +37,7 @@ class SearchResultAdapter(
private var exoPlayer: ExoPlayer? = null
private var paused: Boolean = false
private var isItemSelected: Boolean = false
private var nativeAudioProcessor = NativeAudioProcessor()
/* Listener Interface */
interface SearchResultAdapterListener {
@@ -138,6 +145,7 @@ class SearchResultAdapter(
}
@OptIn(UnstableApi::class)
private fun performPrePlayback(context: Context, streamUri: String) {
if (streamUri.contains(".m3u8")) {
// release previous player if it exists
@@ -151,8 +159,30 @@ class SearchResultAdapter(
// release previous player if it exists
stopPrePlayback()
// create a new instance of ExoPlayer
exoPlayer = ExoPlayer.Builder(context).build()
// set up audio attributes for the preview player
val audioAttributes = androidx.media3.common.AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
// Create a RenderersFactory that injects the NativeAudioProcessor
val renderersFactory = object : DefaultRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean
): AudioSink? {
return DefaultAudioSink.Builder(context)
.setAudioProcessors(arrayOf(nativeAudioProcessor))
.build()
}
}
// create a new instance of ExoPlayer with focus handling
exoPlayer = ExoPlayer.Builder(context, renderersFactory)
.setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.build()
// create a MediaItem with the streamUri
val mediaItem = MediaItem.fromUri(streamUri)
@@ -199,7 +229,7 @@ class SearchResultAdapter(
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(audioAttributes)
.build()
@@ -19,9 +19,11 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.Group
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.mediarouter.app.MediaRouteButton
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
import com.michatec.radio.Keys
@@ -60,6 +62,7 @@ data class LayoutHolder(var rootView: View) {
private var sheetNextMetadataView: ImageButton? = rootView.findViewById(R.id.sheet_next_metadata_button)
private var sheetPreviousMetadataView: ImageButton? = rootView.findViewById(R.id.sheet_previous_metadata_button)
private var sheetCopyMetadataButtonView: ImageButton? = rootView.findViewById(R.id.copy_station_metadata_button)
private var mediaRouteButton: MediaRouteButton? = rootView.findViewById(R.id.media_route_button)
private var sheetShareLinkButtonView: ImageView? = rootView.findViewById(R.id.sheet_share_link_button)
private var sheetBitrateView: TextView? = rootView.findViewById(R.id.sheet_bitrate_view)
var sheetSleepTimerStartButtonView: ImageButton? = rootView.findViewById(R.id.sleep_timer_start_button)
@@ -115,6 +118,11 @@ data class LayoutHolder(var rootView: View) {
return@setOnLongClickListener true
}
// Set up MediaRouteButton (Google Cast)
mediaRouteButton?.let {
CastButtonFactory.setUpMediaRouteButton(rootView.context, it)
}
// set layout for player
setupBottomSheet()
}
@@ -123,8 +131,6 @@ data class LayoutHolder(var rootView: View) {
/* Updates the player views */
@SuppressLint("DefaultLocale")
fun updatePlayerViews(context: Context, station: Station, isPlaying: Boolean) {
// set default metadata views, when playback has stopped
if (!isPlaying) {
metadataView?.text = station.name
sheetMetadataHistoryView?.text = station.name
@@ -11,6 +11,7 @@
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingBottom="16dp"
android:nextFocusRight="@id/dialog_negative_button"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/guideline"
app:layout_constraintStart_toStartOf="parent"
@@ -39,6 +40,7 @@
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nextFocusLeft="@id/station_list"
android:text="@string/dialog_find_station_button_add" />
<Button
@@ -47,6 +49,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:nextFocusLeft="@id/station_list"
android:text="@string/dialog_generic_button_cancel" />
</LinearLayout>
@@ -9,6 +9,11 @@
android:id="@+id/station_search_box_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:focusable="true"
android:focusableInTouchMode="true"
android:clickable="true"
android:nextFocusRight="@id/dialog_negative_button"
android:nextFocusDown="@id/station_search_result_list"
app:iconifiedByDefault="false"
app:layout_constraintEnd_toStartOf="@+id/guideline"
app:layout_constraintStart_toStartOf="parent"
@@ -44,6 +49,8 @@
android:layout_marginTop="8dp"
android:clipToPadding="false"
android:paddingBottom="16dp"
android:nextFocusRight="@id/dialog_negative_button"
android:nextFocusUp="@id/station_search_box_view"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/guideline"
app:layout_constraintStart_toStartOf="parent"
@@ -73,6 +80,8 @@
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nextFocusUp="@id/station_search_box_view"
android:nextFocusLeft="@id/station_search_result_list"
android:text="@string/dialog_find_station_button_add" />
<Button
@@ -81,6 +90,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:nextFocusLeft="@id/station_search_result_list"
android:text="@string/dialog_generic_button_cancel" />
</LinearLayout>
@@ -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>
@@ -7,7 +7,7 @@
android:layout_marginBottom="8dp"
android:focusable="true"
android:clickable="true"
android:nextFocusRight="@+id/dialog_positive_button"
android:nextFocusRight="@+id/dialog_negative_button"
android:background="@drawable/selector_search_result_item">
<androidx.constraintlayout.widget.ConstraintLayout
@@ -20,6 +20,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Material3.TitleLarge"
android:textColor="@color/text_default"
@@ -35,6 +36,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
android:textColor="@color/text_lightweight"
@@ -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: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
android:id="@+id/sheet_bitrate_view"
android:layout_width="wrap_content"
@@ -290,4 +302,4 @@
android:visibility="gone"
app:constraint_referenced_ids="sleep_timer_remaining_time,sleep_timer_cancel_button" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -22,6 +22,24 @@
app:strokeColor="@color/list_card_stroke_background"
app:strokeWidth="3dp" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/card_visualizer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="10dp"
android:layout_marginEnd="70dp"
android:layout_marginBottom="24dp"
android:focusable="true"
android:clickable="true"
android:stateListAnimator="@null"
app:backgroundTint="@color/list_card_background"
app:icon="@drawable/ic_music_note_24dp"
app:iconTint="@color/icon_default"
app:rippleColor="@color/list_card_stroke_background"
app:strokeColor="@color/list_card_stroke_background"
app:strokeWidth="3dp" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/card_settings"
android:layout_width="wrap_content"
@@ -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>
+33 -2
View File
@@ -13,12 +13,43 @@
<action
android:id="@+id/action_map_fragment_to_settings_fragment"
app:destination="@id/settings_destination" />
<action
android:id="@+id/action_map_fragment_to_visualizer_fragment"
app:destination="@id/visualizer_destination" />
<action
android:id="@+id/action_map_fragment_to_player_to_add_station"
app:destination="@id/add_station_destination" />
</fragment>
<!-- SETTINGS -->
<fragment
android:id="@+id/settings_destination"
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>
</navigation>
<!-- 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" />
<!-- ADD STATION (TV) -->
<fragment
android:id="@+id/add_station_destination"
android:name="com.michatec.radio.AddStationFragment"
android:label="Add Station"
tools:layout="@layout/dialog_find_station" />
</navigation>
+30
View File
@@ -117,4 +117,34 @@
<!-- Snackbars -->
<string name="snackbar_show">Zeigen</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>
+6
View File
@@ -27,4 +27,10 @@
<!-- Don't show light status bar -->
<item name="android:statusBarColor">@android:color/transparent</item>
</style>
<style name="CustomCastExpandedControllerStyle" parent="CastExpandedController">
<item name="android:windowBackground">#FFFFFF</item>
<item name="castBackground">#FFFFFF</item>
<item name="castButtonColor">#FF495D92</item>
</style>
</resources>
+31
View File
@@ -63,6 +63,34 @@
<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_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_app_version_summary">Version</string>
<string name="pref_app_version_title">App Version</string>
@@ -144,4 +172,7 @@
<!-- Extras -->
<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>
+6
View File
@@ -74,4 +74,10 @@
<item name="cornerSizeBottomRight">16dp</item>
<item name="cornerFamilyBottomRight">rounded</item>
</style>
<style name="CustomCastExpandedControllerStyle" parent="CastExpandedController">
<item name="android:windowBackground">#FFFFFF</item>
<item name="castBackground">#FFFFFF</item>
<item name="castButtonColor">#FF495D92</item>
</style>
</resources>
+1 -1
View File
@@ -7,5 +7,5 @@ plugins {
}
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"
volley = "1.2.1"
workRuntimeKtx = "2.11.2"
playServicesCastFramework = "22.3.0"
[libraries]
activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
@@ -29,12 +30,14 @@ media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasourc
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
media3-cast = { group = "androidx.media3", name = "media3-cast", version.ref = "media3" }
navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
volley = { group = "com.android.volley", name = "volley", version.ref = "volley" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" }
play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCastFramework" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }