From 2e04711c8fbdabb0cd5f930551d77350bdf6b875 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Thu, 21 May 2026 17:15:18 +0200 Subject: [PATCH] snap + backend headache --- AV_QUALITY_QA_SESSION.md | 23 +- .../androidcast/AndroidCastApplication.java | 2 + .../com/foxx/androidcast/AppPreferences.java | 54 ++++ .../foxx/androidcast/CastSettingsBinder.java | 18 -- .../androidcast/CastSettingsFragment.java | 14 +- .../DeveloperSettingsActivity.java | 140 ++++++++- .../androidcast/receiver/AudioDecoder.java | 4 + .../receiver/LibvpxVideoDecoder.java | 2 + .../receiver/ReceiverCastService.java | 2 + .../receiver/ReceiverPlaybackActivity.java | 21 +- .../androidcast/receiver/VideoDecoder.java | 2 + .../androidcast/receiver/av/AudioFilter.java | 9 + .../receiver/av/AudioFilterChain.java | 31 ++ .../receiver/av/BassBoostAudioFilter.java | 40 +++ .../receiver/av/DolbySurroundAudioFilter.java | 60 ++++ .../av/GlExperimentalVideoPipeline.java | 33 ++ .../receiver/av/ImmersiveAudioFilter.java | 51 +++ .../receiver/av/PassthroughAudioFilter.java | 9 + .../receiver/av/PassthroughVideoPipeline.java | 13 + .../receiver/av/PcmAudioFilters.java | 43 +++ .../receiver/av/ReceiverAudioPreset.java | 25 ++ .../av/ReceiverAvPipelineFactory.java | 40 +++ .../receiver/av/ReceiverAvRuntime.java | 31 ++ .../receiver/av/ReceiverVideoGlHub.java | 68 ++++ .../receiver/av/ReceiverVideoGlRenderer.java | 293 ++++++++++++++++++ .../receiver/av/ReceiverVideoPipeline.java | 18 ++ .../receiver/av/ReceiverVideoPreset.java | 23 ++ .../res/layout/activity_cast_settings.xml | 11 - .../layout/activity_developer_settings.xml | 70 +++++ .../res/layout/fragment_cast_settings.xml | 11 - .../res/layout/include_app_info_section.xml | 1 + app/src/main/res/values/strings.xml | 15 + .../av/ReceiverAvPipelineFactoryTest.java | 47 +++ backend_ops.txt | 103 ++++++ examples/crash_reporter/backend/nginx.vm.conf | 89 ++++-- 35 files changed, 1328 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/AudioFilter.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/AudioFilterChain.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/BassBoostAudioFilter.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/DolbySurroundAudioFilter.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/GlExperimentalVideoPipeline.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/ImmersiveAudioFilter.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/PassthroughAudioFilter.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/PassthroughVideoPipeline.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/PcmAudioFilters.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAudioPreset.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAvPipelineFactory.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAvRuntime.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoGlHub.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoGlRenderer.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoPipeline.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoPreset.java create mode 100644 app/src/test/java/com/foxx/androidcast/receiver/av/ReceiverAvPipelineFactoryTest.java create mode 100644 backend_ops.txt diff --git a/AV_QUALITY_QA_SESSION.md b/AV_QUALITY_QA_SESSION.md index 2e52522..11e1035 100644 --- a/AV_QUALITY_QA_SESSION.md +++ b/AV_QUALITY_QA_SESSION.md @@ -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. -**Status:** Planning / research only — not implemented. -**Related code (today):** sender `ScreenCastService` → `VideoEncoder` / `AudioEncoder` → network; receiver network → `VideoDecoder` / `LibvpxCapableVideoDecoder` / `AudioDecoder` → `TextureView` / `AudioTrack`. +**Status:** Receiver experimental presets in **Developer settings** — audio PCM chain + video GLES post-decode (immersive / HDR shaders). +**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. --- diff --git a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java index f61693f..3441d12 100644 --- a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java +++ b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java @@ -16,6 +16,7 @@ import android.content.Context; import com.foxx.androidcast.crash.CrashReporter; import com.foxx.androidcast.media.CodecPriorityCatalog; +import com.foxx.androidcast.receiver.av.ReceiverAvRuntime; /** Applies saved theme and locale at process start. */ public class AndroidCastApplication extends Application { @@ -31,5 +32,6 @@ public class AndroidCastApplication extends Application { CastLocaleHelper.applyStoredLocale(this); CastThemeHelper.applyNightMode(this); CrashReporter.install(this); + ReceiverAvRuntime.reload(this); } } diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java index c20b13f..793070e 100644 --- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java +++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java @@ -17,6 +17,9 @@ import android.os.Build; import android.provider.Settings; 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. */ @@ -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_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_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_ENABLED = "receiver_pip_enabled"; private static final String KEY_OTA_CHANNEL_URL = "ota_channel_url"; @@ -257,9 +264,56 @@ public final class AppPreferences { 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) { CastSettings s = new CastSettings(); 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.setQuality(CastSettings.Quality.MEDIUM); if (s.getBitrateMode() == CastSettings.BitrateMode.AUTO) { diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java index 86bf2c8..67f8c22 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java @@ -118,7 +118,6 @@ public final class CastSettingsBinder { bindTransport(activity, settings, R.id.spinner_receiver_transport, true); bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec); bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network); - bindReceiverDisplay(activity, settings); Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio); if (audioPlay != null) { bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity), @@ -145,7 +144,6 @@ public final class CastSettingsBinder { readTransport(activity, settings, R.id.spinner_receiver_transport); readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec); readNetworkAdoption(activity, settings, R.id.spinner_receiver_network); - readReceiverDisplay(activity, settings); Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio); if (audioPlay != null) { 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) { Spinner spinner = activity.findViewById(R.id.spinner_sender_capture_mode); List labels = new ArrayList<>(); diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java index 21099a8..5431a49 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java @@ -156,18 +156,22 @@ public class CastSettingsFragment extends Fragment { TextView commit = view.findViewById(R.id.text_app_commit_value); TextView built = view.findViewById(R.id.text_app_built_value); TextView author = view.findViewById(R.id.text_app_author_value); - View.OnClickListener versionTap = v -> onVersionLabelTapped(); + View.OnClickListener devTap = v -> onDeveloperSettingsTap(); if (version != null) { version.setText(VersionInfo.APP_VERSION_STRING); - version.setOnClickListener(versionTap); + version.setOnClickListener(devTap); } View versionRow = view.findViewById(R.id.row_app_version); if (versionRow != null) { - versionRow.setOnClickListener(versionTap); + versionRow.setOnClickListener(devTap); } TextView versionLabel = view.findViewById(R.id.label_app_version); 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) { 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(); if (now - versionTapWindowStartMs > 2500) { versionTapCount = 0; diff --git a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java index 45770cd..b92e0b7 100644 --- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java +++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java @@ -13,8 +13,11 @@ package com.foxx.androidcast; **********************************************************************/ import android.content.Context; import android.os.Bundle; +import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.CheckBox; +import android.widget.SeekBar; +import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; @@ -23,11 +26,19 @@ import androidx.appcompat.app.AppCompatActivity; import com.foxx.androidcast.media.CodecPriorityCatalog; import com.foxx.androidcast.ota.OtaUpdateChecker; 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.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 { + private Spinner videoPresetSpinner; + private Spinner audioPresetSpinner; + private SeekBar intensitySeek; + private TextView intensityValue; + @Override protected void attachBaseContext(Context 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 codecScores = findViewById(R.id.check_show_codec_priority_scores); 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 -> 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); Toast.makeText(this, summary, Toast.LENGTH_LONG).show(); }); @@ -70,6 +81,14 @@ public class DeveloperSettingsActivity extends AppCompatActivity { AppPreferences.setOtaChannelUrl(this, url); OtaUpdateCoordinator.requestManualCheck(this); }); + + bindAvPresets(); + } + + @Override + protected void onPause() { + saveAvPresets(); + super.onPause(); } @Override @@ -78,11 +97,117 @@ public class DeveloperSettingsActivity extends AppCompatActivity { refreshFromPreferences( findViewById(R.id.check_receiver_debug_overlay), 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( findViewById(R.id.text_ota_installed_version), findViewById(R.id.edit_ota_manifest_url), 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 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 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) { @@ -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), AppPreferences::setShowReceiverDebugOverlay); bindDeveloperCheckbox(codecScores, AppPreferences.isShowCodecPriorityScores(this), AppPreferences::setShowCodecPriorityScores); bindDeveloperCheckbox(sessionStats, AppPreferences.isGrabSessionStats(this), AppPreferences::setGrabSessionStats); + bindDeveloperCheckbox(adaptiveDisplay, AppPreferences.isDevReceiverAdaptiveDisplay(this), + AppPreferences::setDevReceiverAdaptiveDisplay); } private interface BoolPrefWriter { diff --git a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java index b11d207..77eca5f 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java @@ -25,6 +25,8 @@ import android.util.Log; import com.foxx.androidcast.media.AacAdtsHelper; 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.nio.ByteBuffer; @@ -116,6 +118,7 @@ public class AudioDecoder { codec.configure(format, null, null, 0); codec.start(); createAudioTrack(sampleRate, channels); + ReceiverAvRuntime.audio().reset(sampleRate, channels); configured = true; draining = true; retryFrame = null; @@ -291,6 +294,7 @@ public class AudioDecoder { } else { stats.pcmAudibleBlocks++; } + ReceiverAvRuntime.audio().process(pcm, pcm.length, sampleRate, channels); int written = writePcm(pcm); if (written > 0) { stats.pcmBytesWritten += written; diff --git a/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java index ccdfd3c..65096f4 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java @@ -16,6 +16,7 @@ import android.view.Surface; import com.foxx.androidcast.media.codec.VideoDecoderSink; import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; +import com.foxx.androidcast.receiver.av.ReceiverAvRuntime; import java.io.IOException; @@ -59,6 +60,7 @@ public final class LibvpxVideoDecoder implements VideoDecoderSink { return; } if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) { + ReceiverAvRuntime.video().onFrameDecoded(ptsUs); notifyRenderedLocked(); } else if ((++decodeFailLogCount % DECODE_FAIL_LOG_EVERY) == 1) { Log.w(TAG, "vpx decode failed len=" + data.length + " key=" + keyFrame); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index 326d7a1..fd1ea3d 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -757,6 +757,8 @@ public class ReceiverCastService extends Service { reconfiguring = false; decoderWidth = pendingWidth; decoderHeight = pendingHeight; + com.foxx.androidcast.receiver.av.ReceiverAvRuntime.video() + .onConfigured(pendingWidth, pendingHeight); updateStatus(getString(R.string.playback_awaiting_stream)); broadcastStreamStarted(pendingWidth, pendingHeight); Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java index 159da2e..3fd59ad 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -44,6 +44,7 @@ import com.foxx.androidcast.PictureInPictureHelper; import com.foxx.androidcast.ICastReceiverService; import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.R; +import com.foxx.androidcast.receiver.av.ReceiverVideoGlHub; import com.foxx.androidcast.ui.AspectRatioFrameLayout; import com.foxx.androidcast.ui.PlaybackTextureTransform; import com.foxx.androidcast.ui.ZoomPanContainer; @@ -445,6 +446,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { applyTextureFitCenter(); if (surfaceTexture != null) { surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight); + if (surfaceSentToService) { + detachSurface(); + } attachSurfaceIfReady(); } } @@ -520,21 +524,28 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { return; } 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; } catch (RemoteException ignored) { } } private void detachSurface() { - if (!surfaceSentToService || receiverService == null) { + if (!surfaceSentToService && receiverService == null) { + ReceiverVideoGlHub.release(this); return; } - try { - receiverService.detachVideoSurface(); - } catch (RemoteException ignored) { + if (receiverService != null) { + try { + receiverService.detachVideoSurface(); + } catch (RemoteException ignored) { + } } surfaceSentToService = false; + ReceiverVideoGlHub.release(this); } @Override diff --git a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java index e312b31..a70d235 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java @@ -19,6 +19,7 @@ import android.view.Surface; import com.foxx.androidcast.media.CodecNegotiator; import com.foxx.androidcast.media.H264Bitstream; import com.foxx.androidcast.media.codec.VideoDecoderSink; +import com.foxx.androidcast.receiver.av.ReceiverAvRuntime; import java.io.IOException; import java.nio.ByteBuffer; @@ -160,6 +161,7 @@ public class VideoDecoder implements VideoDecoderSink { && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0; c.releaseOutputBuffer(outIndex, render); if (render) { + ReceiverAvRuntime.video().onFrameDecoded(info.presentationTimeUs); notifyFirstFrameLocked(); } } diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/AudioFilter.java b/app/src/main/java/com/foxx/androidcast/receiver/av/AudioFilter.java new file mode 100644 index 0000000..cec0e64 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/AudioFilter.java @@ -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) {} +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/AudioFilterChain.java b/app/src/main/java/com/foxx/androidcast/receiver/av/AudioFilterChain.java new file mode 100644 index 0000000..b066764 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/AudioFilterChain.java @@ -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); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/BassBoostAudioFilter.java b/app/src/main/java/com/foxx/androidcast/receiver/av/BassBoostAudioFilter.java new file mode 100644 index 0000000..27c5980 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/BassBoostAudioFilter.java @@ -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)); + }); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/DolbySurroundAudioFilter.java b/app/src/main/java/com/foxx/androidcast/receiver/av/DolbySurroundAudioFilter.java new file mode 100644 index 0000000..3d3467b --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/DolbySurroundAudioFilter.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/GlExperimentalVideoPipeline.java b/app/src/main/java/com/foxx/androidcast/receiver/av/GlExperimentalVideoPipeline.java new file mode 100644 index 0000000..2363b0e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/GlExperimentalVideoPipeline.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/ImmersiveAudioFilter.java b/app/src/main/java/com/foxx/androidcast/receiver/av/ImmersiveAudioFilter.java new file mode 100644 index 0000000..2688552 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/ImmersiveAudioFilter.java @@ -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))); + }); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/PassthroughAudioFilter.java b/app/src/main/java/com/foxx/androidcast/receiver/av/PassthroughAudioFilter.java new file mode 100644 index 0000000..92c39f8 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/PassthroughAudioFilter.java @@ -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 + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/PassthroughVideoPipeline.java b/app/src/main/java/com/foxx/androidcast/receiver/av/PassthroughVideoPipeline.java new file mode 100644 index 0000000..6e7b23f --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/PassthroughVideoPipeline.java @@ -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. + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/PcmAudioFilters.java b/app/src/main/java/com/foxx/androidcast/receiver/av/PcmAudioFilters.java new file mode 100644 index 0000000..295b55f --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/PcmAudioFilters.java @@ -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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAudioPreset.java b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAudioPreset.java new file mode 100644 index 0000000..696d1dd --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAudioPreset.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAvPipelineFactory.java b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAvPipelineFactory.java new file mode 100644 index 0000000..3247261 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAvPipelineFactory.java @@ -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(); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAvRuntime.java b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAvRuntime.java new file mode 100644 index 0000000..e8b8606 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverAvRuntime.java @@ -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); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoGlHub.java b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoGlHub.java new file mode 100644 index 0000000..c43965c --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoGlHub.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoGlRenderer.java b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoGlRenderer.java new file mode 100644 index 0000000..54be8dd --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoGlRenderer.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoPipeline.java b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoPipeline.java new file mode 100644 index 0000000..86c3663 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoPipeline.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoPreset.java b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoPreset.java new file mode 100644 index 0000000..19bc0d0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/av/ReceiverVideoPreset.java @@ -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; + } + } +} diff --git a/app/src/main/res/layout/activity_cast_settings.xml b/app/src/main/res/layout/activity_cast_settings.xml index 8c669b5..11469df 100644 --- a/app/src/main/res/layout/activity_cast_settings.xml +++ b/app/src/main/res/layout/activity_cast_settings.xml @@ -181,17 +181,6 @@ android:text="@string/settings_section_receiver" android:textStyle="bold" /> - - - - + + + + + + + + + + + + + + + + + + + +