mirror of
https://github.com/Michatec/Radio.git
synced 2026-05-31 01:52:39 +02:00
Compare commits
5 Commits
14.4
...
bd3ad427fa
| Author | SHA1 | Date | |
|---|---|---|---|
| bd3ad427fa | |||
| 7b2cfb4b17 | |||
| 0796bc8ef4 | |||
| 1564fa3dc4 | |||
| d40ae6b746 |
+2
-1
@@ -6,4 +6,5 @@
|
||||
/build
|
||||
/captures
|
||||
/gradle/gradle-daemon-jvm.properties
|
||||
/.kotlin
|
||||
/.kotlin
|
||||
/app/.cxx/
|
||||
+15
-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,12 @@ android {
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file('src/main/cpp/CMakeLists.txt')
|
||||
version '3.22.1'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -57,6 +68,7 @@ dependencies {
|
||||
// Google Stuff //
|
||||
implementation libs.material
|
||||
implementation libs.gson
|
||||
implementation libs.play.services.cast.framework
|
||||
|
||||
// AndroidX Stuff //
|
||||
implementation libs.core.ktx
|
||||
@@ -67,6 +79,7 @@ dependencies {
|
||||
implementation libs.media3.exoplayer
|
||||
implementation libs.media3.exoplayer.hls
|
||||
implementation libs.media3.session
|
||||
implementation libs.media3.cast
|
||||
implementation libs.media3.datasource.okhttp
|
||||
implementation libs.navigation.fragment.ktx
|
||||
implementation libs.navigation.ui.ktx
|
||||
|
||||
@@ -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,38 @@
|
||||
|
||||
# For more information about using CMake with Android Studio, read the
|
||||
# documentation: https://d.android.com/studio/projects/add-native-code.html.
|
||||
# For more examples on how to use CMake, see https://github.com/android/ndk-samples.
|
||||
|
||||
# Sets the minimum CMake version required for this project.
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
# Declares the project name. The project name can be accessed via ${ PROJECT_NAME},
|
||||
# Since this is the top level CMakeLists.txt, the project name is also accessible
|
||||
# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level
|
||||
# build script scope).
|
||||
project("radio")
|
||||
|
||||
# Creates and names a library, sets it as either STATIC
|
||||
# or SHARED, and provides the relative paths to its source code.
|
||||
# You can define multiple libraries, and CMake builds them for you.
|
||||
# Gradle automatically packages shared libraries with your APK.
|
||||
#
|
||||
# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
|
||||
# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
|
||||
# is preferred for the same purpose.
|
||||
#
|
||||
# In order to load a library into your app from Java/Kotlin, you must call
|
||||
# System.loadLibrary() and pass the name of the library defined here;
|
||||
# for GameActivity/NativeActivity derived applications, the same library name must be
|
||||
# used in the AndroidManifest.xml file.
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
# List C/C++ source files with relative paths to this CMakeLists.txt.
|
||||
radio.cpp)
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
# can link libraries from various origins, such as libraries defined in this
|
||||
# build script, prebuilt third-party libraries, or Android system libraries.
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
# List libraries link to the target library
|
||||
android
|
||||
log)
|
||||
@@ -0,0 +1,192 @@
|
||||
#include <jni.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <android/log.h>
|
||||
|
||||
// --- DSP Classes ---
|
||||
|
||||
/**
|
||||
* Biquad Filter for EQ and Shelving
|
||||
*/
|
||||
class Biquad {
|
||||
public:
|
||||
float a0 = 1.0f, a1 = 0.0f, a2 = 0.0f, b1 = 0.0f, b2 = 0.0f;
|
||||
float z1 = 0.0f, z2 = 0.0f;
|
||||
|
||||
void setPeakingEQ(float sampleRate, float freq, float gainDb, float bandwidth) {
|
||||
float a = powf(10.0f, gainDb / 40.0f);
|
||||
float w0 = 2.0f * static_cast<float>(M_PI) * freq / sampleRate;
|
||||
float alpha = sinf(w0) * sinhf(logf(2.0f) / 2.0f * bandwidth * w0 / sinf(w0));
|
||||
|
||||
float b0 = 1.0f + alpha * a;
|
||||
a1 = -2.0f * cosf(w0);
|
||||
a2 = 1.0f - alpha * a;
|
||||
float b0_inv = 1.0f / (1.0f + alpha / a);
|
||||
b1 = -2.0f * cosf(w0) * b0_inv;
|
||||
b2 = (1.0f - alpha / a) * b0_inv;
|
||||
a0 = b0 * b0_inv;
|
||||
a1 *= b0_inv;
|
||||
a2 *= b0_inv;
|
||||
}
|
||||
|
||||
void setLowShelf(float sampleRate, float frequency, float gainDb, float q) {
|
||||
float a = powf(10.0f, gainDb / 40.0f);
|
||||
float w0 = 2.0f * static_cast<float>(M_PI) * frequency / sampleRate;
|
||||
float alpha = sinf(w0) / 2.0f * sqrtf((a + 1.0f / a) * (1.0f / q - 1.0f) + 2.0f);
|
||||
float cosW0 = cosf(w0);
|
||||
|
||||
float b0 = a * ((a + 1.0f) - (a - 1.0f) * cosW0 + 2.0f * sqrtf(a) * alpha);
|
||||
a1 = 2.0f * a * ((a - 1.0f) - (a + 1.0f) * cosW0);
|
||||
a2 = a * ((a + 1.0f) - (a - 1.0f) * cosW0 - 2.0f * sqrtf(a) * alpha);
|
||||
float b0_inv = 1.0f / ((a + 1.0f) + (a - 1.0f) * cosW0 + 2.0f * sqrtf(a) * alpha);
|
||||
b1 = -2.0f * ((a - 1.0f) + (a + 1.0f) * cosW0) * b0_inv;
|
||||
b2 = ((a + 1.0f) + (a - 1.0f) * cosW0 - 2.0f * sqrtf(a) * alpha) * b0_inv;
|
||||
a0 = b0 * b0_inv;
|
||||
a1 *= b0_inv;
|
||||
a2 *= b0_inv;
|
||||
}
|
||||
|
||||
float process(float in) {
|
||||
float out = in * a0 + z1;
|
||||
z1 = in * a1 + z2 - b1 * out;
|
||||
z2 = in * a2 - b2 * out;
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Dynamic Range Compressor
|
||||
*/
|
||||
class Compressor {
|
||||
public:
|
||||
float threshold = 0.3f;
|
||||
float ratio = 4.0f;
|
||||
float attack = 0.01f;
|
||||
float release = 0.2f;
|
||||
float sampleRate = 44100.0f;
|
||||
float envelope = 0.0f;
|
||||
|
||||
void process(float* buffer, int size) {
|
||||
float attackCoef = expf(-1.0f / (attack * sampleRate));
|
||||
float releaseCoef = expf(-1.0f / (release * sampleRate));
|
||||
|
||||
for (int i = 0; i < size; ++i) {
|
||||
float absInput = std::abs(buffer[i]);
|
||||
if (absInput > envelope)
|
||||
envelope = attackCoef * (envelope - absInput) + absInput;
|
||||
else
|
||||
envelope = releaseCoef * (envelope - absInput) + absInput;
|
||||
|
||||
if (envelope > threshold) {
|
||||
float gainReduction = threshold + (envelope - threshold) / ratio;
|
||||
buffer[i] *= (gainReduction / envelope);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple Reverb (Comb Filter based)
|
||||
*/
|
||||
class Reverb {
|
||||
public:
|
||||
std::vector<float> delayLine;
|
||||
int pos = 0;
|
||||
float feedback = 0.4f;
|
||||
float mix = 0.0f;
|
||||
|
||||
Reverb() { delayLine.resize(4410, 0.0f); } // ~100ms
|
||||
|
||||
float process(float in) {
|
||||
float delayed = delayLine[static_cast<size_t>(pos)];
|
||||
delayLine[static_cast<size_t>(pos)] = in + delayed * feedback;
|
||||
pos = (pos + 1) % static_cast<int>(delayLine.size());
|
||||
return in + delayed * mix;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Global Engine State ---
|
||||
Compressor gCompressor;
|
||||
Reverb gReverb;
|
||||
std::vector<Biquad> gEqBands(10);
|
||||
Biquad gBassBoost;
|
||||
|
||||
bool gDrcEnabled = false;
|
||||
bool gReverbEnabled = false;
|
||||
bool gEqEnabled = false;
|
||||
bool gBassBoostEnabled = false;
|
||||
|
||||
extern "C" {
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_michatec_radio_helpers_NativeAudioProcessor_setDrcEnabled(JNIEnv *env, jobject thiz, jboolean enabled) {
|
||||
gDrcEnabled = enabled;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_michatec_radio_helpers_NativeAudioProcessor_setReverbMix(JNIEnv *env, jobject thiz, jfloat mix) {
|
||||
gReverb.mix = mix;
|
||||
gReverbEnabled = (mix > 0.01f);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_michatec_radio_helpers_NativeAudioProcessor_setEqBand(JNIEnv *env, jobject thiz, jint band, jfloat gainDb) {
|
||||
float freqs[] = {31.25f, 62.5f, 125.0f, 250.0f, 500.0f, 1000.0f, 2000.0f, 4000.0f, 8000.0f, 16000.0f};
|
||||
if (band >= 0 && band < 10) {
|
||||
gEqBands[static_cast<size_t>(band)].setPeakingEQ(44100.0f, freqs[band], gainDb, 1.0f);
|
||||
gEqEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_michatec_radio_helpers_NativeAudioProcessor_setBassBoost(JNIEnv *env, jobject thiz, jfloat gainDb) {
|
||||
if (gainDb > 0.0f) {
|
||||
gBassBoost.setLowShelf(44100.0f, 150.0f, gainDb, 0.707f);
|
||||
gBassBoostEnabled = true;
|
||||
} else {
|
||||
gBassBoostEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_michatec_radio_helpers_NativeAudioProcessor_processAudio(JNIEnv *env, jobject thiz, jshortArray data, jint size) {
|
||||
jshort *buffer = env->GetShortArrayElements(data, nullptr);
|
||||
if (!buffer) return;
|
||||
|
||||
std::vector<float> floatBuf(static_cast<size_t>(size));
|
||||
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = static_cast<float>(buffer[i]) / 32768.0f;
|
||||
|
||||
// Apply EQ
|
||||
if (gEqEnabled) {
|
||||
for (auto &band : gEqBands) {
|
||||
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = band.process(floatBuf[static_cast<size_t>(i)]);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Bass Boost
|
||||
if (gBassBoostEnabled) {
|
||||
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = gBassBoost.process(floatBuf[static_cast<size_t>(i)]);
|
||||
}
|
||||
|
||||
// Apply Reverb
|
||||
if (gReverbEnabled) {
|
||||
for (int i = 0; i < size; ++i) floatBuf[static_cast<size_t>(i)] = gReverb.process(floatBuf[static_cast<size_t>(i)]);
|
||||
}
|
||||
|
||||
// Apply Compressor (at the end to prevent clipping)
|
||||
if (gDrcEnabled) {
|
||||
gCompressor.process(floatBuf.data(), size);
|
||||
}
|
||||
|
||||
// Back to short
|
||||
for (int i = 0; i < size; ++i) {
|
||||
float out = std::max(-1.0f, std::min(1.0f, floatBuf[static_cast<size_t>(i)]));
|
||||
buffer[i] = static_cast<jshort>(out * 32767.0f);
|
||||
}
|
||||
|
||||
env->ReleaseShortArrayElements(data, buffer, 0);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,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,74 @@
|
||||
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
|
||||
*/
|
||||
class EqualizerFragment : PreferenceFragmentCompat() {
|
||||
|
||||
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()
|
||||
// Manually update SeekBars to 0
|
||||
findPreference<SeekBarPreference>(Keys.PREF_EQ_LOW)?.value = 0
|
||||
findPreference<SeekBarPreference>(Keys.PREF_EQ_MID)?.value = 0
|
||||
findPreference<SeekBarPreference>(Keys.PREF_EQ_HIGH)?.value = 0
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
screen.addPreference(resetPreference)
|
||||
|
||||
// EQ Low
|
||||
val eqLow = SeekBarPreference(context)
|
||||
eqLow.title = getString(R.string.pref_eq_low_title)
|
||||
eqLow.key = Keys.PREF_EQ_LOW
|
||||
eqLow.setIcon(R.drawable.ic_music_note_24dp)
|
||||
eqLow.min = -12
|
||||
eqLow.max = 12
|
||||
eqLow.showSeekBarValue = true
|
||||
eqLow.setDefaultValue(0)
|
||||
screen.addPreference(eqLow)
|
||||
|
||||
// EQ Mid
|
||||
val eqMid = SeekBarPreference(context)
|
||||
eqMid.title = getString(R.string.pref_eq_mid_title)
|
||||
eqMid.key = Keys.PREF_EQ_MID
|
||||
eqMid.setIcon(R.drawable.ic_music_note_24dp)
|
||||
eqMid.min = -12
|
||||
eqMid.max = 12
|
||||
eqMid.showSeekBarValue = true
|
||||
eqMid.setDefaultValue(0)
|
||||
screen.addPreference(eqMid)
|
||||
|
||||
// EQ High
|
||||
val eqHigh = SeekBarPreference(context)
|
||||
eqHigh.title = getString(R.string.pref_eq_high_title)
|
||||
eqHigh.key = Keys.PREF_EQ_HIGH
|
||||
eqHigh.setIcon(R.drawable.ic_music_note_24dp)
|
||||
eqHigh.min = -12
|
||||
eqHigh.max = 12
|
||||
eqHigh.showSeekBarValue = true
|
||||
eqHigh.setDefaultValue(0)
|
||||
screen.addPreference(eqHigh)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,12 @@ object Keys {
|
||||
const val PREF_LARGE_BUFFER_SIZE: String = "LARGE_BUFFER_SIZE"
|
||||
const val PREF_EDIT_STATIONS: String = "EDIT_STATIONS"
|
||||
const val PREF_EDIT_STREAMS_URIS: String = "EDIT_STREAMS_URIS"
|
||||
const val PREF_BASS_BOOST: String = "BASS_BOOST"
|
||||
const val PREF_REVERB: String = "REVERB"
|
||||
const val PREF_DRC: String = "DRC"
|
||||
const val PREF_EQ_LOW: String = "EQ_LOW"
|
||||
const val PREF_EQ_MID: String = "EQ_MID"
|
||||
const val PREF_EQ_HIGH: String = "EQ_HIGH"
|
||||
|
||||
// default const values
|
||||
const val DEFAULT_SIZE_OF_METADATA_HISTORY: Int = 25
|
||||
@@ -84,7 +90,6 @@ object Keys {
|
||||
const val DIALOG_REMOVE_STATION: Int = 2
|
||||
const val DIALOG_UPDATE_STATION_IMAGES: Int = 4
|
||||
const val DIALOG_RESTORE_COLLECTION: Int = 5
|
||||
const val DIALOG_THEME_SELECTION: Int = 6
|
||||
|
||||
// dialog results
|
||||
const val DIALOG_EMPTY_PAYLOAD_STRING: String = ""
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -402,6 +402,7 @@ class PlayerFragment : Fragment(),
|
||||
|
||||
/* Releases MediaController */
|
||||
private fun releaseController() {
|
||||
controller?.removeListener(playerListener)
|
||||
MediaController.releaseFuture(controllerFuture)
|
||||
}
|
||||
|
||||
@@ -809,7 +810,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,7 +152,10 @@ 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
|
||||
// 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
|
||||
player = object : ForwardingPlayer(exoPlayer) {
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
return super.getAvailableCommands().buildUpon().add(COMMAND_SEEK_TO_NEXT)
|
||||
@@ -246,6 +282,28 @@ class PlayerService : MediaLibraryService() {
|
||||
}
|
||||
|
||||
|
||||
/* Applies audio effects based on preferences */
|
||||
private fun applyAudioEffects() {
|
||||
nativeAudioProcessor.enableBassBoost(PreferencesHelper.loadBassBoost())
|
||||
nativeAudioProcessor.setReverb(PreferencesHelper.loadReverb())
|
||||
nativeAudioProcessor.enableDrc(PreferencesHelper.loadDrcEnabled())
|
||||
nativeAudioProcessor.setEq(0, PreferencesHelper.loadEqLow())
|
||||
nativeAudioProcessor.setEq(1, PreferencesHelper.loadEqMid())
|
||||
nativeAudioProcessor.setEq(2, PreferencesHelper.loadEqHigh())
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onSharedPreferenceChanged from SharedPreferences.OnSharedPreferenceChangeListener */
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
Keys.PREF_BASS_BOOST, Keys.PREF_REVERB, Keys.PREF_DRC,
|
||||
Keys.PREF_EQ_LOW, Keys.PREF_EQ_MID, Keys.PREF_EQ_HIGH -> {
|
||||
applyAudioEffects()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Custom MediaSession Callback that handles player commands
|
||||
*/
|
||||
@@ -268,7 +326,6 @@ class PlayerService : MediaLibraryService() {
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
// add custom commands
|
||||
val connectionResult: MediaSession.ConnectionResult = super.onConnect(session, controller)
|
||||
val builder: SessionCommands.Builder = connectionResult.availableSessionCommands.buildUpon()
|
||||
builder.add(SessionCommand(Keys.CMD_START_SLEEP_TIMER, Bundle.EMPTY))
|
||||
@@ -494,7 +551,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
|
||||
@@ -186,6 +185,40 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
|
||||
return@setOnPreferenceChangeListener true
|
||||
}
|
||||
|
||||
// set up "Bass Boost" preference
|
||||
val preferenceBassBoost = MarqueeSwitchPreference(context)
|
||||
preferenceBassBoost.title = getString(R.string.pref_bass_boost_title)
|
||||
preferenceBassBoost.setIcon(R.drawable.ic_music_note_24dp)
|
||||
preferenceBassBoost.key = Keys.PREF_BASS_BOOST
|
||||
preferenceBassBoost.summary = getString(R.string.pref_bass_boost_summary)
|
||||
preferenceBassBoost.setDefaultValue(false)
|
||||
|
||||
// set up "Reverb" preference
|
||||
val preferenceReverb = MarqueeSwitchPreference(context)
|
||||
preferenceReverb.title = getString(R.string.pref_reverb_title)
|
||||
preferenceReverb.setIcon(R.drawable.ic_music_note_24dp)
|
||||
preferenceReverb.key = Keys.PREF_REVERB
|
||||
preferenceReverb.summary = getString(R.string.pref_reverb_summary)
|
||||
preferenceReverb.setDefaultValue(false)
|
||||
|
||||
// set up "DRC" preference
|
||||
val preferenceDrc = MarqueeSwitchPreference(context)
|
||||
preferenceDrc.title = getString(R.string.pref_drc_title)
|
||||
preferenceDrc.setIcon(R.drawable.ic_music_note_24dp)
|
||||
preferenceDrc.key = Keys.PREF_DRC
|
||||
preferenceDrc.summary = getString(R.string.pref_drc_summary)
|
||||
preferenceDrc.setDefaultValue(true)
|
||||
|
||||
// set up "Equalizer" preference entry
|
||||
val preferenceEqualizer = Preference(context)
|
||||
preferenceEqualizer.title = getString(R.string.pref_equalizer_title)
|
||||
preferenceEqualizer.setIcon(R.drawable.ic_music_note_24dp)
|
||||
preferenceEqualizer.summary = getString(R.string.pref_equalizer_summary)
|
||||
preferenceEqualizer.setOnPreferenceClickListener {
|
||||
findNavController().navigate(R.id.action_settings_to_equalizer)
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
// set up "App Version" preference
|
||||
val preferenceAppVersion = Preference(context)
|
||||
preferenceAppVersion.title = getString(R.string.pref_app_version_title)
|
||||
@@ -196,10 +229,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 +268,54 @@ 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(preferenceEqualizer)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -491,28 +524,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 +560,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,58 @@
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.audio.AudioProcessor
|
||||
import androidx.media3.common.audio.AudioProcessor.AudioFormat
|
||||
import androidx.media3.common.audio.BaseAudioProcessor
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class NativeAudioProcessor : BaseAudioProcessor() {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("radio")
|
||||
}
|
||||
}
|
||||
|
||||
// JNI Methods
|
||||
private external fun setDrcEnabled(enabled: Boolean)
|
||||
private external fun setReverbMix(mix: Float)
|
||||
private external fun setEqBand(band: Int, gainDb: Float)
|
||||
private external fun setBassBoost(gainDb: Float)
|
||||
private external fun processAudio(data: ShortArray, size: Int)
|
||||
|
||||
// Public API
|
||||
fun enableDrc(enabled: Boolean) = setDrcEnabled(enabled)
|
||||
fun setReverb(mix: Float) = setReverbMix(mix)
|
||||
fun setEq(band: Int, gainDb: Float) = setEqBand(band, gainDb)
|
||||
fun enableBassBoost(gainDb: Float) = setBassBoost(gainDb)
|
||||
|
||||
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat {
|
||||
if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT) {
|
||||
throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat)
|
||||
}
|
||||
return inputAudioFormat
|
||||
}
|
||||
|
||||
override fun queueInput(inputBuffer: ByteBuffer) {
|
||||
val remaining = inputBuffer.remaining()
|
||||
if (remaining == 0) return
|
||||
|
||||
val shortArraySize = remaining / 2
|
||||
val shortArray = ShortArray(shortArraySize)
|
||||
|
||||
inputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer().get(shortArray)
|
||||
|
||||
processAudio(shortArray, shortArraySize)
|
||||
|
||||
val outputBuffer = replaceOutputBuffer(remaining)
|
||||
outputBuffer.asShortBuffer().put(shortArray)
|
||||
outputBuffer.limit(remaining)
|
||||
|
||||
inputBuffer.position(inputBuffer.limit())
|
||||
}
|
||||
}
|
||||
@@ -254,4 +254,36 @@ object PreferencesHelper {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* Loads Bass Boost gain */
|
||||
fun loadBassBoost(): Float {
|
||||
return if (sharedPreferences.getBoolean(Keys.PREF_BASS_BOOST, false)) 5.0f else 0.0f
|
||||
}
|
||||
|
||||
|
||||
/* Loads Reverb mix */
|
||||
fun loadReverb(): Float {
|
||||
return if (sharedPreferences.getBoolean(Keys.PREF_REVERB, false)) 0.3f else 0.0f
|
||||
}
|
||||
|
||||
|
||||
/* Loads DRC enabled state */
|
||||
fun loadDrcEnabled(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_DRC, false)
|
||||
}
|
||||
|
||||
/* Loads EQ gains */
|
||||
fun loadEqLow(): Float = sharedPreferences.getInt(Keys.PREF_EQ_LOW, 0).toFloat()
|
||||
fun loadEqMid(): Float = sharedPreferences.getInt(Keys.PREF_EQ_MID, 0).toFloat()
|
||||
fun loadEqHigh(): Float = sharedPreferences.getInt(Keys.PREF_EQ_HIGH, 0).toFloat()
|
||||
|
||||
/* Resets Equalizer settings to default */
|
||||
fun resetEqualizer() {
|
||||
sharedPreferences.edit {
|
||||
putInt(Keys.PREF_EQ_LOW, 0)
|
||||
putInt(Keys.PREF_EQ_MID, 0)
|
||||
putInt(Keys.PREF_EQ_HIGH, 0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.Group
|
||||
import androidx.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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/media_route_menu_item"
|
||||
android:title="@string/media_route_menu_title"
|
||||
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
|
||||
app:showAsAction="always" />
|
||||
|
||||
</menu>
|
||||
@@ -19,6 +19,16 @@
|
||||
<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" />
|
||||
</fragment>
|
||||
|
||||
</navigation>
|
||||
<!-- EQUALIZER -->
|
||||
<fragment
|
||||
android:id="@+id/equalizer_destination"
|
||||
android:name="com.michatec.radio.EqualizerFragment"
|
||||
android:label="Equalizer" />
|
||||
|
||||
</navigation>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<string name="pref_update_collection_summary">Die neueste Version aller Senderinformationen herunterladen.</string>
|
||||
<string name="dialog_yes_no_message_update_collection">Die neueste Version aller Senderinformationen herunterladen?</string>
|
||||
<string name="dialog_yes_no_positive_button_update_collection">Aktualisieren</string>
|
||||
<string name="pref_eq_high_title">Equalizer: Höhen</string>
|
||||
<string name="pref_advanced_title">Erweitert</string>
|
||||
<string name="pref_app_version_summary">Version</string>
|
||||
<string name="pref_app_version_title">App-Version</string>
|
||||
@@ -117,4 +118,16 @@
|
||||
<!-- 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: Bass</string>
|
||||
<string name="pref_eq_mid_title">Equalizer: Mitten</string>
|
||||
<string name="pref_equalizer_title">Equalizer</string>
|
||||
<string name="pref_equalizer_summary">Passen Sie die Audio-Frequenzen an.</string>
|
||||
<string name="pref_equalizer_reset_title">Equalizer zurücksetzen</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,19 @@
|
||||
<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: Low</string>
|
||||
<string name="pref_eq_mid_title">Equalizer: Mid</string>
|
||||
<string name="pref_eq_high_title">Equalizer: High</string>
|
||||
<string name="pref_equalizer_title">Equalizer</string>
|
||||
<string name="pref_equalizer_summary">Adjust audio frequencies</string>
|
||||
<string name="pref_equalizer_reset_title">Reset Equalizer</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 +157,5 @@
|
||||
|
||||
<!-- Extras -->
|
||||
<string name="loading">Loading...</string>
|
||||
<string name="media_route_menu_title">Cast</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