# 5.1 声道改造 — 代码变更清单与复用指南 > 用途:当前验证代码直接改在 main 上(v1.3.32 → v1.3.35),后续需从 main 单独建 5.1 渠道分支重新套用本文档的变更。 > 验证设备:Amlogic Android 14 盒子(172.16.100.166,曾用 184/175),外接功放 HDMI。 > 验证版本:v1.3.35 aishiRemoteRelease,已确认 5.1/2.0 切换、AC3 直通正常。 --- ## 0. 结论速览 | 项 | 结论 | |---|---| | 播放策略 | 三级:AC3 源码直通 > 平台硬解(6ch LPCM) > FFmpeg 软解(下混 2.0);不支持直通时回退普通 1080P AAC 档 | | 渲染器模式 | `EXTENSION_RENDERER_MODE_ON`(平台优先,FFmpeg 仅兜底)——**不能用 PREFER**(会全量软解,直通不可达) | | 系统设置 | 该固件 `encoded_surround_output` 必须设 **0 (AUTO)**,**不能设 2 (ALWAYS)**(详见 §3,这是本次最大的坑) | | 切换生效 | 切 5.1 开关后无需杀 app,重新起播(换集/退出播放页重进)即生效;播放中不热切(5.1 档与 2.0 档是两条 CDN 流) | | CDN 结构 | 档位因片而异。新片:720P×2 + 1080P AAC 2.0 + 1080P H265 AC3 5.1;老片可能只有 720P + 5.1 档 | | 5.1 流形态 | 单 AC3 音轨、**无 2.0 兜底音轨**,不支持直通时必须整条换流(不可同流切音轨) | | 注入原理 | **v1.3.36 关键修复**:`DefaultAudioSink.Builder()` 必须**无参构造**——传 context 时首次 `getFormatSupport()` 会懒注册 AudioCapabilitiesReceiver 用系统 caps **覆盖注入值**(反编译 2.19.1 字节码确认),v1.3.35 的注入从未生效。随 v1.3.35 版本号发布 | | 直通降级 | 注入会话中音频初始化类错误(1000/4001/5001)自动触发:进程级禁用注入 + 重建播放器软解重试(保留进度,一次为限),播放不中断 | | 音量边界 | 直通后 App 无法干预响度(raw 流不解码不混音,响度由接收端解码决定),与软解路径间存在感知音量差——直通固有特性,非缺陷 | --- ## 1. 代码变更清单(10 个文件) ### 1.1 [新增] `app/src/main/java/com/pukka/jiecheng/utils/Surround51Manager.java` 5.1 能力检测与播放开关的唯一入口。 ```java package com.pukka.jiecheng.utils; import android.content.Context; import android.media.MediaCodecInfo; import android.media.MediaCodecList; import android.os.Build; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.audio.AudioCapabilities; import com.pukka.libcore.utils.LogUtils; import com.pukka.libcore.utils.SharedPreferenceUtils; /** * 5.1 环绕声能力检测与播放开关。 *
开关(默认开启)关闭时不选 5.1 独立档(H265+AC3_51),播放普通 2.0 档; * 开启时按设备能力自动选择输出路径:AC3直通(HDMI bitstream) > 平台硬解(6ch LPCM) > FFmpeg软解(下混 2.0)。
*CDN 侧 5.1 档为单 AC3 音轨流(无 2.0 兜底音轨),故能力不足或开关关闭时必须回退普通档 URL。
*/ public final class Surround51Manager { private static final String TAG = "Surround51Manager"; /** SP 键:5.1 环绕声播放开关(设备信息页可切换) */ private static final String SP_KEY_51_ENABLED = "Surround51Enabled"; private static volatile Boolean sAc3Passthrough; private static volatile Boolean sPlatformAc3Decoder; private Surround51Manager() { } /** 5.1 环绕声播放开关,默认开启 */ public static boolean is51Enabled() { return SharedPreferenceUtils.getBoolean(SP_KEY_51_ENABLED, true); } public static void set51Enabled(boolean enabled) { SharedPreferenceUtils.put(SP_KEY_51_ENABLED, enabled); clearCache(); } /** 清除静态缓存(开关切换后需重新检测) */ public static void clearCache() { sAc3Passthrough = null; sPlatformAc3Decoder = null; } /** * 是否优先选用 5.1 独立档:开关开启 且 Android 5.1(API 22) 以上。 * API 22 及以下设备解码能力有限,固定走普通 2.0 档。 */ public static boolean is51Preferred() { return is51Enabled() && Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1; } /** 判断 SDK 返回档位是否 5.1 环绕声档(format='1080P 5.1 环绕声') */ public static boolean isSurroundFormat(String format) { String fmt = format == null ? "" : format; return fmt.contains("5.1") || fmt.contains("环绕声"); } /** * 设备是否支持 AC3 源码直通(HDMI/SPDIF bitstream)。 * 结果受系统编码环绕声设置(encoded_surround_output)与当前接入的音频设备能力影响。 */ public static boolean supportsAc3Passthrough(Context context) { if (sAc3Passthrough == null) { try { sAc3Passthrough = AudioCapabilities.getCapabilities(context) .supportsEncoding(C.ENCODING_AC3); } catch (Throwable e) { LogUtils.d(TAG, "supportsAc3Passthrough err:" + e); sAc3Passthrough = false; } } return sAc3Passthrough; } /** 平台是否自带 AC3 硬解码器(如带 Dolby/MS12 授权的芯片),硬解输出 6ch LPCM 由系统按输出能力下混 */ public static boolean hasPlatformAc3Decoder() { if (sPlatformAc3Decoder == null) { boolean found = false; try { for (MediaCodecInfo info : new MediaCodecList(MediaCodecList.ALL_CODECS).getCodecInfos()) { // v1.3.36: isAlias() 是 API 29+ 方法,API 28 及以下调用抛 NoSuchMethodError //(腾讯T5/Android 9 实测),会中断整个循环导致检测永远为"无解码器" if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && info.isAlias()) || info.isEncoder()) { continue; } for (String type : info.getSupportedTypes()) { if ("audio/ac3".equalsIgnoreCase(type)) { found = true; break; } } if (found) { break; } } } catch (Throwable e) { LogUtils.d(TAG, "hasPlatformAc3Decoder err:" + e); found = false; } sPlatformAc3Decoder = found; } return sPlatformAc3Decoder; } /** 当前设备 5.1 音频输出路径描述(设备信息页展示用) */ public static String getAudioPathDesc(Context context) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) { return "不支持(系统版本过低)"; } if (supportsAc3Passthrough(context)) { return "AC3源码直通"; } if (hasPlatformAc3Decoder()) { return "硬解码"; } return "软解"; } } ``` 注意事项: - `MediaCodecList` 遍历用 `new MediaCodecList(MediaCodecList.ALL_CODECS)` 构造(API 21+);`MediaCodecList.CodecInfo` 不是内部类,正确类型是独立的 `android.media.MediaCodecInfo`。 - `mediaplayertv` 模块不能依赖此类的选流方法(app 模块),但能力检测逻辑被 §1.2 以直接读 SP + 内联方式复用。 ### 1.2 [修改] `mediaplayertv/src/main/java/com/pukka/videoplayertv/exoplayer/PkExoMediaPlayer.java` **改动 1(v1.3.33)**:`initPlayer()` 中渲染器模式 `EXTENSION_RENDERER_MODE_PREFER` → `EXTENSION_RENDERER_MODE_ON`。原因:PREFER 让 FFmpeg 渲染器全量优先(含 AC3 软解为 PCM),直通永远不可达;ON 平台优先、FFmpeg 仅兜底。 **改动 2(v1.3.35)**:`initPlayer()` 中 `DefaultRenderersFactory` 改为匿名子类,重写 `buildAudioSink()`,在系统未报告 AC3 但 5.1 开关开启且系统未禁止环绕声时,强制注入含 AC3 的 `AudioCapabilities`。新增 import: ```java import android.provider.Settings; import com.google.android.exoplayer2.audio.AudioCapabilities; import com.google.android.exoplayer2.audio.AudioSink; import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.pukka.libcore.utils.SharedPreferenceUtils; ``` `initPlayer()` 中创建播放器的完整替换代码: ```java // 增加缓冲区大小 DefaultLoadControl mLoadControl = new DefaultLoadControl.Builder() .setBufferDurationsMs(50000, 50000, 1500, 2000) .build(); if (mRenderersFactory == null) { mRenderersFactory = new DefaultRenderersFactory(mAppContext) { @Override protected AudioSink buildAudioSink(Context context, boolean enableFloatOutput, boolean enableAudioTrackPlaybackParams, boolean enableOffload) { AudioCapabilities caps = AudioCapabilities.getCapabilities(context); boolean ac3Supported = caps.supportsEncoding(C.ENCODING_AC3); // 5.1 开关开启且系统未禁止环绕声时,强制注入 AC3 直通能力。 // 修复:HAL 从 PCM 切回 AUTO 有延迟,getDirectPlaybackSupportedEncodings() // 可能返回过时结果,导致 ExoPlayer 不尝试 AC3 直通。 if (!ac3Supported) { boolean surroundEnabled = SharedPreferenceUtils.getBoolean("Surround51Enabled", true); if (surroundEnabled) { int encodedSurround = Settings.Global.getInt( context.getContentResolver(), "encoded_surround_output", 0); // encoded_surround_output: 0=AUTO, 1=NEVER, 2=ALWAYS if (encodedSurround != 1) { LogUtils.d(TAG, "buildAudioSink: 系统未报告AC3,强制注入。" + " encodedSurround=" + encodedSurround); caps = new AudioCapabilities( new int[]{C.ENCODING_PCM_16BIT, C.ENCODING_AC3}, 8); } } } else { LogUtils.d(TAG, "buildAudioSink: 系统已报告AC3直通能力"); } return new DefaultAudioSink.Builder(context) .setAudioCapabilities(caps) .setEnableFloatOutput(enableFloatOutput) .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) .setOffloadMode(enableOffload ? DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_NOT_REQUIRED : DefaultAudioSink.OFFLOAD_MODE_DISABLED) .build(); } }.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON); } ExoPlayer mInternalPlayer = new ExoPlayer.Builder( mAppContext, mRenderersFactory, new DefaultMediaSourceFactory(mAppContext), mTrackSelector == null ? mTrackSelector = new DefaultTrackSelector(mAppContext) : mTrackSelector, mLoadControl, DefaultBandwidthMeter.getSingletonInstance(mAppContext), new DefaultAnalyticsCollector(Clock.DEFAULT)) .build(); ``` **改动 3(v1.3.36,随 v1.3.35 版本号发布:修复注入失效 + 自动降级重试)** 字段(类顶部新增): ```java /** 进程级禁用 AC3 caps 注入:直通初始化失败自动降级后置位,本进程内不再注入(设备级失败,重试无意义) */ private static volatile boolean sAc3InjectionDisabled = false; /** 当前播放器实例的 AudioSink 是否注入了 AC3 caps(onPlayerError 降级判定用) */ private volatile boolean mAc3Injected = false; ``` `buildAudioSink()` 相对改动 2 的变化:注入条件追加 `!sAc3InjectionDisabled`;注入成功置 `mAc3Injected = true`;三分支日志(已报告/注入/注入已禁用);**`new DefaultAudioSink.Builder(context)` 改为无参 `new DefaultAudioSink.Builder()`(关键修复)**: ```java if (!ac3Supported && !sAc3InjectionDisabled) { boolean surroundEnabled = SharedPreferenceUtils.getBoolean("Surround51Enabled", true); if (surroundEnabled) { int encodedSurround = Settings.Global.getInt( context.getContentResolver(), "encoded_surround_output", 0); if (encodedSurround != 1) { LogUtils.d(TAG, "buildAudioSink: 系统未报告AC3,强制注入。" + " encodedSurround=" + encodedSurround); caps = new AudioCapabilities( new int[]{C.ENCODING_PCM_16BIT, C.ENCODING_AC3}, 8); mAc3Injected = true; } } } else if (ac3Supported) { LogUtils.d(TAG, "buildAudioSink: 系统已报告AC3直通能力"); } else { LogUtils.d(TAG, "buildAudioSink: 注入已禁用(本进程曾直通失败),走系统 caps"); } return new DefaultAudioSink.Builder() // 无参!传 context 会导致注入被系统 caps 覆盖 .setAudioCapabilities(caps) .setEnableFloatOutput(enableFloatOutput) .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) .setOffloadMode(enableOffload ? DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_NOT_REQUIRED : DefaultAudioSink.OFFLOAD_MODE_DISABLED) .build(); ``` `onPlayerError()` 拦截音频初始化类错误自动降级,`degradeAndRetry()` 重建播放器(核心步骤): ```java // onPlayerError 中(notifyOnError 之前): if (mAc3Injected && !sAc3InjectionDisabled) { int code = error.errorCode; if (code == PlaybackException.ERROR_CODE_UNSPECIFIED // 1000 || code == PlaybackException.ERROR_CODE_DECODER_INIT_FAILED // 4001 || code == PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED) { // 5001 LogUtils.d(TAG, "onPlayerError: 注入会话音频初始化失败(code=" + code + "),自动降级为软解重试"); sAc3InjectionDisabled = true; // 进程级一次降级,防循环 degradeAndRetry(error); return; } } // degradeAndRetry 核心:记录进度 → 主线程移除监听并 release → // mRenderersFactory = null(下次 initPlayer 重建 sink,因 sAc3InjectionDisabled=true 不再注入)→ // mAc3Injected = false → initPlayer()/setWakeMode/setOptions/initMediaListener → // setMediaSource(mMediaSource)/prepare()/seekTo(resumePos);任意步骤异常才上抛原错误。 ``` 技术依据(反编译 2.19.1 字节码确认): - **注入失效根因**:`DefaultAudioSink` 私有 `getAudioCapabilities()` 在 `receiver==null && context!=null` 时懒创建 `AudioCapabilitiesReceiver` 并 `register()`,用**系统实时 caps 直接覆盖 `audioCapabilities` 字段**。`Builder(context)` 传 context → 注入值只在构造到首次 `getFormatSupport()` 之间短暂存在,从未参与格式判定;**无参 `Builder()`(context=null)注入才持续生效**。 - `AudioCapabilities.getCapabilities()` **无静态缓存**,每次实时查询;API 29+ TV 走 `AudioManager.getDirectPlaybackSupportedEncodings()`,该查询反映 audio policy/HAL 当前运行时状态,在 HAL 模式切换期间会返回过时结果。 - **`PlaybackException` 位置与命名坑**:类在 **exoplayer-common-2.19.1.aar**(2.18 起模块化拆分,不在 exoplayer-core 的 classes.jar 根包);错误码常量命名是 `ERROR_CODE_*`(**非 `ERRORCODE_*`,编译期找不到符号的坑**)。白名单只收音频初始化类错误(1000/4001/5001),防误降级。 - `MediaCodecAudioRenderer` 直通准入 gate(字节码):`audioSink.supportsFormat(format) && MediaCodecUtil.getDecryptOnlyDecoderInfo() != null`(后者查 `audio/raw` decoder)——注入后有 raw decoder 但 policy 无 DIRECT 输出的设备(如部分 MTK)会在 `AudioTrack(ENCODING_AC3)` 初始化时硬失败,这正是降级重试要防的场景。 - `DefaultRenderersFactory.buildAudioSink()` 是 protected,可被子类重写;`DefaultAudioSink.Builder.setAudioCapabilities()` 是官方注入点。 - SP 键 `"Surround51Enabled"` 与 app 模块 `Surround51Manager.SP_KEY_51_ENABLED` 保持一致(mediaplayertv 依赖 libcore,可直接用 `SharedPreferenceUtils`,两模块读同一 `config.xml`)。 ### 1.3 [修改] `app/src/main/java/com/pukka/jiecheng/utils/JetsenxSdkUtil.java` `getVideoUrls2()`(约 L776)与 `getFreeVideoUrls2()`(约 L841)**两处相同改动**: 1. 主循环普通 1080P 匹配排除 5.1 档: ```java }else if (info.getFormat().contains("1080") &&TextUtils.isEmpty(videoUrl_1080) &&!Surround51Manager.isSurroundFormat(info.getFormat())){ // 普通 1080P 档(排除 5.1 档) ``` 2. SDK 回调尾部新增 5.1 覆盖块(原条件 `Build.VERSION.SDK_INT > LOLLIPOP_MR1` 改为开关判断): ```java if (Surround51Manager.is51Preferred()) { for (VideoInfo info : videoInfos) { if (Surround51Manager.isSurroundFormat(info.getFormat())) { videoUrl_1080 = info.getVideoUrl(); // PlayCodeBean 取码同步覆盖(照原 1080 分支写法) break; } } } ``` 日志锚点:`videoUrl_51 applied: