mirror of
https://github.com/Michatec/Radio.git
synced 2026-05-31 01:52:39 +02:00
Compare commits
29 Commits
14.4
...
cdf7668d43
| Author | SHA1 | Date | |
|---|---|---|---|
| cdf7668d43 | |||
| f755dc5173 | |||
| 8c7a8ce7c4 | |||
| d1cc340417 | |||
| e0d1770a19 | |||
| 2e8cc9b243 | |||
| 9e1219549e | |||
| 17ba1c268a | |||
| b328af5c3a | |||
| d31d476cb5 | |||
| 6bb34cd707 | |||
| 883a4443e9 | |||
| 8ba22a4c09 | |||
| 9d47684f13 | |||
| 82993d7c97 | |||
| 487195b716 | |||
| 12445a3918 | |||
| bc38742eae | |||
| 99499ad174 | |||
| 0d35770375 | |||
| 0d0980a1ef | |||
| ae215691ca | |||
| 52f1a57de3 | |||
| 53abe918ca | |||
| bd3ad427fa | |||
| 7b2cfb4b17 | |||
| 0796bc8ef4 | |||
| 1564fa3dc4 | |||
| d40ae6b746 |
@@ -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
@@ -6,4 +6,5 @@
|
||||
/build
|
||||
/captures
|
||||
/gradle/gradle-daemon-jvm.properties
|
||||
/.kotlin
|
||||
/.kotlin
|
||||
/app/.cxx/
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Android",
|
||||
"includePath": [
|
||||
"${workspaceFolder}/**"
|
||||
],
|
||||
"defines": [
|
||||
"_DEBUG",
|
||||
"UNICODE",
|
||||
"_UNICODE"
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": 4
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"C_Cpp.default.compilerPath": ""
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -7,5 +7,5 @@ plugins {
|
||||
}
|
||||
|
||||
tasks.register('clean', Delete) {
|
||||
delete rootProject.buildDir()
|
||||
delete layout.buildDirectory
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user