mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:17:39 +03:00
snap + backend headache
This commit is contained in:
@@ -2,8 +2,27 @@
|
|||||||
|
|
||||||
Research and recommendations for **movie cast** vs **video cast** quality enhancement in the Android Cast project. Combines user requirements, comparison tables, algorithm choices, placement (sender vs receiver), and fit with the current codebase.
|
Research and recommendations for **movie cast** vs **video cast** quality enhancement in the Android Cast project. Combines user requirements, comparison tables, algorithm choices, placement (sender vs receiver), and fit with the current codebase.
|
||||||
|
|
||||||
**Status:** Planning / research only — not implemented.
|
**Status:** Receiver experimental presets in **Developer settings** — audio PCM chain + video GLES post-decode (immersive / HDR shaders).
|
||||||
**Related code (today):** sender `ScreenCastService` → `VideoEncoder` / `AudioEncoder` → network; receiver network → `VideoDecoder` / `LibvpxCapableVideoDecoder` / `AudioDecoder` → `TextureView` / `AudioTrack`.
|
**Related code:** `app/.../receiver/av/*`, `ReceiverVideoGlRenderer`, `AudioDecoder`, `VideoDecoder` / `LibvpxVideoDecoder`, `ReceiverPlaybackActivity`, `DeveloperSettingsActivity`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Receiver A/V presets (product, May 2026)
|
||||||
|
|
||||||
|
Developer-only combo in **Developer settings** (not the main cast drawer):
|
||||||
|
|
||||||
|
| Video | Audio | Pipeline today |
|
||||||
|
|-------|-------|----------------|
|
||||||
|
| **Default** — decode→Surface passthrough | **Default** — decode→AudioTrack passthrough | Hook only; no DSP / no GLES |
|
||||||
|
| **Immersive** — GLES edge-aware denoise + mild sharpen | **Bass boost** — shelf below ~150 Hz, gentle treble cut | PCM + GLES; **intensity 0–10** |
|
||||||
|
| **HDR** — GLES Reinhard-lite + saturation | **Immersive** — clarity + noise gate + limiter (no ML v1) | RNNoise-class deferred; see §8A |
|
||||||
|
| | **Dolby 5.1 / 7.1** — crossfeed + short delays for stereo headsets | Spatialization stub; **intensity 0–10** |
|
||||||
|
|
||||||
|
**Immersive audio (this pass):** clearer, wider perceived loudness without extra hiss — high-pass (~90 Hz), envelope noise gate, mild presence lift, soft ceiling. Not “cinema reverb”; aim for intelligibility on cast compression artifacts. Scale all stages by **intensity/10**.
|
||||||
|
|
||||||
|
**Intensity:** 0 = bypass effect (passthrough samples); 10 = maximum for that preset.
|
||||||
|
|
||||||
|
**Integration:** `ReceiverAvRuntime` → `AudioDecoder` after AAC decode; video decoder → intermediate OES `SurfaceTexture` → GLES shader → `TextureView` (`ReceiverVideoGlHub`) when preset is immersive/HDR; default preset uses direct decode→`TextureView` Surface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import android.content.Context;
|
|||||||
|
|
||||||
import com.foxx.androidcast.crash.CrashReporter;
|
import com.foxx.androidcast.crash.CrashReporter;
|
||||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||||
|
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
||||||
|
|
||||||
/** Applies saved theme and locale at process start. */
|
/** Applies saved theme and locale at process start. */
|
||||||
public class AndroidCastApplication extends Application {
|
public class AndroidCastApplication extends Application {
|
||||||
@@ -31,5 +32,6 @@ public class AndroidCastApplication extends Application {
|
|||||||
CastLocaleHelper.applyStoredLocale(this);
|
CastLocaleHelper.applyStoredLocale(this);
|
||||||
CastThemeHelper.applyNightMode(this);
|
CastThemeHelper.applyNightMode(this);
|
||||||
CrashReporter.install(this);
|
CrashReporter.install(this);
|
||||||
|
ReceiverAvRuntime.reload(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import android.os.Build;
|
|||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
|
||||||
|
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global app preferences: identity, PIN, tray icon, and default sender/receiver cast options.
|
* Global app preferences: identity, PIN, tray icon, and default sender/receiver cast options.
|
||||||
*/
|
*/
|
||||||
@@ -33,6 +36,10 @@ public final class AppPreferences {
|
|||||||
private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats";
|
private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats";
|
||||||
private static final String KEY_SEND_ANONYMOUS_CRASH_LOGS = "send_anonymous_crash_logs";
|
private static final String KEY_SEND_ANONYMOUS_CRASH_LOGS = "send_anonymous_crash_logs";
|
||||||
private static final String KEY_DEV_SHOW_CODEC_PRIORITY_SCORES = "dev_show_codec_priority_scores";
|
private static final String KEY_DEV_SHOW_CODEC_PRIORITY_SCORES = "dev_show_codec_priority_scores";
|
||||||
|
private static final String KEY_DEV_RECEIVER_VIDEO_PRESET = "dev_receiver_video_preset";
|
||||||
|
private static final String KEY_DEV_RECEIVER_AUDIO_PRESET = "dev_receiver_audio_preset";
|
||||||
|
private static final String KEY_DEV_RECEIVER_AV_INTENSITY = "dev_receiver_av_intensity";
|
||||||
|
private static final String KEY_DEV_RECEIVER_ADAPTIVE_DISPLAY = "dev_receiver_adaptive_display";
|
||||||
private static final String KEY_RECEIVER_PIP_USER_DEFINED = "receiver_pip_user_defined";
|
private static final String KEY_RECEIVER_PIP_USER_DEFINED = "receiver_pip_user_defined";
|
||||||
private static final String KEY_RECEIVER_PIP_ENABLED = "receiver_pip_enabled";
|
private static final String KEY_RECEIVER_PIP_ENABLED = "receiver_pip_enabled";
|
||||||
private static final String KEY_OTA_CHANNEL_URL = "ota_channel_url";
|
private static final String KEY_OTA_CHANNEL_URL = "ota_channel_url";
|
||||||
@@ -257,9 +264,56 @@ public final class AppPreferences {
|
|||||||
storeCastSettings(context, PREFIX_SENDER, settings);
|
storeCastSettings(context, PREFIX_SENDER, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ReceiverVideoPreset getReceiverVideoPreset(Context context) {
|
||||||
|
return ReceiverVideoPreset.fromName(
|
||||||
|
prefs(context).getString(KEY_DEV_RECEIVER_VIDEO_PRESET, null),
|
||||||
|
ReceiverVideoPreset.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setReceiverVideoPreset(Context context, ReceiverVideoPreset preset) {
|
||||||
|
prefs(context).edit()
|
||||||
|
.putString(KEY_DEV_RECEIVER_VIDEO_PRESET, preset.name())
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReceiverAudioPreset getReceiverAudioPreset(Context context) {
|
||||||
|
return ReceiverAudioPreset.fromName(
|
||||||
|
prefs(context).getString(KEY_DEV_RECEIVER_AUDIO_PRESET, null),
|
||||||
|
ReceiverAudioPreset.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setReceiverAudioPreset(Context context, ReceiverAudioPreset preset) {
|
||||||
|
prefs(context).edit()
|
||||||
|
.putString(KEY_DEV_RECEIVER_AUDIO_PRESET, preset.name())
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int getReceiverAvIntensity(Context context) {
|
||||||
|
return Math.max(0, Math.min(10, prefs(context).getInt(KEY_DEV_RECEIVER_AV_INTENSITY, 5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setReceiverAvIntensity(Context context, int intensity) {
|
||||||
|
prefs(context).edit()
|
||||||
|
.putInt(KEY_DEV_RECEIVER_AV_INTENSITY, Math.max(0, Math.min(10, intensity)))
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isDevReceiverAdaptiveDisplay(Context context) {
|
||||||
|
return prefs(context).getBoolean(KEY_DEV_RECEIVER_ADAPTIVE_DISPLAY, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setDevReceiverAdaptiveDisplay(Context context, boolean enabled) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_DEV_RECEIVER_ADAPTIVE_DISPLAY, enabled).apply();
|
||||||
|
}
|
||||||
|
|
||||||
public static CastSettings loadReceiverDefaults(Context context) {
|
public static CastSettings loadReceiverDefaults(Context context) {
|
||||||
CastSettings s = new CastSettings();
|
CastSettings s = new CastSettings();
|
||||||
applyStoredCastSettings(context, PREFIX_RECEIVER, s);
|
applyStoredCastSettings(context, PREFIX_RECEIVER, s);
|
||||||
|
if (isDevReceiverAdaptiveDisplay(context)) {
|
||||||
|
s.setReceiverDisplayResolution(CastSettings.ReceiverDisplayResolution.ADAPTIVE_EXPERIMENTAL);
|
||||||
|
} else {
|
||||||
|
s.setReceiverDisplayResolution(CastSettings.ReceiverDisplayResolution.AUTO_FIT);
|
||||||
|
}
|
||||||
s.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
|
s.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
|
||||||
s.setQuality(CastSettings.Quality.MEDIUM);
|
s.setQuality(CastSettings.Quality.MEDIUM);
|
||||||
if (s.getBitrateMode() == CastSettings.BitrateMode.AUTO) {
|
if (s.getBitrateMode() == CastSettings.BitrateMode.AUTO) {
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ public final class CastSettingsBinder {
|
|||||||
bindTransport(activity, settings, R.id.spinner_receiver_transport, true);
|
bindTransport(activity, settings, R.id.spinner_receiver_transport, true);
|
||||||
bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
||||||
bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
||||||
bindReceiverDisplay(activity, settings);
|
|
||||||
Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
|
Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
|
||||||
if (audioPlay != null) {
|
if (audioPlay != null) {
|
||||||
bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity),
|
bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity),
|
||||||
@@ -145,7 +144,6 @@ public final class CastSettingsBinder {
|
|||||||
readTransport(activity, settings, R.id.spinner_receiver_transport);
|
readTransport(activity, settings, R.id.spinner_receiver_transport);
|
||||||
readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
||||||
readNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
readNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
||||||
readReceiverDisplay(activity, settings);
|
|
||||||
Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
|
Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
|
||||||
if (audioPlay != null) {
|
if (audioPlay != null) {
|
||||||
AppPreferences.setPlayIncomingAudio(activity, audioPlay.getSelectedItemPosition() == 0);
|
AppPreferences.setPlayIncomingAudio(activity, audioPlay.getSelectedItemPosition() == 0);
|
||||||
@@ -375,22 +373,6 @@ public final class CastSettingsBinder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void bindReceiverDisplay(AppCompatActivity activity, CastSettings settings) {
|
|
||||||
Spinner spinner = activity.findViewById(R.id.spinner_receiver_resolution);
|
|
||||||
CastSettings.ReceiverDisplayResolution[] modes = CastSettings.ReceiverDisplayResolution.values();
|
|
||||||
String[] labels = {
|
|
||||||
activity.getString(R.string.receiver_display_auto_fit),
|
|
||||||
activity.getString(R.string.receiver_display_adaptive)
|
|
||||||
};
|
|
||||||
bindEnumSpinner(spinner, modes, labels, settings.getReceiverDisplayResolution(),
|
|
||||||
settings::setReceiverDisplayResolution);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void readReceiverDisplay(AppCompatActivity activity, CastSettings settings) {
|
|
||||||
readEnumSpinner(activity.findViewById(R.id.spinner_receiver_resolution),
|
|
||||||
settings::setReceiverDisplayResolution);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void bindCaptureMode(AppCompatActivity activity, CastSettings settings) {
|
private static void bindCaptureMode(AppCompatActivity activity, CastSettings settings) {
|
||||||
Spinner spinner = activity.findViewById(R.id.spinner_sender_capture_mode);
|
Spinner spinner = activity.findViewById(R.id.spinner_sender_capture_mode);
|
||||||
List<String> labels = new ArrayList<>();
|
List<String> labels = new ArrayList<>();
|
||||||
|
|||||||
@@ -156,18 +156,22 @@ public class CastSettingsFragment extends Fragment {
|
|||||||
TextView commit = view.findViewById(R.id.text_app_commit_value);
|
TextView commit = view.findViewById(R.id.text_app_commit_value);
|
||||||
TextView built = view.findViewById(R.id.text_app_built_value);
|
TextView built = view.findViewById(R.id.text_app_built_value);
|
||||||
TextView author = view.findViewById(R.id.text_app_author_value);
|
TextView author = view.findViewById(R.id.text_app_author_value);
|
||||||
View.OnClickListener versionTap = v -> onVersionLabelTapped();
|
View.OnClickListener devTap = v -> onDeveloperSettingsTap();
|
||||||
if (version != null) {
|
if (version != null) {
|
||||||
version.setText(VersionInfo.APP_VERSION_STRING);
|
version.setText(VersionInfo.APP_VERSION_STRING);
|
||||||
version.setOnClickListener(versionTap);
|
version.setOnClickListener(devTap);
|
||||||
}
|
}
|
||||||
View versionRow = view.findViewById(R.id.row_app_version);
|
View versionRow = view.findViewById(R.id.row_app_version);
|
||||||
if (versionRow != null) {
|
if (versionRow != null) {
|
||||||
versionRow.setOnClickListener(versionTap);
|
versionRow.setOnClickListener(devTap);
|
||||||
}
|
}
|
||||||
TextView versionLabel = view.findViewById(R.id.label_app_version);
|
TextView versionLabel = view.findViewById(R.id.label_app_version);
|
||||||
if (versionLabel != null) {
|
if (versionLabel != null) {
|
||||||
versionLabel.setOnClickListener(versionTap);
|
versionLabel.setOnClickListener(devTap);
|
||||||
|
}
|
||||||
|
View appInfoSection = view.findViewById(R.id.section_app_info);
|
||||||
|
if (appInfoSection != null) {
|
||||||
|
appInfoSection.setOnClickListener(devTap);
|
||||||
}
|
}
|
||||||
if (protocol != null) {
|
if (protocol != null) {
|
||||||
protocol.setText(VersionInfo.PROTO_VERSION_STRING);
|
protocol.setText(VersionInfo.PROTO_VERSION_STRING);
|
||||||
@@ -202,7 +206,7 @@ public class CastSettingsFragment extends Fragment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onVersionLabelTapped() {
|
private void onDeveloperSettingsTap() {
|
||||||
long now = SystemClock.elapsedRealtime();
|
long now = SystemClock.elapsedRealtime();
|
||||||
if (now - versionTapWindowStartMs > 2500) {
|
if (now - versionTapWindowStartMs > 2500) {
|
||||||
versionTapCount = 0;
|
versionTapCount = 0;
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ package com.foxx.androidcast;
|
|||||||
**********************************************************************/
|
**********************************************************************/
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.widget.ArrayAdapter;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.CheckBox;
|
import android.widget.CheckBox;
|
||||||
|
import android.widget.SeekBar;
|
||||||
|
import android.widget.Spinner;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
@@ -23,11 +26,19 @@ import androidx.appcompat.app.AppCompatActivity;
|
|||||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||||
import com.foxx.androidcast.ota.OtaUpdateChecker;
|
import com.foxx.androidcast.ota.OtaUpdateChecker;
|
||||||
import com.foxx.androidcast.ota.OtaUpdateCoordinator;
|
import com.foxx.androidcast.ota.OtaUpdateCoordinator;
|
||||||
|
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
|
||||||
|
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
||||||
|
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
||||||
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
||||||
import com.google.android.material.textfield.TextInputEditText;
|
import com.google.android.material.textfield.TextInputEditText;
|
||||||
|
|
||||||
/** Hidden developer settings (opened via version label easter egg). */
|
/** Hidden developer settings (opened via app-info area easter egg). */
|
||||||
public class DeveloperSettingsActivity extends AppCompatActivity {
|
public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||||
|
private Spinner videoPresetSpinner;
|
||||||
|
private Spinner audioPresetSpinner;
|
||||||
|
private SeekBar intensitySeek;
|
||||||
|
private TextView intensityValue;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void attachBaseContext(Context newBase) {
|
protected void attachBaseContext(Context newBase) {
|
||||||
super.attachBaseContext(CastLocaleHelper.attach(newBase));
|
super.attachBaseContext(CastLocaleHelper.attach(newBase));
|
||||||
@@ -43,14 +54,14 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
CheckBox debugOverlay = findViewById(R.id.check_receiver_debug_overlay);
|
CheckBox debugOverlay = findViewById(R.id.check_receiver_debug_overlay);
|
||||||
CheckBox codecScores = findViewById(R.id.check_show_codec_priority_scores);
|
CheckBox codecScores = findViewById(R.id.check_show_codec_priority_scores);
|
||||||
CheckBox sessionStats = findViewById(R.id.check_grab_session_stats);
|
CheckBox sessionStats = findViewById(R.id.check_grab_session_stats);
|
||||||
Button reloadCodecs = findViewById(R.id.btn_reload_codecs_json);
|
CheckBox adaptiveDisplay = findViewById(R.id.check_receiver_adaptive_display);
|
||||||
|
|
||||||
refreshFromPreferences(debugOverlay, codecScores, sessionStats);
|
refreshFromPreferences(debugOverlay, codecScores, sessionStats, adaptiveDisplay);
|
||||||
|
|
||||||
findViewById(R.id.btn_open_session_stats).setOnClickListener(v ->
|
findViewById(R.id.btn_open_session_stats).setOnClickListener(v ->
|
||||||
startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
|
startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
|
||||||
|
|
||||||
reloadCodecs.setOnClickListener(v -> {
|
findViewById(R.id.btn_reload_codecs_json).setOnClickListener(v -> {
|
||||||
String summary = CodecPriorityCatalog.reload(this);
|
String summary = CodecPriorityCatalog.reload(this);
|
||||||
Toast.makeText(this, summary, Toast.LENGTH_LONG).show();
|
Toast.makeText(this, summary, Toast.LENGTH_LONG).show();
|
||||||
});
|
});
|
||||||
@@ -70,6 +81,14 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
AppPreferences.setOtaChannelUrl(this, url);
|
AppPreferences.setOtaChannelUrl(this, url);
|
||||||
OtaUpdateCoordinator.requestManualCheck(this);
|
OtaUpdateCoordinator.requestManualCheck(this);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bindAvPresets();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
saveAvPresets();
|
||||||
|
super.onPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -78,11 +97,117 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
refreshFromPreferences(
|
refreshFromPreferences(
|
||||||
findViewById(R.id.check_receiver_debug_overlay),
|
findViewById(R.id.check_receiver_debug_overlay),
|
||||||
findViewById(R.id.check_show_codec_priority_scores),
|
findViewById(R.id.check_show_codec_priority_scores),
|
||||||
findViewById(R.id.check_grab_session_stats));
|
findViewById(R.id.check_grab_session_stats),
|
||||||
|
findViewById(R.id.check_receiver_adaptive_display));
|
||||||
refreshOtaUi(
|
refreshOtaUi(
|
||||||
findViewById(R.id.text_ota_installed_version),
|
findViewById(R.id.text_ota_installed_version),
|
||||||
findViewById(R.id.edit_ota_manifest_url),
|
findViewById(R.id.edit_ota_manifest_url),
|
||||||
findViewById(R.id.check_ota_on_launch));
|
findViewById(R.id.check_ota_on_launch));
|
||||||
|
bindAvPresets();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindAvPresets() {
|
||||||
|
videoPresetSpinner = findViewById(R.id.spinner_dev_video_preset);
|
||||||
|
audioPresetSpinner = findViewById(R.id.spinner_dev_audio_preset);
|
||||||
|
intensitySeek = findViewById(R.id.seek_dev_av_intensity);
|
||||||
|
intensityValue = findViewById(R.id.text_dev_av_intensity_value);
|
||||||
|
|
||||||
|
if (videoPresetSpinner != null) {
|
||||||
|
String[] labels = {
|
||||||
|
getString(R.string.dev_av_video_default),
|
||||||
|
getString(R.string.dev_av_video_immersive),
|
||||||
|
getString(R.string.dev_av_video_hdr)
|
||||||
|
};
|
||||||
|
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||||
|
android.R.layout.simple_spinner_item, labels);
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
videoPresetSpinner.setAdapter(adapter);
|
||||||
|
videoPresetSpinner.setSelection(AppPreferences.getReceiverVideoPreset(this).ordinal());
|
||||||
|
videoPresetSpinner.setOnItemSelectedListener(simpleSelection(() -> updateIntensityEnabled()));
|
||||||
|
}
|
||||||
|
if (audioPresetSpinner != null) {
|
||||||
|
String[] labels = {
|
||||||
|
getString(R.string.dev_av_audio_default),
|
||||||
|
getString(R.string.dev_av_audio_bass_boost),
|
||||||
|
getString(R.string.dev_av_audio_immersive),
|
||||||
|
getString(R.string.dev_av_audio_dolby_51),
|
||||||
|
getString(R.string.dev_av_audio_dolby_71)
|
||||||
|
};
|
||||||
|
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||||
|
android.R.layout.simple_spinner_item, labels);
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
audioPresetSpinner.setAdapter(adapter);
|
||||||
|
audioPresetSpinner.setSelection(AppPreferences.getReceiverAudioPreset(this).ordinal());
|
||||||
|
audioPresetSpinner.setOnItemSelectedListener(simpleSelection(() -> updateIntensityEnabled()));
|
||||||
|
}
|
||||||
|
if (intensitySeek != null) {
|
||||||
|
intensitySeek.setProgress(AppPreferences.getReceiverAvIntensity(this));
|
||||||
|
intensitySeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
|
||||||
|
@Override
|
||||||
|
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
|
||||||
|
updateIntensityLabel(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStartTrackingTouch(SeekBar seekBar) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStopTrackingTouch(SeekBar seekBar) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateIntensityEnabled();
|
||||||
|
updateIntensityLabel(intensitySeek != null ? intensitySeek.getProgress() : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveAvPresets() {
|
||||||
|
if (videoPresetSpinner != null) {
|
||||||
|
AppPreferences.setReceiverVideoPreset(this,
|
||||||
|
ReceiverVideoPreset.values()[videoPresetSpinner.getSelectedItemPosition()]);
|
||||||
|
}
|
||||||
|
if (audioPresetSpinner != null) {
|
||||||
|
AppPreferences.setReceiverAudioPreset(this,
|
||||||
|
ReceiverAudioPreset.values()[audioPresetSpinner.getSelectedItemPosition()]);
|
||||||
|
}
|
||||||
|
if (intensitySeek != null) {
|
||||||
|
AppPreferences.setReceiverAvIntensity(this, intensitySeek.getProgress());
|
||||||
|
}
|
||||||
|
ReceiverAvRuntime.reload(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateIntensityEnabled() {
|
||||||
|
boolean audioOn = audioPresetSpinner != null
|
||||||
|
&& ReceiverAudioPreset.values()[audioPresetSpinner.getSelectedItemPosition()]
|
||||||
|
.supportsIntensity();
|
||||||
|
boolean videoOn = videoPresetSpinner != null
|
||||||
|
&& ReceiverVideoPreset.values()[videoPresetSpinner.getSelectedItemPosition()]
|
||||||
|
.supportsIntensity();
|
||||||
|
boolean on = audioOn || videoOn;
|
||||||
|
if (intensitySeek != null) {
|
||||||
|
intensitySeek.setEnabled(on);
|
||||||
|
}
|
||||||
|
TextView label = findViewById(R.id.label_dev_av_intensity);
|
||||||
|
if (label != null) {
|
||||||
|
label.setEnabled(on);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateIntensityLabel(int progress) {
|
||||||
|
if (intensityValue != null) {
|
||||||
|
intensityValue.setText(getString(R.string.dev_av_intensity_value, progress));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private android.widget.AdapterView.OnItemSelectedListener simpleSelection(Runnable after) {
|
||||||
|
return new android.widget.AdapterView.OnItemSelectedListener() {
|
||||||
|
@Override
|
||||||
|
public void onItemSelected(android.widget.AdapterView<?> parent, android.view.View view,
|
||||||
|
int position, long id) {
|
||||||
|
after.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNothingSelected(android.widget.AdapterView<?> parent) {}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private void refreshOtaUi(TextView installedVersion, TextInputEditText manifestUrl, CheckBox otaOnLaunch) {
|
private void refreshOtaUi(TextView installedVersion, TextInputEditText manifestUrl, CheckBox otaOnLaunch) {
|
||||||
@@ -109,13 +234,16 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void refreshFromPreferences(CheckBox debugOverlay, CheckBox codecScores, CheckBox sessionStats) {
|
private void refreshFromPreferences(CheckBox debugOverlay, CheckBox codecScores, CheckBox sessionStats,
|
||||||
|
CheckBox adaptiveDisplay) {
|
||||||
bindDeveloperCheckbox(debugOverlay, AppPreferences.isShowReceiverDebugOverlay(this),
|
bindDeveloperCheckbox(debugOverlay, AppPreferences.isShowReceiverDebugOverlay(this),
|
||||||
AppPreferences::setShowReceiverDebugOverlay);
|
AppPreferences::setShowReceiverDebugOverlay);
|
||||||
bindDeveloperCheckbox(codecScores, AppPreferences.isShowCodecPriorityScores(this),
|
bindDeveloperCheckbox(codecScores, AppPreferences.isShowCodecPriorityScores(this),
|
||||||
AppPreferences::setShowCodecPriorityScores);
|
AppPreferences::setShowCodecPriorityScores);
|
||||||
bindDeveloperCheckbox(sessionStats, AppPreferences.isGrabSessionStats(this),
|
bindDeveloperCheckbox(sessionStats, AppPreferences.isGrabSessionStats(this),
|
||||||
AppPreferences::setGrabSessionStats);
|
AppPreferences::setGrabSessionStats);
|
||||||
|
bindDeveloperCheckbox(adaptiveDisplay, AppPreferences.isDevReceiverAdaptiveDisplay(this),
|
||||||
|
AppPreferences::setDevReceiverAdaptiveDisplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
private interface BoolPrefWriter {
|
private interface BoolPrefWriter {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import android.util.Log;
|
|||||||
|
|
||||||
import com.foxx.androidcast.media.AacAdtsHelper;
|
import com.foxx.androidcast.media.AacAdtsHelper;
|
||||||
import com.foxx.androidcast.media.PcmLevelAnalyzer;
|
import com.foxx.androidcast.media.PcmLevelAnalyzer;
|
||||||
|
import com.foxx.androidcast.receiver.av.AudioFilterChain;
|
||||||
|
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
@@ -116,6 +118,7 @@ public class AudioDecoder {
|
|||||||
codec.configure(format, null, null, 0);
|
codec.configure(format, null, null, 0);
|
||||||
codec.start();
|
codec.start();
|
||||||
createAudioTrack(sampleRate, channels);
|
createAudioTrack(sampleRate, channels);
|
||||||
|
ReceiverAvRuntime.audio().reset(sampleRate, channels);
|
||||||
configured = true;
|
configured = true;
|
||||||
draining = true;
|
draining = true;
|
||||||
retryFrame = null;
|
retryFrame = null;
|
||||||
@@ -291,6 +294,7 @@ public class AudioDecoder {
|
|||||||
} else {
|
} else {
|
||||||
stats.pcmAudibleBlocks++;
|
stats.pcmAudibleBlocks++;
|
||||||
}
|
}
|
||||||
|
ReceiverAvRuntime.audio().process(pcm, pcm.length, sampleRate, channels);
|
||||||
int written = writePcm(pcm);
|
int written = writePcm(pcm);
|
||||||
if (written > 0) {
|
if (written > 0) {
|
||||||
stats.pcmBytesWritten += written;
|
stats.pcmBytesWritten += written;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import android.view.Surface;
|
|||||||
|
|
||||||
import com.foxx.androidcast.media.codec.VideoDecoderSink;
|
import com.foxx.androidcast.media.codec.VideoDecoderSink;
|
||||||
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
|
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
|
||||||
|
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ public final class LibvpxVideoDecoder implements VideoDecoderSink {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) {
|
if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) {
|
||||||
|
ReceiverAvRuntime.video().onFrameDecoded(ptsUs);
|
||||||
notifyRenderedLocked();
|
notifyRenderedLocked();
|
||||||
} else if ((++decodeFailLogCount % DECODE_FAIL_LOG_EVERY) == 1) {
|
} else if ((++decodeFailLogCount % DECODE_FAIL_LOG_EVERY) == 1) {
|
||||||
Log.w(TAG, "vpx decode failed len=" + data.length + " key=" + keyFrame);
|
Log.w(TAG, "vpx decode failed len=" + data.length + " key=" + keyFrame);
|
||||||
|
|||||||
@@ -757,6 +757,8 @@ public class ReceiverCastService extends Service {
|
|||||||
reconfiguring = false;
|
reconfiguring = false;
|
||||||
decoderWidth = pendingWidth;
|
decoderWidth = pendingWidth;
|
||||||
decoderHeight = pendingHeight;
|
decoderHeight = pendingHeight;
|
||||||
|
com.foxx.androidcast.receiver.av.ReceiverAvRuntime.video()
|
||||||
|
.onConfigured(pendingWidth, pendingHeight);
|
||||||
updateStatus(getString(R.string.playback_awaiting_stream));
|
updateStatus(getString(R.string.playback_awaiting_stream));
|
||||||
broadcastStreamStarted(pendingWidth, pendingHeight);
|
broadcastStreamStarted(pendingWidth, pendingHeight);
|
||||||
Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);
|
Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import com.foxx.androidcast.PictureInPictureHelper;
|
|||||||
import com.foxx.androidcast.ICastReceiverService;
|
import com.foxx.androidcast.ICastReceiverService;
|
||||||
import com.foxx.androidcast.ICastStatusCallback;
|
import com.foxx.androidcast.ICastStatusCallback;
|
||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
|
import com.foxx.androidcast.receiver.av.ReceiverVideoGlHub;
|
||||||
import com.foxx.androidcast.ui.AspectRatioFrameLayout;
|
import com.foxx.androidcast.ui.AspectRatioFrameLayout;
|
||||||
import com.foxx.androidcast.ui.PlaybackTextureTransform;
|
import com.foxx.androidcast.ui.PlaybackTextureTransform;
|
||||||
import com.foxx.androidcast.ui.ZoomPanContainer;
|
import com.foxx.androidcast.ui.ZoomPanContainer;
|
||||||
@@ -445,6 +446,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
applyTextureFitCenter();
|
applyTextureFitCenter();
|
||||||
if (surfaceTexture != null) {
|
if (surfaceTexture != null) {
|
||||||
surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight);
|
surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight);
|
||||||
|
if (surfaceSentToService) {
|
||||||
|
detachSurface();
|
||||||
|
}
|
||||||
attachSurfaceIfReady();
|
attachSurfaceIfReady();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,21 +524,28 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
receiverService.attachVideoSurface(new Surface(surfaceTexture));
|
int w = videoWidth > 0 ? videoWidth : 1280;
|
||||||
|
int h = videoHeight > 0 ? videoHeight : 720;
|
||||||
|
Surface surface = ReceiverVideoGlHub.bindDisplay(this, surfaceTexture, w, h);
|
||||||
|
receiverService.attachVideoSurface(surface);
|
||||||
surfaceSentToService = true;
|
surfaceSentToService = true;
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void detachSurface() {
|
private void detachSurface() {
|
||||||
if (!surfaceSentToService || receiverService == null) {
|
if (!surfaceSentToService && receiverService == null) {
|
||||||
|
ReceiverVideoGlHub.release(this);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (receiverService != null) {
|
||||||
try {
|
try {
|
||||||
receiverService.detachVideoSurface();
|
receiverService.detachVideoSurface();
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
|
}
|
||||||
surfaceSentToService = false;
|
surfaceSentToService = false;
|
||||||
|
ReceiverVideoGlHub.release(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import android.view.Surface;
|
|||||||
import com.foxx.androidcast.media.CodecNegotiator;
|
import com.foxx.androidcast.media.CodecNegotiator;
|
||||||
import com.foxx.androidcast.media.H264Bitstream;
|
import com.foxx.androidcast.media.H264Bitstream;
|
||||||
import com.foxx.androidcast.media.codec.VideoDecoderSink;
|
import com.foxx.androidcast.media.codec.VideoDecoderSink;
|
||||||
|
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
@@ -160,6 +161,7 @@ public class VideoDecoder implements VideoDecoderSink {
|
|||||||
&& (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0;
|
&& (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0;
|
||||||
c.releaseOutputBuffer(outIndex, render);
|
c.releaseOutputBuffer(outIndex, render);
|
||||||
if (render) {
|
if (render) {
|
||||||
|
ReceiverAvRuntime.video().onFrameDecoded(info.presentationTimeUs);
|
||||||
notifyFirstFrameLocked();
|
notifyFirstFrameLocked();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/** In-place PCM filter (16-bit interleaved) between AAC decoder and {@link android.media.AudioTrack}. */
|
||||||
|
public interface AudioFilter {
|
||||||
|
/** @param intensity 0 = bypass effect, 10 = maximum for this filter */
|
||||||
|
void process(byte[] pcm, int bytes, int sampleRate, int channels, int intensity);
|
||||||
|
|
||||||
|
default void reset(int sampleRate, int channels) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/** Ordered PCM filters (decoder → chain → AudioTrack). */
|
||||||
|
public final class AudioFilterChain {
|
||||||
|
private final AudioFilter[] filters;
|
||||||
|
private final int intensity;
|
||||||
|
|
||||||
|
public AudioFilterChain(AudioFilter[] filters, int intensity) {
|
||||||
|
this.filters = filters != null ? filters : new AudioFilter[0];
|
||||||
|
this.intensity = Math.max(0, Math.min(10, intensity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AudioFilterChain passthrough() {
|
||||||
|
return new AudioFilterChain(new AudioFilter[] {new PassthroughAudioFilter()}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reset(int sampleRate, int channels) {
|
||||||
|
for (AudioFilter f : filters) {
|
||||||
|
f.reset(sampleRate, channels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void process(byte[] pcm, int bytes, int sampleRate, int channels) {
|
||||||
|
if (pcm == null || bytes <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (AudioFilter f : filters) {
|
||||||
|
f.process(pcm, bytes, sampleRate, channels, intensity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boosts energy below ~150 Hz and gently reduces mids/highs at higher intensity.
|
||||||
|
*/
|
||||||
|
public final class BassBoostAudioFilter implements AudioFilter {
|
||||||
|
private float[] lowL;
|
||||||
|
private float[] lowR;
|
||||||
|
private int channels;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reset(int sampleRate, int channels) {
|
||||||
|
this.channels = Math.max(1, channels);
|
||||||
|
lowL = new float[this.channels];
|
||||||
|
lowR = new float[this.channels];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void process(byte[] pcm, int bytes, int sampleRate, int channels, int intensity) {
|
||||||
|
if (intensity <= 0 || sampleRate <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lowL == null || this.channels != channels) {
|
||||||
|
reset(sampleRate, channels);
|
||||||
|
}
|
||||||
|
float g = PcmAudioFilters.intensityGain(intensity);
|
||||||
|
float alpha = (float) (1.0 - Math.exp(-2.0 * Math.PI * 150.0 / sampleRate));
|
||||||
|
float bassGain = 1f + 1.2f * g;
|
||||||
|
float trebleCut = 1f - 0.35f * g;
|
||||||
|
PcmAudioFilters.process16Le(pcm, bytes, channels, (sample, ch, frame) -> {
|
||||||
|
float[] state = ch == 0 ? lowL : (ch == 1 ? lowR : lowL);
|
||||||
|
int idx = Math.min(ch, state.length - 1);
|
||||||
|
state[idx] += alpha * (sample - state[idx]);
|
||||||
|
float low = state[idx];
|
||||||
|
float high = sample - low;
|
||||||
|
float out = sample + (bassGain - 1f) * low + (trebleCut - 1f) * high;
|
||||||
|
return Math.max(-1f, Math.min(1f, out));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stereo headphone spatialization stub: crossfeed + short delays (5.1 or 7.1 flavor).
|
||||||
|
*/
|
||||||
|
public final class DolbySurroundAudioFilter implements AudioFilter {
|
||||||
|
private final boolean sevenOne;
|
||||||
|
private float[] delayL;
|
||||||
|
private float[] delayR;
|
||||||
|
private int writeIdx;
|
||||||
|
private int channels;
|
||||||
|
|
||||||
|
DolbySurroundAudioFilter(boolean sevenOne) {
|
||||||
|
this.sevenOne = sevenOne;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reset(int sampleRate, int channels) {
|
||||||
|
this.channels = channels;
|
||||||
|
int maxDelay = sampleRate > 0 ? Math.max(32, sampleRate * 12 / 1000) : 512;
|
||||||
|
delayL = new float[maxDelay];
|
||||||
|
delayR = new float[maxDelay];
|
||||||
|
writeIdx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void process(byte[] pcm, int bytes, int sampleRate, int channels, int intensity) {
|
||||||
|
if (intensity <= 0 || channels < 2 || sampleRate <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (delayL == null || this.channels != channels) {
|
||||||
|
reset(sampleRate, channels);
|
||||||
|
}
|
||||||
|
float g = PcmAudioFilters.intensityGain(intensity);
|
||||||
|
int d1 = Math.min(delayL.length - 1, sampleRate * 3 / 1000);
|
||||||
|
int d2 = Math.min(delayL.length - 1, sampleRate * 7 / 1000);
|
||||||
|
int d3 = sevenOne ? Math.min(delayL.length - 1, sampleRate * 11 / 1000) : 0;
|
||||||
|
float wet = 0.22f * g;
|
||||||
|
int frames = bytes / (2 * channels);
|
||||||
|
int off = 0;
|
||||||
|
for (int i = 0; i < frames; i++) {
|
||||||
|
float l = PcmAudioFilters.readSample(pcm, off);
|
||||||
|
float r = PcmAudioFilters.readSample(pcm, off + 2);
|
||||||
|
delayL[writeIdx] = l;
|
||||||
|
delayR[writeIdx] = r;
|
||||||
|
float tapL = delayL[(writeIdx - d1 + delayL.length) % delayL.length];
|
||||||
|
float tapR = delayR[(writeIdx - d1 + delayR.length) % delayR.length];
|
||||||
|
float wideL = l + wet * (tapR * 0.6f + delayR[(writeIdx - d2 + delayR.length) % delayR.length] * 0.3f);
|
||||||
|
float wideR = r + wet * (tapL * 0.6f + delayL[(writeIdx - d2 + delayL.length) % delayL.length] * 0.3f);
|
||||||
|
if (sevenOne && d3 > 0) {
|
||||||
|
wideL += wet * 0.15f * delayR[(writeIdx - d3 + delayR.length) % delayR.length];
|
||||||
|
wideR += wet * 0.15f * delayL[(writeIdx - d3 + delayL.length) % delayL.length];
|
||||||
|
}
|
||||||
|
PcmAudioFilters.writeSample(pcm, off, wideL);
|
||||||
|
PcmAudioFilters.writeSample(pcm, off + 2, wideR);
|
||||||
|
writeIdx = (writeIdx + 1) % delayL.length;
|
||||||
|
off += 2 * channels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
/** GLES post-decode path for immersive / HDR experimental presets. */
|
||||||
|
public final class GlExperimentalVideoPipeline implements ReceiverVideoPipeline {
|
||||||
|
private final Context appContext;
|
||||||
|
private final ReceiverVideoPreset preset;
|
||||||
|
|
||||||
|
public GlExperimentalVideoPipeline(Context context, ReceiverVideoPreset preset) {
|
||||||
|
this.appContext = context.getApplicationContext();
|
||||||
|
this.preset = preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFrameDecoded(long ptsUs) {
|
||||||
|
ReceiverVideoGlHub.onFrameDecoded(appContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConfigured(int width, int height) {
|
||||||
|
ReceiverVideoGlHub.refreshPreset(appContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReleased() {
|
||||||
|
ReceiverVideoGlHub.release(appContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReceiverVideoPreset getPreset() {
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight clarity chain (no ML): DC/high-pass, noise gate, gentle presence, limiter.
|
||||||
|
* Aligns with receiver live path in {@code AV_QUALITY_QA_SESSION.md} §8A audio.
|
||||||
|
*/
|
||||||
|
public final class ImmersiveAudioFilter implements AudioFilter {
|
||||||
|
private float hpL;
|
||||||
|
private float hpR;
|
||||||
|
private float env;
|
||||||
|
private int channels;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reset(int sampleRate, int channels) {
|
||||||
|
this.channels = Math.max(1, channels);
|
||||||
|
hpL = hpR = env = 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void process(byte[] pcm, int bytes, int sampleRate, int channels, int intensity) {
|
||||||
|
if (intensity <= 0 || sampleRate <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.channels != channels) {
|
||||||
|
reset(sampleRate, channels);
|
||||||
|
}
|
||||||
|
float g = PcmAudioFilters.intensityGain(intensity);
|
||||||
|
float hpAlpha = (float) (1.0 - Math.exp(-2.0 * Math.PI * 90.0 / sampleRate));
|
||||||
|
float envAlpha = (float) (1.0 - Math.exp(-2.0 * Math.PI * 12.0 / sampleRate));
|
||||||
|
float gateThresh = 0.008f * (1f - 0.5f * g);
|
||||||
|
float presence = 0.15f * g;
|
||||||
|
float ceiling = 0.98f - 0.05f * g;
|
||||||
|
PcmAudioFilters.process16Le(pcm, bytes, channels, (sample, ch, frame) -> {
|
||||||
|
float hp = ch == 0 ? hpL : (ch == 1 ? hpR : hpL);
|
||||||
|
hp += hpAlpha * (sample - hp);
|
||||||
|
if (ch == 0) {
|
||||||
|
hpL = hp;
|
||||||
|
} else if (ch == 1) {
|
||||||
|
hpR = hp;
|
||||||
|
}
|
||||||
|
float x = sample - hp;
|
||||||
|
float ax = Math.abs(x);
|
||||||
|
env += envAlpha * (ax - env);
|
||||||
|
if (env < gateThresh) {
|
||||||
|
x *= 0.25f + 0.75f * (env / Math.max(gateThresh, 1e-6f));
|
||||||
|
}
|
||||||
|
float bright = x + presence * (x - hp);
|
||||||
|
return Math.max(-ceiling, Math.min(ceiling, bright * (1f + 0.08f * g)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/** No-op audio filter (preset DEFAULT). */
|
||||||
|
public final class PassthroughAudioFilter implements AudioFilter {
|
||||||
|
@Override
|
||||||
|
public void process(byte[] pcm, int bytes, int sampleRate, int channels, int intensity) {
|
||||||
|
// passthrough
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/** Direct decode→display Surface (preset DEFAULT). */
|
||||||
|
public final class PassthroughVideoPipeline implements ReceiverVideoPipeline {
|
||||||
|
static final PassthroughVideoPipeline INSTANCE = new PassthroughVideoPipeline();
|
||||||
|
|
||||||
|
private PassthroughVideoPipeline() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFrameDecoded(long ptsUs) {
|
||||||
|
// Frame already on the display Surface.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/** Lightweight 16-bit PCM helpers for experimental receiver filters. */
|
||||||
|
final class PcmAudioFilters {
|
||||||
|
private PcmAudioFilters() {}
|
||||||
|
|
||||||
|
static float intensityGain(int intensity) {
|
||||||
|
return Math.max(0f, Math.min(10, intensity)) / 10f;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void process16Le(byte[] pcm, int bytes, int channels, SampleProcessor proc) {
|
||||||
|
int frames = bytes / (2 * channels);
|
||||||
|
int off = 0;
|
||||||
|
for (int i = 0; i < frames; i++) {
|
||||||
|
for (int ch = 0; ch < channels; ch++) {
|
||||||
|
float s = readSample(pcm, off);
|
||||||
|
s = proc.process(s, ch, i);
|
||||||
|
writeSample(pcm, off, s);
|
||||||
|
off += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static float readSample(byte[] pcm, int off) {
|
||||||
|
int lo = pcm[off] & 0xff;
|
||||||
|
int hi = pcm[off + 1];
|
||||||
|
int v = (hi << 8) | lo;
|
||||||
|
if (v > 32767) {
|
||||||
|
v -= 65536;
|
||||||
|
}
|
||||||
|
return v / 32768f;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void writeSample(byte[] pcm, int off, float s) {
|
||||||
|
int v = Math.round(Math.max(-1f, Math.min(1f, s)) * 32767f);
|
||||||
|
pcm[off] = (byte) (v & 0xff);
|
||||||
|
pcm[off + 1] = (byte) ((v >> 8) & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SampleProcessor {
|
||||||
|
float process(float sample, int channel, int frameIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/** Receiver audio enhancement preset (developer / experimental). */
|
||||||
|
public enum ReceiverAudioPreset {
|
||||||
|
DEFAULT,
|
||||||
|
BASS_BOOST,
|
||||||
|
IMMERSIVE,
|
||||||
|
DOLBY_51,
|
||||||
|
DOLBY_71;
|
||||||
|
|
||||||
|
public static ReceiverAudioPreset fromName(String name, ReceiverAudioPreset fallback) {
|
||||||
|
if (name == null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return valueOf(name.trim().toUpperCase());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean supportsIntensity() {
|
||||||
|
return this != DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.AppPreferences;
|
||||||
|
|
||||||
|
public final class ReceiverAvPipelineFactory {
|
||||||
|
private ReceiverAvPipelineFactory() {}
|
||||||
|
|
||||||
|
public static ReceiverVideoPipeline createVideo(Context context) {
|
||||||
|
Context app = context.getApplicationContext();
|
||||||
|
ReceiverVideoPreset preset = AppPreferences.getReceiverVideoPreset(app);
|
||||||
|
switch (preset) {
|
||||||
|
case IMMERSIVE:
|
||||||
|
case HDR:
|
||||||
|
return new GlExperimentalVideoPipeline(app, preset);
|
||||||
|
case DEFAULT:
|
||||||
|
default:
|
||||||
|
return ReceiverVideoPipeline.passthrough();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AudioFilterChain createAudio(Context context) {
|
||||||
|
ReceiverAudioPreset preset = AppPreferences.getReceiverAudioPreset(context);
|
||||||
|
int intensity = AppPreferences.getReceiverAvIntensity(context);
|
||||||
|
switch (preset) {
|
||||||
|
case BASS_BOOST:
|
||||||
|
return new AudioFilterChain(new AudioFilter[] {new BassBoostAudioFilter()}, intensity);
|
||||||
|
case IMMERSIVE:
|
||||||
|
return new AudioFilterChain(new AudioFilter[] {new ImmersiveAudioFilter()}, intensity);
|
||||||
|
case DOLBY_51:
|
||||||
|
return new AudioFilterChain(new AudioFilter[] {new DolbySurroundAudioFilter(false)}, intensity);
|
||||||
|
case DOLBY_71:
|
||||||
|
return new AudioFilterChain(new AudioFilter[] {new DolbySurroundAudioFilter(true)}, intensity);
|
||||||
|
case DEFAULT:
|
||||||
|
default:
|
||||||
|
return AudioFilterChain.passthrough();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
/** Live receiver A/V pipelines (reloaded from developer settings). */
|
||||||
|
public final class ReceiverAvRuntime {
|
||||||
|
private static volatile ReceiverVideoPipeline videoPipeline = ReceiverVideoPipeline.passthrough();
|
||||||
|
private static volatile AudioFilterChain audioChain = AudioFilterChain.passthrough();
|
||||||
|
|
||||||
|
private ReceiverAvRuntime() {}
|
||||||
|
|
||||||
|
public static ReceiverVideoPipeline video() {
|
||||||
|
return videoPipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AudioFilterChain audio() {
|
||||||
|
return audioChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void reload(Context context) {
|
||||||
|
Context app = context.getApplicationContext();
|
||||||
|
videoPipeline = ReceiverAvPipelineFactory.createVideo(app);
|
||||||
|
audioChain = ReceiverAvPipelineFactory.createAudio(app);
|
||||||
|
audioChain.reset(48_000, 2);
|
||||||
|
if (!ReceiverVideoGlHub.isGlMode(app)) {
|
||||||
|
ReceiverVideoGlHub.release(app);
|
||||||
|
} else {
|
||||||
|
ReceiverVideoGlHub.refreshPreset(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.SurfaceTexture;
|
||||||
|
import android.view.Surface;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.AppPreferences;
|
||||||
|
|
||||||
|
/** Routes decoder output through GLES when an experimental video preset is active. */
|
||||||
|
public final class ReceiverVideoGlHub {
|
||||||
|
private static final Object LOCK = new Object();
|
||||||
|
private static ReceiverVideoGlRenderer renderer;
|
||||||
|
|
||||||
|
private ReceiverVideoGlHub() {}
|
||||||
|
|
||||||
|
public static boolean isGlMode(Context context) {
|
||||||
|
ReceiverVideoPreset p = AppPreferences.getReceiverVideoPreset(context);
|
||||||
|
return p == ReceiverVideoPreset.IMMERSIVE || p == ReceiverVideoPreset.HDR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Surface for {@code MediaCodec} / libvpx (decoder input). */
|
||||||
|
public static Surface bindDisplay(Context context, SurfaceTexture display, int width, int height) {
|
||||||
|
synchronized (LOCK) {
|
||||||
|
if (!isGlMode(context)) {
|
||||||
|
releaseLocked();
|
||||||
|
return new Surface(display);
|
||||||
|
}
|
||||||
|
if (renderer == null) {
|
||||||
|
renderer = new ReceiverVideoGlRenderer();
|
||||||
|
}
|
||||||
|
renderer.setPreset(
|
||||||
|
AppPreferences.getReceiverVideoPreset(context),
|
||||||
|
AppPreferences.getReceiverAvIntensity(context));
|
||||||
|
return renderer.bindDisplay(display, width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void refreshPreset(Context context) {
|
||||||
|
synchronized (LOCK) {
|
||||||
|
if (renderer != null && isGlMode(context)) {
|
||||||
|
renderer.setPreset(
|
||||||
|
AppPreferences.getReceiverVideoPreset(context),
|
||||||
|
AppPreferences.getReceiverAvIntensity(context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void onFrameDecoded(Context context) {
|
||||||
|
synchronized (LOCK) {
|
||||||
|
if (renderer != null && isGlMode(context)) {
|
||||||
|
renderer.onDecoderFrameAvailable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void release(Context context) {
|
||||||
|
synchronized (LOCK) {
|
||||||
|
releaseLocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void releaseLocked() {
|
||||||
|
if (renderer != null) {
|
||||||
|
renderer.release();
|
||||||
|
renderer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
import android.graphics.SurfaceTexture;
|
||||||
|
import android.opengl.EGL14;
|
||||||
|
import android.opengl.EGLConfig;
|
||||||
|
import android.opengl.EGLContext;
|
||||||
|
import android.opengl.EGLDisplay;
|
||||||
|
import android.opengl.EGLSurface;
|
||||||
|
import android.opengl.GLES11Ext;
|
||||||
|
import android.opengl.GLES20;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.Surface;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.FloatBuffer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GLES2 bridge: decoder renders to an external-OES {@link SurfaceTexture}, then a preset shader
|
||||||
|
* draws into the {@link android.view.TextureView} display surface.
|
||||||
|
*/
|
||||||
|
final class ReceiverVideoGlRenderer {
|
||||||
|
private static final String TAG = "ReceiverVideoGl";
|
||||||
|
private static final int GL_TEXTURE_EXTERNAL_OES = 0x8D65;
|
||||||
|
|
||||||
|
private static final String VERT_OES =
|
||||||
|
"#extension GL_OES_EGL_image_external : require\n"
|
||||||
|
+ "attribute vec4 aPosition;\n"
|
||||||
|
+ "attribute vec2 aTexCoord;\n"
|
||||||
|
+ "varying vec2 vTexCoord;\n"
|
||||||
|
+ "void main() {\n"
|
||||||
|
+ " gl_Position = aPosition;\n"
|
||||||
|
+ " vTexCoord = aTexCoord;\n"
|
||||||
|
+ "}\n";
|
||||||
|
|
||||||
|
private static final String FRAG_OES =
|
||||||
|
"#extension GL_OES_EGL_image_external : require\n"
|
||||||
|
+ "precision mediump float;\n"
|
||||||
|
+ "varying vec2 vTexCoord;\n"
|
||||||
|
+ "uniform samplerExternalOES uTexture;\n"
|
||||||
|
+ "uniform float uIntensity;\n"
|
||||||
|
+ "uniform int uPreset;\n"
|
||||||
|
+ "void main() {\n"
|
||||||
|
+ " vec2 uv = vTexCoord;\n"
|
||||||
|
+ " vec4 src = texture2D(uTexture, uv);\n"
|
||||||
|
+ " vec3 c = src.rgb;\n"
|
||||||
|
+ " float t = clamp(uIntensity, 0.0, 1.0);\n"
|
||||||
|
+ " if (t <= 0.001) {\n"
|
||||||
|
+ " gl_FragColor = src;\n"
|
||||||
|
+ " return;\n"
|
||||||
|
+ " }\n"
|
||||||
|
+ " vec2 px = vec2(1.0 / 720.0, 1.0 / 1280.0);\n"
|
||||||
|
+ " vec3 n = texture2D(uTexture, uv + vec2(px.x, 0.0)).rgb;\n"
|
||||||
|
+ " vec3 s = texture2D(uTexture, uv - vec2(px.x, 0.0)).rgb;\n"
|
||||||
|
+ " vec3 e = texture2D(uTexture, uv + vec2(0.0, px.y)).rgb;\n"
|
||||||
|
+ " vec3 w = texture2D(uTexture, uv - vec2(0.0, px.y)).rgb;\n"
|
||||||
|
+ " vec3 blur = (n + s + e + w + c) * 0.2;\n"
|
||||||
|
+ " vec3 outCol;\n"
|
||||||
|
+ " if (uPreset == 1) {\n"
|
||||||
|
+ " vec3 hdr = c * (1.0 + 0.45 * t);\n"
|
||||||
|
+ " hdr = hdr / (hdr + vec3(0.55));\n"
|
||||||
|
+ " hdr = pow(hdr, vec3(0.9));\n"
|
||||||
|
+ " float luma = dot(hdr, vec3(0.299, 0.587, 0.114));\n"
|
||||||
|
+ " hdr = mix(vec3(luma), hdr, 1.0 + 0.3 * t);\n"
|
||||||
|
+ " outCol = mix(c, hdr, t);\n"
|
||||||
|
+ " } else {\n"
|
||||||
|
+ " vec3 denoise = mix(c, blur, 0.4 * t);\n"
|
||||||
|
+ " vec3 sharp = denoise + (denoise - blur) * (0.35 * t);\n"
|
||||||
|
+ " float luma = dot(sharp, vec3(0.299, 0.587, 0.114));\n"
|
||||||
|
+ " outCol = mix(vec3(luma), sharp, 1.0 + 0.08 * t);\n"
|
||||||
|
+ " outCol = mix(c, outCol, t);\n"
|
||||||
|
+ " }\n"
|
||||||
|
+ " gl_FragColor = vec4(clamp(outCol, 0.0, 1.0), src.a);\n"
|
||||||
|
+ "}\n";
|
||||||
|
|
||||||
|
private static final float[] QUAD = {
|
||||||
|
-1f, -1f, 0f, 1f,
|
||||||
|
1f, -1f, 1f, 1f,
|
||||||
|
-1f, 1f, 0f, 0f,
|
||||||
|
1f, 1f, 1f, 0f,
|
||||||
|
};
|
||||||
|
|
||||||
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
|
||||||
|
private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY;
|
||||||
|
private EGLContext eglContext = EGL14.EGL_NO_CONTEXT;
|
||||||
|
private EGLSurface outputEglSurface = EGL14.EGL_NO_SURFACE;
|
||||||
|
|
||||||
|
private int program;
|
||||||
|
private int oesTextureId;
|
||||||
|
private SurfaceTexture decoderTexture;
|
||||||
|
private Surface decoderSurface;
|
||||||
|
private SurfaceTexture displayTexture;
|
||||||
|
private int viewportW = 1;
|
||||||
|
private int viewportH = 1;
|
||||||
|
private volatile ReceiverVideoPreset preset = ReceiverVideoPreset.IMMERSIVE;
|
||||||
|
private volatile int intensity = 5;
|
||||||
|
private volatile boolean frameAvailable;
|
||||||
|
private FloatBuffer quadBuffer;
|
||||||
|
|
||||||
|
Surface bindDisplay(SurfaceTexture display, int width, int height) {
|
||||||
|
release();
|
||||||
|
displayTexture = display;
|
||||||
|
viewportW = Math.max(1, width);
|
||||||
|
viewportH = Math.max(1, height);
|
||||||
|
display.setDefaultBufferSize(viewportW, viewportH);
|
||||||
|
initEgl(new Surface(display));
|
||||||
|
createDecoderInput();
|
||||||
|
return decoderSurface;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPreset(ReceiverVideoPreset preset, int intensity) {
|
||||||
|
this.preset = preset != null ? preset : ReceiverVideoPreset.IMMERSIVE;
|
||||||
|
this.intensity = Math.max(0, Math.min(10, intensity));
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDecoderFrameAvailable() {
|
||||||
|
frameAvailable = true;
|
||||||
|
drawFrameInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
void release() {
|
||||||
|
if (Looper.myLooper() == mainHandler.getLooper()) {
|
||||||
|
releaseOnGlThread();
|
||||||
|
} else {
|
||||||
|
mainHandler.post(this::releaseOnGlThread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createDecoderInput() {
|
||||||
|
int[] textures = new int[1];
|
||||||
|
GLES20.glGenTextures(1, textures, 0);
|
||||||
|
oesTextureId = textures[0];
|
||||||
|
GLES20.glBindTexture(GL_TEXTURE_EXTERNAL_OES, oesTextureId);
|
||||||
|
GLES20.glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
|
||||||
|
GLES20.glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
|
||||||
|
GLES20.glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
|
||||||
|
GLES20.glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
|
||||||
|
|
||||||
|
decoderTexture = new SurfaceTexture(oesTextureId);
|
||||||
|
decoderTexture.setDefaultBufferSize(viewportW, viewportH);
|
||||||
|
decoderTexture.setOnFrameAvailableListener(st -> onDecoderFrameAvailable(), mainHandler);
|
||||||
|
decoderSurface = new Surface(decoderTexture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initEgl(Surface outputSurface) {
|
||||||
|
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
|
||||||
|
if (eglDisplay == EGL14.EGL_NO_DISPLAY) {
|
||||||
|
throw new IllegalStateException("eglGetDisplay failed");
|
||||||
|
}
|
||||||
|
int[] version = new int[2];
|
||||||
|
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
|
||||||
|
throw new IllegalStateException("eglInitialize failed");
|
||||||
|
}
|
||||||
|
EGLConfig config = chooseConfig(eglDisplay);
|
||||||
|
int[] ctxAttr = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE};
|
||||||
|
eglContext = EGL14.eglCreateContext(eglDisplay, config, EGL14.EGL_NO_CONTEXT, ctxAttr, 0);
|
||||||
|
outputEglSurface = EGL14.eglCreateWindowSurface(eglDisplay, config, outputSurface,
|
||||||
|
new int[] {EGL14.EGL_NONE}, 0);
|
||||||
|
if (!EGL14.eglMakeCurrent(eglDisplay, outputEglSurface, outputEglSurface, eglContext)) {
|
||||||
|
throw new IllegalStateException("eglMakeCurrent failed");
|
||||||
|
}
|
||||||
|
program = buildProgram(VERT_OES, FRAG_OES);
|
||||||
|
quadBuffer = ByteBuffer.allocateDirect(QUAD.length * 4).order(ByteOrder.nativeOrder())
|
||||||
|
.asFloatBuffer();
|
||||||
|
quadBuffer.put(QUAD).position(0);
|
||||||
|
GLES20.glViewport(0, 0, viewportW, viewportH);
|
||||||
|
Log.i(TAG, "GLES video bridge " + viewportW + "x" + viewportH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawFrameInternal() {
|
||||||
|
if (decoderTexture == null || outputEglSurface == EGL14.EGL_NO_SURFACE || !frameAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frameAvailable = false;
|
||||||
|
try {
|
||||||
|
if (!EGL14.eglMakeCurrent(eglDisplay, outputEglSurface, outputEglSurface, eglContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
decoderTexture.updateTexImage();
|
||||||
|
GLES20.glViewport(0, 0, viewportW, viewportH);
|
||||||
|
GLES20.glClearColor(0f, 0f, 0f, 1f);
|
||||||
|
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
|
||||||
|
GLES20.glUseProgram(program);
|
||||||
|
int aPos = GLES20.glGetAttribLocation(program, "aPosition");
|
||||||
|
int aTex = GLES20.glGetAttribLocation(program, "aTexCoord");
|
||||||
|
int uTex = GLES20.glGetUniformLocation(program, "uTexture");
|
||||||
|
int uInt = GLES20.glGetUniformLocation(program, "uIntensity");
|
||||||
|
int uPreset = GLES20.glGetUniformLocation(program, "uPreset");
|
||||||
|
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
|
||||||
|
GLES20.glBindTexture(GL_TEXTURE_EXTERNAL_OES, oesTextureId);
|
||||||
|
GLES20.glUniform1i(uTex, 0);
|
||||||
|
GLES20.glUniform1f(uInt, intensity / 10f);
|
||||||
|
GLES20.glUniform1i(uPreset, preset == ReceiverVideoPreset.HDR ? 1 : 0);
|
||||||
|
quadBuffer.position(0);
|
||||||
|
GLES20.glEnableVertexAttribArray(aPos);
|
||||||
|
GLES20.glVertexAttribPointer(aPos, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
|
||||||
|
quadBuffer.position(2);
|
||||||
|
GLES20.glEnableVertexAttribArray(aTex);
|
||||||
|
GLES20.glVertexAttribPointer(aTex, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
|
||||||
|
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
|
||||||
|
GLES20.glDisableVertexAttribArray(aPos);
|
||||||
|
GLES20.glDisableVertexAttribArray(aTex);
|
||||||
|
EGL14.eglSwapBuffers(eglDisplay, outputEglSurface);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "drawFrame: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseOnGlThread() {
|
||||||
|
if (decoderSurface != null) {
|
||||||
|
decoderSurface.release();
|
||||||
|
decoderSurface = null;
|
||||||
|
}
|
||||||
|
if (decoderTexture != null) {
|
||||||
|
decoderTexture.release();
|
||||||
|
decoderTexture = null;
|
||||||
|
}
|
||||||
|
if (oesTextureId != 0) {
|
||||||
|
int[] t = new int[] {oesTextureId};
|
||||||
|
GLES20.glDeleteTextures(1, t, 0);
|
||||||
|
oesTextureId = 0;
|
||||||
|
}
|
||||||
|
if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
|
||||||
|
EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE,
|
||||||
|
EGL14.EGL_NO_CONTEXT);
|
||||||
|
if (outputEglSurface != EGL14.EGL_NO_SURFACE) {
|
||||||
|
EGL14.eglDestroySurface(eglDisplay, outputEglSurface);
|
||||||
|
outputEglSurface = EGL14.EGL_NO_SURFACE;
|
||||||
|
}
|
||||||
|
if (eglContext != EGL14.EGL_NO_CONTEXT) {
|
||||||
|
EGL14.eglDestroyContext(eglDisplay, eglContext);
|
||||||
|
eglContext = EGL14.EGL_NO_CONTEXT;
|
||||||
|
}
|
||||||
|
EGL14.eglTerminate(eglDisplay);
|
||||||
|
eglDisplay = EGL14.EGL_NO_DISPLAY;
|
||||||
|
}
|
||||||
|
displayTexture = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EGLConfig chooseConfig(EGLDisplay display) {
|
||||||
|
int[] attribs = {
|
||||||
|
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||||
|
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
|
||||||
|
EGL14.EGL_RED_SIZE, 8,
|
||||||
|
EGL14.EGL_GREEN_SIZE, 8,
|
||||||
|
EGL14.EGL_BLUE_SIZE, 8,
|
||||||
|
EGL14.EGL_ALPHA_SIZE, 8,
|
||||||
|
EGL14.EGL_NONE
|
||||||
|
};
|
||||||
|
EGLConfig[] configs = new EGLConfig[1];
|
||||||
|
int[] num = new int[1];
|
||||||
|
if (!EGL14.eglChooseConfig(display, attribs, 0, configs, 0, 1, num, 0)) {
|
||||||
|
throw new IllegalStateException("eglChooseConfig failed");
|
||||||
|
}
|
||||||
|
return configs[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int buildProgram(String vert, String frag) {
|
||||||
|
int vs = compile(GLES20.GL_VERTEX_SHADER, vert);
|
||||||
|
int fs = compile(GLES20.GL_FRAGMENT_SHADER, frag);
|
||||||
|
int prog = GLES20.glCreateProgram();
|
||||||
|
GLES20.glAttachShader(prog, vs);
|
||||||
|
GLES20.glAttachShader(prog, fs);
|
||||||
|
GLES20.glLinkProgram(prog);
|
||||||
|
int[] link = new int[1];
|
||||||
|
GLES20.glGetProgramiv(prog, GLES20.GL_LINK_STATUS, link, 0);
|
||||||
|
if (link[0] == 0) {
|
||||||
|
String log = GLES20.glGetProgramInfoLog(prog);
|
||||||
|
GLES20.glDeleteProgram(prog);
|
||||||
|
throw new IllegalStateException("Program link failed: " + log);
|
||||||
|
}
|
||||||
|
GLES20.glDeleteShader(vs);
|
||||||
|
GLES20.glDeleteShader(fs);
|
||||||
|
return prog;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int compile(int type, String src) {
|
||||||
|
int shader = GLES20.glCreateShader(type);
|
||||||
|
GLES20.glShaderSource(shader, src);
|
||||||
|
GLES20.glCompileShader(shader);
|
||||||
|
int[] ok = new int[1];
|
||||||
|
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, ok, 0);
|
||||||
|
if (ok[0] == 0) {
|
||||||
|
String log = GLES20.glGetShaderInfoLog(shader);
|
||||||
|
GLES20.glDeleteShader(shader);
|
||||||
|
throw new IllegalStateException("Shader compile failed: " + log);
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/** Hook between video decoder output and display (direct Surface or GLES bridge). */
|
||||||
|
public interface ReceiverVideoPipeline {
|
||||||
|
/**
|
||||||
|
* Called after {@code releaseOutputBuffer(..., render=true)} (or libvpx render). For GLES
|
||||||
|
* experimental presets, triggers shader draw to the TextureView surface.
|
||||||
|
*/
|
||||||
|
void onFrameDecoded(long ptsUs);
|
||||||
|
|
||||||
|
default void onConfigured(int width, int height) {}
|
||||||
|
|
||||||
|
default void onReleased() {}
|
||||||
|
|
||||||
|
static ReceiverVideoPipeline passthrough() {
|
||||||
|
return PassthroughVideoPipeline.INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
/** Receiver video enhancement preset (developer / experimental). */
|
||||||
|
public enum ReceiverVideoPreset {
|
||||||
|
DEFAULT,
|
||||||
|
IMMERSIVE,
|
||||||
|
HDR;
|
||||||
|
|
||||||
|
public boolean supportsIntensity() {
|
||||||
|
return this != DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReceiverVideoPreset fromName(String name, ReceiverVideoPreset fallback) {
|
||||||
|
if (name == null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return valueOf(name.trim().toUpperCase());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,17 +181,6 @@
|
|||||||
android:text="@string/settings_section_receiver"
|
android:text="@string/settings_section_receiver"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:text="@string/label_receiver_display" />
|
|
||||||
|
|
||||||
<Spinner
|
|
||||||
android:id="@+id/spinner_receiver_resolution"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -38,6 +38,76 @@
|
|||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
android:text="@string/dev_grab_session_stats" />
|
android:text="@string/dev_grab_session_stats" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/check_receiver_adaptive_display"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/dev_receiver_adaptive_display" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="28dp"
|
||||||
|
android:text="@string/dev_av_presets_section"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/dev_av_video_preset"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_dev_video_preset"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/dev_av_audio_preset"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_dev_audio_preset"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/label_dev_av_intensity"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/dev_av_intensity"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<SeekBar
|
||||||
|
android:id="@+id/seek_dev_av_intensity"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:max="10"
|
||||||
|
android:progress="5" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_dev_av_intensity_value"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/dev_av_presets_hint"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/btn_open_session_stats"
|
android:id="@+id/btn_open_session_stats"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -239,17 +239,6 @@
|
|||||||
android:text="@string/receiver_pip_hint"
|
android:text="@string/receiver_pip_hint"
|
||||||
android:textSize="12sp" />
|
android:textSize="12sp" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:text="@string/label_receiver_display" />
|
|
||||||
|
|
||||||
<Spinner
|
|
||||||
android:id="@+id/spinner_receiver_resolution"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/section_app_info"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
|
|||||||
@@ -217,6 +217,21 @@
|
|||||||
<string name="session_stats_chart_hint">Tap sessions to toggle overlay. Pinch or scroll wheel to zoom. Drag finger for values.</string>
|
<string name="session_stats_chart_hint">Tap sessions to toggle overlay. Pinch or scroll wheel to zoom. Drag finger for values.</string>
|
||||||
<string name="dev_reload_codecs_json">Reload codecs.json</string>
|
<string name="dev_reload_codecs_json">Reload codecs.json</string>
|
||||||
<string name="dev_reload_codecs_json_hint">Loads assets/codecs.json overrides without restarting the app. Reopen the settings drawer to refresh spinners.</string>
|
<string name="dev_reload_codecs_json_hint">Loads assets/codecs.json overrides without restarting the app. Reopen the settings drawer to refresh spinners.</string>
|
||||||
|
<string name="dev_receiver_adaptive_display">Adaptive receiver display (experimental)</string>
|
||||||
|
<string name="dev_av_presets_section">Receiver A/V presets (experimental)</string>
|
||||||
|
<string name="dev_av_video_preset">Video preset</string>
|
||||||
|
<string name="dev_av_audio_preset">Audio preset</string>
|
||||||
|
<string name="dev_av_intensity">Filter intensity</string>
|
||||||
|
<string name="dev_av_intensity_value">Intensity: %1$d (0 = off, 10 = max)</string>
|
||||||
|
<string name="dev_av_presets_hint">Audio: PCM chain after AAC decode. Video experimental: GLES shader after decode (immersive denoise/sharpen, HDR tone map). Intensity 0–10. Restart cast session after preset changes.</string>
|
||||||
|
<string name="dev_av_video_default">Default (passthrough)</string>
|
||||||
|
<string name="dev_av_video_immersive">Immersive (experimental)</string>
|
||||||
|
<string name="dev_av_video_hdr">HDR (experimental)</string>
|
||||||
|
<string name="dev_av_audio_default">Default (passthrough)</string>
|
||||||
|
<string name="dev_av_audio_bass_boost">Bass boost</string>
|
||||||
|
<string name="dev_av_audio_immersive">Immersive</string>
|
||||||
|
<string name="dev_av_audio_dolby_51">Dolby 5.1 (stereo headset)</string>
|
||||||
|
<string name="dev_av_audio_dolby_71">Dolby 7.1 (stereo headset)</string>
|
||||||
<string name="ota_section_title">App updates (OTA)</string>
|
<string name="ota_section_title">App updates (OTA)</string>
|
||||||
<string name="ota_section_hint">v0 layout: https://host/v0/ota/channel/stable.json → manifest → APK. Service checks every 1 minute by default (overridable in private settings.json).</string>
|
<string name="ota_section_hint">v0 layout: https://host/v0/ota/channel/stable.json → manifest → APK. Service checks every 1 minute by default (overridable in private settings.json).</string>
|
||||||
<string name="ota_manifest_url_hint">OTA channel URL (v0 stable.json)</string>
|
<string name="ota_manifest_url_hint">OTA channel URL (v0 stable.json)</string>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.foxx.androidcast.receiver.av;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
|
||||||
|
public class ReceiverAvPipelineFactoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void passthroughChainLeavesPcmUnchangedAtZeroIntensity() {
|
||||||
|
AudioFilterChain chain = new AudioFilterChain(new AudioFilter[] {new BassBoostAudioFilter()}, 0);
|
||||||
|
byte[] pcm = new byte[] {0, 0x40, 0, 0, (byte) 0x80, (byte) 0xFF, 0, 0};
|
||||||
|
byte[] copy = pcm.clone();
|
||||||
|
chain.process(copy, copy.length, 48_000, 2);
|
||||||
|
assertEquals(pcm[0], copy[0]);
|
||||||
|
assertEquals(pcm[1], copy[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void bassBoostChangesSamplesAtHighIntensity() {
|
||||||
|
AudioFilterChain chain = new AudioFilterChain(new AudioFilter[] {new BassBoostAudioFilter()}, 10);
|
||||||
|
chain.reset(48_000, 2);
|
||||||
|
byte[] pcm = new byte[4096];
|
||||||
|
for (int i = 0; i < pcm.length; i += 2) {
|
||||||
|
pcm[i] = (byte) (i & 0xff);
|
||||||
|
pcm[i + 1] = 0;
|
||||||
|
}
|
||||||
|
byte[] copy = pcm.clone();
|
||||||
|
chain.process(copy, copy.length, 48_000, 2);
|
||||||
|
boolean changed = false;
|
||||||
|
for (int i = 0; i < pcm.length; i++) {
|
||||||
|
if (pcm[i] != copy[i]) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
org.junit.Assert.assertTrue(changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void videoPassthroughOnFrameDecodedNoOp() {
|
||||||
|
ReceiverVideoPipeline p = ReceiverVideoPipeline.passthrough();
|
||||||
|
assertNotNull(p);
|
||||||
|
p.onFrameDecoded(1L);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
backend_ops.txt
Normal file
103
backend_ops.txt
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
Android Cast — crash reporter backend deployment (checklist)
|
||||||
|
================================================================
|
||||||
|
|
||||||
|
PUBLIC URL (example)
|
||||||
|
https://apps.f0xx.org/app/androidcast_project/crashes/
|
||||||
|
Upload API: .../crashes/api/upload.php
|
||||||
|
|
||||||
|
FILES ON VM
|
||||||
|
Deploy backend so web root is:
|
||||||
|
.../androidcast_project/backend/public/
|
||||||
|
Symlink is fine, e.g.:
|
||||||
|
backend -> android_cast/examples/crash_reporter/backend/
|
||||||
|
|
||||||
|
config: backend/config/config.php (copy from config.example.php)
|
||||||
|
DB (SQLite default): backend/data/crashes.sqlite
|
||||||
|
Writable dirs: backend/data/ backend/storage/
|
||||||
|
|
||||||
|
ALPINE + PHP-FPM 8.1 (required modules)
|
||||||
|
apk add php81-fpm php81-session php81-pdo php81-pdo_sqlite php81-sqlite3
|
||||||
|
rc-service php-fpm81 restart
|
||||||
|
Verify FPM (not only CLI): php-fpm81 -m | grep -E 'session|pdo_sqlite|PDO'
|
||||||
|
If "session_name() undefined" -> php81-session missing or FPM not restarted
|
||||||
|
|
||||||
|
INIT DB (SQLite)
|
||||||
|
mkdir -p backend/data backend/storage
|
||||||
|
sqlite3 backend/data/crashes.sqlite < backend/sql/schema.sqlite.sql
|
||||||
|
chown -R www-data:www-data backend/data backend/storage # user = php-fpm pool user
|
||||||
|
|
||||||
|
config.php
|
||||||
|
base_path MUST match URL prefix exactly:
|
||||||
|
'base_path' => '/app/androidcast_project/crashes'
|
||||||
|
(empty base_path only for local: php -S -t public)
|
||||||
|
|
||||||
|
INNER NGINX (VM behind reverse proxy)
|
||||||
|
- ONE location for the app prefix only:
|
||||||
|
location ^~ /app/androidcast_project/crashes/ {
|
||||||
|
alias .../backend/public/;
|
||||||
|
try_files + nested .php -> php-fpm socket
|
||||||
|
}
|
||||||
|
- See: examples/crash_reporter/backend/nginx.vm.conf
|
||||||
|
- DO NOT mix proxy_*, try_files, and fastcgi in one broken /app/androidcast_project/ block
|
||||||
|
- DO NOT set root = androidcast_project/ for crash URLs (doubles path on disk)
|
||||||
|
- Optional redirects:
|
||||||
|
/app/androidcast_project/ -> /app/androidcast_project/crashes/
|
||||||
|
- fastcgi_pass: unix:/run/php-fpm.socket (match your install)
|
||||||
|
- client_max_body_size 4m (upload JSON)
|
||||||
|
- Every adb/php-fpm call: use </dev/null if testing from scripts (not required in nginx)
|
||||||
|
|
||||||
|
OUTER REVERSE PROXY (apps.f0xx.org -> artc0 VM)
|
||||||
|
- proxy_pass with full URI preserved
|
||||||
|
- proxy_set_header Host $host;
|
||||||
|
- proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
- TLS terminate at outer nginx
|
||||||
|
|
||||||
|
ANDROID APP (device settings.json)
|
||||||
|
"crash": {
|
||||||
|
"enabled": "true",
|
||||||
|
"upload_url": "https://apps.f0xx.org/app/androidcast_project/crashes/api/upload.php",
|
||||||
|
"prune_after_upload": "true",
|
||||||
|
"upload_interval": "5m"
|
||||||
|
}
|
||||||
|
Enable "Send anonymous crash logs" in app settings.
|
||||||
|
|
||||||
|
SMOKE TESTS
|
||||||
|
curl -vk -H 'Host: apps.f0xx.org' \
|
||||||
|
https://127.0.0.1/app/androidcast_project/crashes/
|
||||||
|
|
||||||
|
curl -vk -H 'Host: apps.f0xx.org' -H 'Content-Type: application/json' \
|
||||||
|
-d '{"schema_version":1,"report_id":"test","generated_at_epoch_ms":1,"crash_type":"java","fingerprint":"x","device":{},"app":{}}' \
|
||||||
|
https://127.0.0.1/app/androidcast_project/crashes/api/upload.php
|
||||||
|
|
||||||
|
Web UI login (change in prod): admin / admin
|
||||||
|
|
||||||
|
COMMON MISTAKES
|
||||||
|
- 400 on GET /app/androidcast_project/ only -> health check wrong path; add redirect or ignore
|
||||||
|
- 500 session_name() -> install php81-session for FPM
|
||||||
|
- Location /crashes/ without /app/androidcast_project/ prefix -> no match behind full URI proxy
|
||||||
|
- "Primary script unknown" in nginx error log:
|
||||||
|
php-fpm got a SCRIPT_FILENAME that does not exist on disk
|
||||||
|
usual cause: fastcgi.conf + alias + try_files (wrong path)
|
||||||
|
fix: examples/crash_reporter/backend/nginx.vm.conf (explicit paths, fastcgi_params)
|
||||||
|
verify: ls .../backend/public/index.php
|
||||||
|
compare: php-fpm pool user can read that file
|
||||||
|
- Pending reports deleted before pull: prune_after_upload uploads then deletes on device
|
||||||
|
|
||||||
|
CURL WHERE
|
||||||
|
127.0.0.1 tests only ON the VM (loopback on that machine)
|
||||||
|
From laptop use: curl -vk https://apps.f0xx.org/app/androidcast_project/crashes/
|
||||||
|
-H Host only needed when URL is 127.0.0.1 but vhost is apps.f0xx.org
|
||||||
|
|
||||||
|
PULL REPORTS FROM DEVICE (laptop)
|
||||||
|
./pull_stats.sh (also pulls log_capture/crash_reports/<serial>/)
|
||||||
|
|
||||||
|
PROD HARDENING (when ready)
|
||||||
|
- Change admin password (users table password_hash)
|
||||||
|
- TLS only, restrict /api/upload.php by firewall if desired
|
||||||
|
- MariaDB: use sql/schema.mariadb.sql + db.driver mysql in config.php
|
||||||
|
- nginx/php-fpm error logs: check on 500, not only access log
|
||||||
|
|
||||||
|
REF IN REPO
|
||||||
|
examples/crash_reporter/backend/README.md
|
||||||
|
examples/crash_reporter/backend/nginx.vm.conf
|
||||||
|
docs/CRASH_REPORTER.md
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
# Inner VM nginx — crash reporter behind apps.f0xx.org reverse proxy.
|
# Inner VM nginx — crash reporter behind reverse proxy.
|
||||||
#
|
#
|
||||||
# Files: .../androidcast_project/backend/public/ (symlink to examples/crash_reporter/backend/public)
|
# Adjust CRASH_PUBLIC to match your deploy (symlink OK):
|
||||||
# URL: https://apps.f0xx.org/app/androidcast_project/crashes/
|
# /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public
|
||||||
# PHP: config.php → 'base_path' => '/app/androidcast_project/crashes'
|
|
||||||
#
|
#
|
||||||
# Outer proxy must pass the full URI and Host (your apps.f0xx.org block already does).
|
# URL prefix must match config.php base_path:
|
||||||
|
# /app/androidcast_project/crashes
|
||||||
|
|
||||||
|
# set this once if your install supports it, else replace paths below
|
||||||
|
# map is optional; explicit paths in each block are clearest
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
# server_name _;
|
|
||||||
|
|
||||||
ssl_certificate "/etc/letsencrypt/archive/apps.f0xx.org/fullchain1.pem";
|
ssl_certificate "/etc/letsencrypt/archive/apps.f0xx.org/fullchain1.pem";
|
||||||
ssl_certificate_key "/etc/letsencrypt/archive/apps.f0xx.org/privkey1.pem";
|
ssl_certificate_key "/etc/letsencrypt/archive/apps.f0xx.org/privkey1.pem";
|
||||||
@@ -16,32 +18,56 @@ server {
|
|||||||
access_log /var/log/nginx/apps.intra.raptor.org.access_log main;
|
access_log /var/log/nginx/apps.intra.raptor.org.access_log main;
|
||||||
error_log /var/log/nginx/apps.intra.raptor.org.error_log warn;
|
error_log /var/log/nginx/apps.intra.raptor.org.error_log warn;
|
||||||
|
|
||||||
# Do NOT set root to androidcast_project/ — that doubles the /app/androidcast_project/ prefix on disk.
|
# --- adjust only this directory root on disk ---
|
||||||
|
# CRASH_PUBLIC = .../backend/public
|
||||||
|
|
||||||
# --- Crash reporter (only this block handles the app) ---
|
location = /app/androidcast_project/crashes {
|
||||||
location ^~ /app/androidcast_project/crashes/ {
|
return 301 /app/androidcast_project/crashes/;
|
||||||
alias /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/;
|
|
||||||
index index.php;
|
|
||||||
try_files $uri $uri/ @crash_php;
|
|
||||||
|
|
||||||
location ~ \.php$ {
|
|
||||||
include /etc/nginx/fastcgi.conf;
|
|
||||||
fastcgi_pass unix:/run/php-fpm.socket;
|
|
||||||
# $request_filename is correct when alias ends with / and location ends with /
|
|
||||||
fastcgi_param SCRIPT_FILENAME $request_filename;
|
|
||||||
client_max_body_size 4m;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location @crash_php {
|
# Directory URL -> index.php (avoids try_files + alias bugs)
|
||||||
include /etc/nginx/fastcgi.conf;
|
location = /app/androidcast_project/crashes/ {
|
||||||
|
include fastcgi_params;
|
||||||
fastcgi_pass unix:/run/php-fpm.socket;
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/index.php;
|
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/index.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
|
client_max_body_size 4m;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /app/androidcast_project/crashes/index.php {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/index.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
|
client_max_body_size 4m;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /app/androidcast_project/crashes/api/upload.php {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/api/upload.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/upload.php;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
|
client_max_body_size 4m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static assets (css/js)
|
||||||
|
location ^~ /app/androidcast_project/crashes/assets/ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/assets/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Any other .php under the prefix (if added later)
|
||||||
|
location ~ ^/app/androidcast_project/crashes/(.+\.php)$ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/$1;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $request_filename;
|
||||||
fastcgi_param REQUEST_URI $request_uri;
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
client_max_body_size 4m;
|
client_max_body_size 4m;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Optional: parent path → crashes (stops 400 probes on /app/androidcast_project/)
|
|
||||||
location = /app/androidcast_project {
|
location = /app/androidcast_project {
|
||||||
return 301 /app/androidcast_project/crashes/;
|
return 301 /app/androidcast_project/crashes/;
|
||||||
}
|
}
|
||||||
@@ -49,14 +75,17 @@ server {
|
|||||||
return 301 /app/androidcast_project/crashes/;
|
return 301 /app/androidcast_project/crashes/;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Other PHP on this host (outside crash app)
|
|
||||||
location ~ \.php$ {
|
|
||||||
root /var/www/localhost/htdocs/apps/app/androidcast_project;
|
|
||||||
include /etc/nginx/fastcgi.conf;
|
|
||||||
fastcgi_pass unix:/run/php-fpm.socket;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ /\. {
|
location ~ /\. {
|
||||||
deny all;
|
deny all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# WHY "Primary script unknown":
|
||||||
|
# - include fastcgi.conf often sets SCRIPT_FILENAME=$document_root$fastcgi_script_name
|
||||||
|
# - That breaks under "alias" and subpath URLs -> php-fpm looks for a file that does not exist
|
||||||
|
# - Fix: use fastcgi_params + explicit SCRIPT_FILENAME absolute path (above)
|
||||||
|
#
|
||||||
|
# Verify on VM:
|
||||||
|
# ls -la /var/www/.../backend/public/index.php
|
||||||
|
# ls -la /var/www/.../backend/public/api/upload.php
|
||||||
|
# sudo -u nginx test -r .../index.php # user = php-fpm pool user
|
||||||
|
|||||||
Reference in New Issue
Block a user