diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54cf5e7..6850901 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,10 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + # 该 action 默认安装 'tools platform-tools',而废弃的 'tools' 包已被 Google + # 从 SDK 仓库下架,sdkmanager 会直接报 "Failed to find package 'tools'" 而失败。 + packages: 'platform-tools' - name: Cache Gradle packages uses: actions/cache@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4f54b4..db2a667 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,10 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + # 该 action 默认安装 'tools platform-tools',而废弃的 'tools' 包已被 Google + # 从 SDK 仓库下架,sdkmanager 会直接报 "Failed to find package 'tools'" 而失败。 + packages: 'platform-tools' - name: Cache Gradle packages uses: actions/cache@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa7cce..f6eac2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,105 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [Unreleased] + +### Added + +- **UDP / RTP 组播播放**:新增 `udp://` 与 `rtp://` 频道支持(`MulticastDataSource`), + 自建数据源可配置 `SO_RCVBUF`(默认 4MB,避免高码率组播丢包花屏)、按 `ConnectivityManager` + 的活动网络选择网卡 join(多网卡盒子 join 错网卡会收不到流)、自动申请 `MulticastLock` + (新增 `CHANGE_WIFI_MULTICAST_STATE` 权限) +- **RTP 剥头与乱序重排**:`RtpPacketUtil` 解析 RTP 固定头(CSRC / 扩展头 / 尾部 padding), + `RtpReorderBuffer` 提供 32 包重排窗口且收包路径零分配;源标注 `rtp://` 但实际推裸 TS 时自动回退透传 +- **组播转单播代理(udpxy)**:设置「组播 / UDP」分类与 Web 管理页均可配置代理前缀, + 配置后 `rtp://239.1.1.1:1234` 自动改写为 `http:///rtp/239.1.1.1:1234`; + 这是绝大多数家宽环境下唯一可行的组播方案 +- **组播地址规范化**:`MulticastUrlUtil` 统一处理 VLC 风格 `udp://@`、`udp://@@` 与 + SSM 源地址段 `rtp://@src@group:port` 等写法 +- 新增单元测试 `MulticastUrlUtilTest`、`RtpPacketUtilTest`、`RtpReorderBufferTest`、 + `SwitchableLoadControlTest` +- 新增文档 `docs/multicast-udp-rtp.md`(配置方式、地址写法、实现要点与排查手册) +- **FFmpeg 软解**:引入 `io.github.anilbeesetti:nextlib-media3ext:1.10.0-0.12.1`, + 补上电视盒子硬解常缺的编码——组播 TS 高频的 MPEG-2 视频与 MP2/AC3/DTS 音频。 + 版本号中的 `1.10.0` 必须与 `media3_version` 一致,升级 Media3 时需同步 +- **解码方式设置**(`PlaybackDecoderMode`):硬解优先(默认)/ 软解优先 / 仅硬解。 + 切换后立即重建播放器并续播当前频道(`RenderersFactory` 只能在 ExoPlayer 构建时指定); + 原生库不可用时设置页会给出提示 +- 全局开启 `setEnableDecoderFallback(true)`:某个 MediaCodec 解码器 configure 失败时 + 依次尝试同一渲染器里的其它 MediaCodec 解码器。注意它不会退到 FFmpeg 渲染器 + (渲染器在 supportsFormat 阶段即已选定),硬解整体不可用时需切到「软解优先」档 +- 新增单元测试 `PlaybackDecoderModeTest` + +### Changed + +- 缓冲策略按流类型分档(`SwitchableLoadControl`):HTTP/HLS 维持原有 35s/90s/6s/15s; + UDP/RTP(含经 udpxy 代理的)改为 8s/30s/1.5s/3s。组播是实时推流,服务端没有可回拉的缓冲, + 厚缓冲只会单纯增加开播等待。两档共用同一内存池,换台时只切阈值、不重建 ExoPlayer +- 直播 TS 解复用调参(`TsExtractor.MODE_SINGLE_PMT` + `FLAG_ALLOW_NON_IDR_KEYFRAMES`) + 经 `MulticastAwareExtractorsFactory` **只作用于 `udp://` / `rtp://`**,起播更快; + HTTP 直连 `.ts` 等保持 Media3 默认——`MODE_SINGLE_PMT` 的「只有一个 PMT」假定 + 对来源不明的 TS(多节目 MPTS、中途重发 PMT)不成立,会导致 PID 映射走样 + (症状为 `PesReader: Unexpected start code prefix`)。HLS 走 + `DefaultHlsExtractorFactory`,两档都不影响它 +- 新增单元测试 `MulticastAwareExtractorsFactoryTest` +- debug 构建挂载 ExoPlayer 官方 `EventLogger`(tag `PlayerManager-ev`), + 可看到掉帧数、渲染器就绪状态、解码器初始化与状态变化原因; + 并新增重缓冲计数日志(`Rebuffer #N ... after only Xms of playback`), + 用于区分「缓冲慢慢耗干」与「起播后几十毫秒就又卡」这两种完全不同的故障 +- 组播收流新增诊断日志,用于区分「断流」与「播放器卡住」:每 10 秒一条吞吐心跳 + (包速率/码率/累计丢包)、收包超时时打印距上一个包的时长与会话累计量、 + `SO_RCVBUF` 被内核压缩时显式告警、关流时输出会话总结 +- **组播断流自动重新入组**(`MulticastStallPolicy`):修复「组播播放数分钟后卡住、 + 退出频道重进才恢复」。成因是 IGMP 成员关系被上游交换机剪掉,重进之所以有效是因为 + 它重新 `joinGroup` 补发了 Membership Report。现在收包超时会原地离组再入组 + (socket 与端口不变),健康的流永不触发,默认最多 2 次、约 9 秒后仍无数据才报错换源。 + 新增单元测试 `MulticastStallPolicyTest` +- `PlayerManager` 新增 `getCurrentResolvedPlaybackUrl()`,与原始配置地址区分,便于排查组播改写 +- release 包限定 ABI 为 `armeabi-v7a` + `arm64-v8a`(Android TV 盒子全是 ARM), + 避免 FFmpeg 原生库把 x86/x86_64 一起带上;debug 包保留全部 ABI 以便模拟器调试。 + release APK 由约 7.7MB 增至约 18.9MB,其中原生库约 10.3MB + +### Fixed + +- **修复「起播成功后卡在缓冲再也不恢复」**:起播超时在 `STATE_READY` 时被取消后从未重新武装, + 之后再进入 `STATE_BUFFERING` 就完全没有定时器看管。实测遇到过组播数据以满码率持续进来 + (8Mbps、`rtpLost` 不再增长)、播放器却永远停在 BUFFERING 的情况。 + 新增重缓冲看门狗:超时后先原地重开当前线路(新解复用器、新采样队列、时间戳重新对齐), + 连续 `MAX_STALL_RECOVERIES`(2)次无效才换源——单线路频道换源等于放弃,应优先原地恢复。 + 看门狗只在成功起播过之后才武装,与起播超时互斥,避免同一时刻两个定时器各做一次动作 +- **组播收包改为独立线程 + 环形缓冲**(`PacketRingBuffer`):原先由 ExoPlayer 的 Loader 线程 + 直接 `receive()`,而那条线程还要做 TS 解复用、写采样队列、分配内存。UDP 是推模式, + 内核缓冲(实测被夹到 512KB,8Mbps 下仅约 0.5 秒)一满就永久丢包,于是解码器一挣扎或 GC + 一停顿就出现突发丢包(实测一次卡顿期间 `rtpLost` 由 0 涨到 71、`disc=11`), + 坏数据又让解码更糟,形成恶性循环。现在收包线程只负责把 socket 抽干, + 默认 2048 个数据报(约 4MB / 8Mbps 下 2.7 秒)的缓冲吸收消费侧抖动; + 缓冲写满时丢最旧的并计入 `ringOverflow` 统计。新增单元测试 `PacketRingBufferTest` +- **修复坏线路无限重开不换源**:恢复预算原先在每次进入 `STATE_READY` 时无条件重置, + 而坏源的典型形态正是「重开 → READY 几十毫秒 → 又卡」,于是每次看门狗都被当作第一次尝试, + `MAX_STALL_RECOVERIES` 永远到不了。改为只有持续播放超过 30 秒才算真正恢复 +- **修复暂停后看门狗把播放重新拉起**:退到后台时 `playWhenReady` 已被置假, + 但已武装的看门狗仍会调用 `playCurrentSource()` 将其重置为真,造成后台偷偷续播。 + 现在 `pause()` 取消看门狗、`resume()` 在仍卡顿时重新武装,看门狗自身也校验 `playWhenReady` +- **修复多网卡设备 join 错网卡收不到流**:原先只认 `getActiveNetwork()`, + 而 IPTV 常接在没有公网的以太网口上,Android 不会把它当作活动网络; + 此时在 WiFi 上 `joinGroup` 会「成功」但一个包都收不到,基于异常的回退永不触发。 + 改为在所有可用组播网卡上一并 join +- **修复 RTP 发送端重启后长时间黑屏**:发送端重启会把序号重置到更小的值, + 落在当前期望值之前的半个序号空间时每个包都被判为迟到,最坏要丢 32767 个包 + (约 43 秒)才会自然追上。现在连续迟到 64 个即按新起点重新同步 +- **修复消费侧提前放弃重入组**:消费侧原先只等 `socketTimeoutMs * 2`(6 秒), + 而收包线程要到约 9 秒才用完重入组预算,最后一次尝试还没来得及收包就被放弃 +- **修复 udpxy 流被写入磁盘缓存**:`LoggingPlaybackDataSource` 对非 `.ts` 请求同样走缓存委托, + 经 udpxy 代理的无界组播流会被持续写入 96MB 缓存并持续淘汰,造成盒子闪存的无谓损耗 +- 修复频道列表惯性滚动时按分组切换导致的崩溃 + (`IllegalStateException: Cannot call removeView(At) within removeView(At)`)。 + 频道列表回收掉带焦点的行时,`ViewGroup.removeViewInternal` 会触发 `rootViewRequestFocus()`, + 焦点从根节点重新分发并落到分组列表项上,同步回调 `onGroupFocused` → + `setAdapter()`,对正在回收中的同一个 RecyclerView 造成重入。 + 新增 `RecyclerViewUpdateGate`,在任一列表处于布局/滚动计算中时把更新推迟到下一帧; + `updateSelectedGroup` 里的 `notifyDataSetChanged` 也受同一道闸门保护 +- 新增单元测试 `RecyclerViewUpdateGateTest` + ## [1.2.1] - 2026-04-13 ### Changed diff --git a/README.md b/README.md index ac88495..3e08b0e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Android TV M3U 直播播放器,使用原生 ExoPlayer 播放,支持多源自 - **多源自动切换** — 同一频道聚合多个播放地址,播放失败时自动尝试下一个源 - **EPG 节目预告** — 自动读取 M3U 中的 `x-tvg-url` 属性,也支持手动配置 XMLTV 地址 - **局域网 Web 管理** — 内置 HTTP 服务器(端口 9978),通过手机/电脑浏览器管理播放源和设置 +- **UDP / RTP 组播** — 支持 `udp://`、`rtp://` 组播频道(含 RTP 剥头与乱序重排);也可配置 udpxy 代理把组播改写为 HTTP 单播,详见 [docs/multicast-udp-rtp.md](docs/multicast-udp-rtp.md) +- **FFmpeg 软解兜底** — 内置 NextLib FFmpeg 解码器,补上盒子硬解常缺的 MPEG-2 视频与 MP2/AC3 音频;可在设置中切换硬解优先 / 软解优先 / 仅硬解 - **遥控器适配** — 完整的 D-Pad 导航支持,数字键直接跳转频道 ## 遥控器操作 @@ -27,7 +29,8 @@ Android TV M3U 直播播放器,使用原生 ExoPlayer 播放,支持多源自 | 组件 | 技术 | |------|------| -| 播放器 | Media3 ExoPlayer 1.10.0-rc01 | +| 播放器 | Media3 ExoPlayer 1.10.0 | +| 软件解码 | NextLib media3ext 1.10.0-0.12.1(FFmpeg) | | TV 界面 | Leanback 1.2.0 | | 数据库 | Room 2.6.1 | | HTTP 服务器 | NanoHTTPD 2.3.1 | diff --git a/app/build.gradle b/app/build.gradle index f5ac4c7..3f8a127 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,6 +24,12 @@ android { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + // NextLib 的 FFmpeg 软解带原生库,四个 ABI 全带会让 APK 多出一倍体积。 + // 发布包只保留 ARM(Android TV 盒子全是 ARM);debug 保留全部 ABI, + // 以便在 x86_64 模拟器上调试。 + ndk { + abiFilters 'armeabi-v7a', 'arm64-v8a' + } } } @@ -40,6 +46,11 @@ dependencies { testImplementation "org.robolectric:robolectric:4.14.1" testImplementation "androidx.test:core:1.6.1" + // FFmpeg 软解扩展(MPEG-2 视频、MP2/AC3/DTS 音频等盒子硬解常缺的编码)。 + // 版本号格式为 -,必须与下面的 media3_version 严格一致, + // 否则运行时会因 Media3 内部 API 变动抛 NoSuchMethodError。 + implementation "io.github.anilbeesetti:nextlib-media3ext:1.10.0-0.12.1" + // Media3 ExoPlayer(stable,升级后请在目标盒子上做播放回归) implementation "androidx.media3:media3-exoplayer:$media3_version" implementation "androidx.media3:media3-exoplayer-hls:$media3_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ef96f5b..ffc96bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,11 @@ + + 设置 +
+ +
+ + +
+

+
diff --git a/app/src/main/java/com/whyun/witv/data/PreferenceManager.java b/app/src/main/java/com/whyun/witv/data/PreferenceManager.java index d116cc2..4de5d02 100644 --- a/app/src/main/java/com/whyun/witv/data/PreferenceManager.java +++ b/app/src/main/java/com/whyun/witv/data/PreferenceManager.java @@ -3,6 +3,9 @@ import android.content.Context; import android.content.SharedPreferences; +import com.whyun.witv.player.MulticastUrlUtil; +import com.whyun.witv.player.PlaybackDecoderMode; + public class PreferenceManager { private static final String PREF_NAME = "witv_prefs"; @@ -16,6 +19,10 @@ public class PreferenceManager { private static final String KEY_SHOW_LOAD_SPEED_OVERLAY = "show_load_speed_overlay"; private static final String KEY_REVERSE_CHANNEL_KEYS = "reverse_channel_keys"; private static final String KEY_SOURCE_SWITCH_TIMEOUT_MS = "source_switch_timeout_ms"; + /** 组播转单播代理(udpxy)前缀,空表示直接收组播 */ + private static final String KEY_UDPXY_PROXY_BASE = "udpxy_proxy_base"; + /** 解码方式:硬解与 FFmpeg 软解的优先级,取值见 {@link PlaybackDecoderMode#getId()} */ + private static final String KEY_PLAYBACK_DECODER_MODE = "playback_decoder_mode"; /** 单线路超时未起播则换源;可选值见 {@link #normalizeSourceSwitchTimeoutMs(long)} */ public static final long DEFAULT_SOURCE_SWITCH_TIMEOUT_MS = 15_000L; @@ -128,6 +135,38 @@ public static long normalizeSourceSwitchTimeoutMs(long ms) { return DEFAULT_SOURCE_SWITCH_TIMEOUT_MS; } + /** + * 组播转单播代理(udpxy)前缀,例如 {@code http://192.168.1.1:4022}。 + * + *

绝大多数家宽环境拿不到运营商组播(IPTV 走独立 VLAN,WiFi 下组播丢包也严重), + * 配上路由器/软路由的 udpxy 后,{@code rtp://239.1.1.1:1234} 会被改写成 + * {@code http://192.168.1.1:4022/rtp/239.1.1.1:1234} 走 HTTP 单播。 + * + * @return 已规范化的前缀;未配置时为空串 + */ + public String getUdpxyProxyBase() { + return prefs.getString(KEY_UDPXY_PROXY_BASE, ""); + } + + /** 存入前统一规范化(补 scheme、去掉末尾 {@code /} 与 {@code /udp}、{@code /rtp})。 */ + public void setUdpxyProxyBase(String base) { + prefs.edit() + .putString(KEY_UDPXY_PROXY_BASE, MulticastUrlUtil.normalizeProxyBase(base)) + .apply(); + } + + /** + * 解码方式。未设置时为 {@link PlaybackDecoderMode#DEFAULT}(硬解优先,软解补位)。 + */ + public PlaybackDecoderMode getPlaybackDecoderMode() { + return PlaybackDecoderMode.fromId(prefs.getString(KEY_PLAYBACK_DECODER_MODE, null)); + } + + public void setPlaybackDecoderMode(PlaybackDecoderMode mode) { + PlaybackDecoderMode effective = mode != null ? mode : PlaybackDecoderMode.DEFAULT; + prefs.edit().putString(KEY_PLAYBACK_DECODER_MODE, effective.getId()).apply(); + } + public static int[] getAllowedSourceTimeoutSeconds() { return new int[]{5, 10, 15, 20, 25, 30}; } diff --git a/app/src/main/java/com/whyun/witv/player/HlsSegmentPrefetcher.java b/app/src/main/java/com/whyun/witv/player/HlsSegmentPrefetcher.java index 836504b..7068f71 100644 --- a/app/src/main/java/com/whyun/witv/player/HlsSegmentPrefetcher.java +++ b/app/src/main/java/com/whyun/witv/player/HlsSegmentPrefetcher.java @@ -677,7 +677,9 @@ public long open(DataSpec dataSpec) throws IOException { if (isTsRequest) { bypassCache = onPlaybackSegmentRequested(dataSpec.uri); } - if (bypassCache) { + // 非 ts 分片的请求可能是无界的连续流(典型是 udpxy 代理后的组播), + // 写进磁盘缓存只会变成持续写入 + 持续淘汰,白白拖垮盒子的闪存。 + if (bypassCache || !isTsRequest) { if (isTsRequest) { recordPlaybackCacheRequest(cacheKey, false, true); } diff --git a/app/src/main/java/com/whyun/witv/player/MulticastAwareDataSourceFactory.java b/app/src/main/java/com/whyun/witv/player/MulticastAwareDataSourceFactory.java new file mode 100644 index 0000000..94fb115 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/MulticastAwareDataSourceFactory.java @@ -0,0 +1,140 @@ +package com.whyun.witv.player; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.media3.common.C; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 按 URI scheme 分流的数据源工厂:{@code udp://} / {@code rtp://} 交给 + * {@link MulticastDataSource},其余(HTTP/HLS/本地文件等)走原有链路。 + * + *

真正的数据源在 {@link DataSource#open} 时才按 scheme 创建,避免每个 HLS 分片的加载任务 + * 都白白分配一套组播收包缓冲。 + */ +@OptIn(markerClass = UnstableApi.class) +public final class MulticastAwareDataSourceFactory implements DataSource.Factory { + + private final DataSource.Factory defaultFactory; + private final DataSource.Factory udpFactory; + private final DataSource.Factory rtpFactory; + + public MulticastAwareDataSourceFactory(DataSource.Factory defaultFactory, + DataSource.Factory udpFactory, + DataSource.Factory rtpFactory) { + this.defaultFactory = defaultFactory; + this.udpFactory = udpFactory; + this.rtpFactory = rtpFactory; + } + + @NonNull + @Override + public DataSource createDataSource() { + return new SchemeRoutingDataSource(defaultFactory, udpFactory, rtpFactory); + } + + private static final class SchemeRoutingDataSource implements DataSource { + + private final DataSource.Factory defaultFactory; + private final DataSource.Factory udpFactory; + private final DataSource.Factory rtpFactory; + private final List transferListeners = new ArrayList<>(); + + @Nullable + private DataSource active; + @Nullable + private Uri openedUri; + + SchemeRoutingDataSource(DataSource.Factory defaultFactory, + DataSource.Factory udpFactory, + DataSource.Factory rtpFactory) { + this.defaultFactory = defaultFactory; + this.udpFactory = udpFactory; + this.rtpFactory = rtpFactory; + } + + @Override + public void addTransferListener(@NonNull TransferListener transferListener) { + transferListeners.add(transferListener); + if (active != null) { + active.addTransferListener(transferListener); + } + } + + @Override + public long open(@NonNull DataSpec dataSpec) throws IOException { + closeActiveQuietly(); + openedUri = dataSpec.uri; + DataSource source = factoryFor(dataSpec.uri).createDataSource(); + for (int i = 0; i < transferListeners.size(); i++) { + source.addTransferListener(transferListeners.get(i)); + } + active = source; + return source.open(dataSpec); + } + + private DataSource.Factory factoryFor(@Nullable Uri uri) { + String scheme = uri != null && uri.getScheme() != null + ? uri.getScheme().toLowerCase(Locale.US) + : ""; + if (MulticastUrlUtil.SCHEME_UDP.equals(scheme)) { + return udpFactory; + } + if (MulticastUrlUtil.SCHEME_RTP.equals(scheme)) { + return rtpFactory; + } + return defaultFactory; + } + + @Override + public int read(@NonNull byte[] buffer, int offset, int length) throws IOException { + return active != null ? active.read(buffer, offset, length) : C.RESULT_END_OF_INPUT; + } + + @Override + @Nullable + public Uri getUri() { + return active != null ? active.getUri() : openedUri; + } + + @NonNull + @Override + public Map> getResponseHeaders() { + return active != null ? active.getResponseHeaders() : Collections.emptyMap(); + } + + @Override + public void close() throws IOException { + openedUri = null; + DataSource source = active; + active = null; + if (source != null) { + source.close(); + } + } + + private void closeActiveQuietly() { + DataSource source = active; + active = null; + if (source != null) { + try { + source.close(); + } catch (IOException ignored) { + } + } + } + } +} diff --git a/app/src/main/java/com/whyun/witv/player/MulticastAwareExtractorsFactory.java b/app/src/main/java/com/whyun/witv/player/MulticastAwareExtractorsFactory.java new file mode 100644 index 0000000..1241f39 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/MulticastAwareExtractorsFactory.java @@ -0,0 +1,92 @@ +package com.whyun.witv.player; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.text.SubtitleParser; + +import java.util.List; +import java.util.Map; + +/** + * 按 URI scheme 选择解复用参数:只有 {@code udp://} / {@code rtp://} 使用直播 TS 调参, + * 其余(HTTP 直连 {@code .ts}、HLS 等)一律保持 Media3 默认。 + * + *

组播档用的是 {@code TsExtractor.MODE_SINGLE_PMT},它假定流里只有一个 PMT,起播更快; + * 但这个假定对「多节目 MPTS」或「播放中途重新下发 PMT」的流不成立,PID 映射会变味, + * 典型症状是 {@code PesReader: Unexpected start code prefix}。运营商组播通常是单节目, + * 吃得消这个假定;而 HTTP 上的 TS 来源五花八门,不该替它做这个假设。 + * + *

{@code BundledExtractorsAdapter} 会调用带 URI 的那个重载,所以这里拿得到地址。 + * 注意经 udpxy 代理后 scheme 变成 http,会落到默认档——内容仍是裸 TS,默认档照样能解, + * 只是少了 SINGLE_PMT 的起播加速。 + */ +@OptIn(markerClass = UnstableApi.class) +final class MulticastAwareExtractorsFactory implements ExtractorsFactory { + + private final ExtractorsFactory multicastFactory; + private final ExtractorsFactory standardFactory; + + MulticastAwareExtractorsFactory(ExtractorsFactory multicastFactory, + ExtractorsFactory standardFactory) { + this.multicastFactory = multicastFactory; + this.standardFactory = standardFactory; + } + + private ExtractorsFactory factoryFor(@Nullable Uri uri) { + return uri != null && MulticastUrlUtil.isMulticastStreamUrl(uri.toString()) + ? multicastFactory + : standardFactory; + } + + @NonNull + @Override + public Extractor[] createExtractors() { + // 没有 URI 可判断时按默认档,宁可慢一点也不要用错假定 + return standardFactory.createExtractors(); + } + + @NonNull + @Override + public Extractor[] createExtractors(@NonNull Uri uri, + @NonNull Map> responseHeaders) { + return factoryFor(uri).createExtractors(uri, responseHeaders); + } + + // 下面几个是 ExtractorsFactory 的配置型方法,必须转发给两个委托, + // 否则只有其中一个会收到 DefaultMediaSourceFactory 下发的配置。 + + @NonNull + @Override + public ExtractorsFactory setSubtitleParserFactory( + @NonNull SubtitleParser.Factory subtitleParserFactory) { + multicastFactory.setSubtitleParserFactory(subtitleParserFactory); + standardFactory.setSubtitleParserFactory(subtitleParserFactory); + return this; + } + + @NonNull + @Override + public ExtractorsFactory experimentalSetTextTrackTranscodingEnabled( + boolean textTrackTranscodingEnabled) { + multicastFactory.experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled); + standardFactory.experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled); + return this; + } + + @NonNull + @Override + public ExtractorsFactory experimentalSetCodecsToParseWithinGopSampleDependencies( + int codecsToParseWithinGopSampleDependencies) { + multicastFactory.experimentalSetCodecsToParseWithinGopSampleDependencies( + codecsToParseWithinGopSampleDependencies); + standardFactory.experimentalSetCodecsToParseWithinGopSampleDependencies( + codecsToParseWithinGopSampleDependencies); + return this; + } +} diff --git a/app/src/main/java/com/whyun/witv/player/MulticastDataSource.java b/app/src/main/java/com/whyun/witv/player/MulticastDataSource.java new file mode 100644 index 0000000..33b1814 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/MulticastDataSource.java @@ -0,0 +1,783 @@ +package com.whyun.witv.player; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.LinkProperties; +import android.net.Network; +import android.net.Uri; +import android.os.SystemClock; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.media3.common.C; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.BaseDataSource; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.UdpDataSource; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.MulticastSocket; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Locale; + +/** + * {@code udp://} / {@code rtp://} 组播与单播收流数据源,内容按 MPEG-TS 交给解复用器。 + * + *

相比 Media3 自带的 {@code UdpDataSource}({@code final},无法扩展)补齐了直播场景必需的几点: + *

    + *
  • 独立收包线程 + 环形缓冲:UDP 是推模式,内核缓冲一满就永久丢包。若由 ExoPlayer 的 + * Loader 线程直接 {@code receive()},那条线程还要做 TS 解复用、写采样队列、分配内存, + * 解码器一挣扎或 GC 一停顿就会突发丢包。见 {@link PacketRingBuffer}。
  • + *
  • 可配置 {@code SO_RCVBUF},并在被内核夹小时告警;
  • + *
  • 在所有可用组播网卡上 join,避免多网卡(WiFi + 以太网)设备 join 错网卡收不到流;
  • + *
  • 自动持有 WiFi 组播锁,断流时自动重新入组(见 {@link MulticastStallPolicy});
  • + *
  • {@code rtp://} 剥 RTP 头并做乱序重排;源实际发的是裸 TS 时自动回退为透传。
  • + *
+ */ +@OptIn(markerClass = UnstableApi.class) +public final class MulticastDataSource extends BaseDataSource { + + private static final String TAG = "MulticastDs"; + + /** 单个数据报最大字节数。常见组播 TS 为 7×188=1316,留足余量以容纳带扩展头的 RTP 包。 */ + public static final int DEFAULT_MAX_PACKET_SIZE = 2048; + /** 收包超时;直播流持续推送,超时即视为断流,不宜太长(Media3 默认 8s 偏长)。 */ + public static final int DEFAULT_SOCKET_TIMEOUT_MS = 3_000; + /** 内核接收缓冲,按 20Mbps × 1.5s 余量取值(常被内核夹小,会告警)。 */ + public static final int DEFAULT_RECEIVE_BUFFER_BYTES = 4 * 1024 * 1024; + /** + * 环形缓冲可缓存的数据报个数。2048 × 2048B = 4MB,8Mbps 下约 2.7 秒, + * 足以吸收 GC 停顿与解码器抖动。 + */ + public static final int DEFAULT_RING_BUFFER_PACKETS = 2048; + + /** MPEG-TS 包同步字节,用于识别标注 rtp:// 但实际推裸 TS 的源 */ + private static final byte TS_SYNC_BYTE = 0x47; + + /** 吞吐统计打印间隔。断流前后的速率变化靠它定位。 */ + private static final long STATS_INTERVAL_MS = 10_000L; + + /** 消费侧等待在收包线程恢复预算之外额外留的余量 */ + private static final long CONSUMER_WAIT_SLACK_MS = 2_000L; + + private final Context appContext; + private final int maxPacketSize; + private final int socketTimeoutMs; + private final int receiveBufferBytes; + private final int ringBufferPackets; + private final boolean rtpMode; + private final MulticastLockHolder lockHolder; + + // --- 仅收包线程访问 --- + private final byte[] packetBuffer; + private final DatagramPacket packet; + private final int[] rtpResult = new int[RtpPacketUtil.RESULT_SIZE]; + @Nullable + private final RtpReorderBuffer reorderBuffer; + @Nullable + private final byte[] readerPayloadBuffer; + private final MulticastStallPolicy stallPolicy = + new MulticastStallPolicy(MulticastStallPolicy.DEFAULT_MAX_REJOIN_ATTEMPTS); + /** rtp:// 源实际发的是裸 TS 时置位,后续按 udp 透传处理 */ + private boolean rawFallback; + private long malformedPackets; + private long lastStatsAtMs; + private long statsWindowPackets; + private long statsWindowBytes; + + // --- 仅消费线程访问 --- + private final byte[] consumerBuffer; + private int currentOffset; + private int currentRemaining; + + // --- 跨线程 --- + @Nullable + private volatile Uri uri; + @Nullable + private volatile MulticastSocket socket; + @Nullable + private volatile PacketRingBuffer ringBuffer; + @Nullable + private volatile Thread readerThread; + @Nullable + private volatile IOException readerError; + private volatile int readerErrorCode = PlaybackException.ERROR_CODE_IO_UNSPECIFIED; + private volatile boolean running; + private volatile long openedAtMs; + private volatile long lastPacketAtMs; + private volatile long totalPackets; + private volatile long totalBytes; + + @Nullable + private InetSocketAddress joinedGroup; + /** 已成功加入组的网卡;open() 之后不再修改,可跨线程安全读取 */ + private volatile List joinedInterfaces = Collections.emptyList(); + private boolean lockAcquired; + private boolean opened; + + public MulticastDataSource(Context context, + boolean rtpMode, + int maxPacketSize, + int socketTimeoutMs, + int receiveBufferBytes, + int ringBufferPackets, + MulticastLockHolder lockHolder) { + super(/* isNetwork= */ true); + this.appContext = context.getApplicationContext(); + this.rtpMode = rtpMode; + this.maxPacketSize = maxPacketSize; + this.socketTimeoutMs = socketTimeoutMs; + this.receiveBufferBytes = receiveBufferBytes; + this.ringBufferPackets = ringBufferPackets; + this.lockHolder = lockHolder; + this.packetBuffer = new byte[maxPacketSize]; + this.packet = new DatagramPacket(packetBuffer, 0, maxPacketSize); + this.consumerBuffer = new byte[maxPacketSize]; + if (rtpMode) { + this.reorderBuffer = + new RtpReorderBuffer(RtpReorderBuffer.DEFAULT_CAPACITY, maxPacketSize); + this.readerPayloadBuffer = new byte[maxPacketSize]; + } else { + this.reorderBuffer = null; + this.readerPayloadBuffer = null; + } + } + + @Override + public long open(@NonNull DataSpec dataSpec) throws UdpDataSource.UdpDataSourceException { + Uri openUri = dataSpec.uri; + uri = openUri; + String host = openUri.getHost(); + int port = openUri.getPort(); + if (host == null || host.isEmpty() || port <= 0) { + throw wrap(new IOException("Invalid multicast URI (host/port missing): " + openUri), + PlaybackException.ERROR_CODE_IO_UNSPECIFIED); + } + transferInitializing(dataSpec); + + MulticastSocket created = null; + try { + InetAddress address = InetAddress.getByName(host); + // 先建未绑定 socket,设好 SO_RCVBUF / SO_REUSEADDR 再 bind + created = new MulticastSocket(null); + created.setReuseAddress(true); + try { + created.setReceiveBufferSize(receiveBufferBytes); + } catch (Exception e) { + Log.w(TAG, "SO_RCVBUF " + receiveBufferBytes + " rejected: " + e.getMessage()); + } + created.bind(new InetSocketAddress(port)); + created.setSoTimeout(socketTimeoutMs); + warnIfReceiveBufferShrunk(created); + + if (address.isMulticastAddress()) { + lockHolder.acquire(); + lockAcquired = true; + InetSocketAddress group = new InetSocketAddress(address, port); + joinMulticastGroup(created, address, group); + joinedGroup = group; + } + socket = created; + created = null; + } catch (SecurityException e) { + closeQuietly(created); + releaseLock(); + throw wrap(new IOException(e), PlaybackException.ERROR_CODE_IO_NO_PERMISSION); + } catch (IOException e) { + closeQuietly(created); + releaseLock(); + throw wrap(e, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED); + } + + if (reorderBuffer != null) { + reorderBuffer.reset(); + } + stallPolicy.reset(); + rawFallback = false; + malformedPackets = 0; + openedAtMs = SystemClock.elapsedRealtime(); + lastPacketAtMs = openedAtMs; + lastStatsAtMs = openedAtMs; + totalPackets = 0; + totalBytes = 0; + statsWindowPackets = 0; + statsWindowBytes = 0; + currentOffset = 0; + currentRemaining = 0; + readerError = null; + + ringBuffer = new PacketRingBuffer(ringBufferPackets, maxPacketSize); + running = true; + Thread thread = new Thread(this::runReaderLoop, "witv-multicast-rx"); + thread.setDaemon(true); + // 收包是硬实时的:晚一点就是永久丢包,优先级给高一档 + thread.setPriority(Thread.MAX_PRIORITY); + readerThread = thread; + thread.start(); + + opened = true; + transferStarted(dataSpec); + Log.i(TAG, String.format(Locale.US, + "Opened %s (rtp=%b, rcvbuf=%d, ring=%d pkts, timeout=%dms, iface=%s)", + openUri, rtpMode, actualReceiveBufferSize(), ringBufferPackets, socketTimeoutMs, + describeJoinedInterfaces())); + return C.LENGTH_UNSET; + } + + // ------------------------------------------------------------------ + // 收包线程 + // ------------------------------------------------------------------ + + /** + * 唯一职责:尽快把 socket 抽干、塞进环形缓冲。这里不做任何可能变慢的事 + * (解复用、采样队列、渲染都在别的线程),否则就失去了独立线程的意义。 + */ + private void runReaderLoop() { + PacketRingBuffer ring = ringBuffer; + MulticastSocket active = socket; + if (ring == null || active == null) { + return; + } + while (running) { + try { + // DatagramPacket 收完会把 length 改成实际长度,每次收包前必须复位 + packet.setLength(maxPacketSize); + active.receive(packet); + int length = packet.getLength(); + if (length <= 0) { + continue; + } + onPacketReceived(length); + dispatchPayload(ring, length); + } catch (SocketTimeoutException e) { + if (!running || !handleReceiveTimeout(e)) { + break; + } + } catch (IOException e) { + if (!running) { + // close() 关掉 socket 导致的异常,属于正常退出 + break; + } + failReader(e, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED); + break; + } catch (RuntimeException e) { + failReader(new IOException(e), PlaybackException.ERROR_CODE_IO_UNSPECIFIED); + break; + } + } + ring.close(); + } + + /** 把刚收到的数据报转成载荷放进环形缓冲(RTP 模式下先剥头重排)。 */ + private void dispatchPayload(PacketRingBuffer ring, int length) { + if (!rtpMode || rawFallback || reorderBuffer == null || readerPayloadBuffer == null) { + ring.offer(packetBuffer, 0, length); + return; + } + if (RtpPacketUtil.parse(packetBuffer, 0, length, rtpResult)) { + reorderBuffer.offer(rtpResult[RtpPacketUtil.RESULT_SEQUENCE], + packetBuffer, + rtpResult[RtpPacketUtil.RESULT_PAYLOAD_OFFSET], + rtpResult[RtpPacketUtil.RESULT_PAYLOAD_LENGTH]); + int ready; + while ((ready = reorderBuffer.poll(readerPayloadBuffer)) > 0) { + ring.offer(readerPayloadBuffer, 0, ready); + } + return; + } + if (packetBuffer[0] == TS_SYNC_BYTE) { + // 源标了 rtp:// 但推的是裸 TS,整条流按 UDP 透传 + Log.i(TAG, "Stream declared rtp:// but carries raw TS; switching to passthrough"); + rawFallback = true; + ring.offer(packetBuffer, 0, length); + return; + } + malformedPackets++; + if (malformedPackets == 1 || malformedPackets % 500 == 0) { + Log.w(TAG, "Dropped malformed RTP packet(s): " + malformedPackets); + } + } + + /** + * @return true 表示已重新入组、收包循环应继续;false 表示判定断流、循环应退出 + */ + private boolean handleReceiveTimeout(SocketTimeoutException e) { + long now = SystemClock.elapsedRealtime(); + Log.w(TAG, String.format(Locale.US, + "No packet for %dms on %s - stream stalled. " + + "Session: %d packets / %d bytes over %dms%s", + now - lastPacketAtMs, uri, totalPackets, totalBytes, now - openedAtMs, + reorderBuffer != null ? ", rtpLost=" + reorderBuffer.getLostPackets() : "")); + // IGMP 成员被剪掉时,重新入组等于补发一个 Membership Report,流会立刻回来。 + // 这正是「重进频道就好了」背后的机制,没理由让用户手动做。 + if (joinedGroup != null && stallPolicy.shouldRejoin() && rejoinMulticastGroup()) { + return true; + } + failReader(e, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT); + return false; + } + + private void failReader(IOException e, int errorCode) { + readerErrorCode = errorCode; + readerError = e; + } + + private void onPacketReceived(int length) { + long now = SystemClock.elapsedRealtime(); + if (stallPolicy.getRejoinCount() > 0) { + Log.i(TAG, "Stream recovered after rejoin: " + uri); + } + stallPolicy.onPacketReceived(); + lastPacketAtMs = now; + totalPackets++; + totalBytes += length; + statsWindowPackets++; + statsWindowBytes += length; + + long windowMs = now - lastStatsAtMs; + if (windowMs < STATS_INTERVAL_MS) { + return; + } + PacketRingBuffer ring = ringBuffer; + // 码率按窗口实际时长算,避免打印间隔抖动导致数字失真 + long kbps = windowMs > 0 ? (statsWindowBytes * 8L) / windowMs : 0L; + Log.i(TAG, String.format(Locale.US, + "Receiving %s: %d pkt/s, %d kbps (session %d packets)%s%s", + uri, statsWindowPackets * 1000L / windowMs, kbps, totalPackets, + reorderBuffer != null + ? String.format(Locale.US, ", rtpLost=%d late=%d disc=%d", + reorderBuffer.getLostPackets(), reorderBuffer.getLatePackets(), + reorderBuffer.getDiscontinuities()) + : "", + ring != null + ? String.format(Locale.US, ", ring=%d/%d overflow=%d", + ring.size(), ring.getCapacity(), ring.getDroppedPackets()) + : "")); + lastStatsAtMs = now; + statsWindowPackets = 0; + statsWindowBytes = 0; + } + + /** + * 在所有可用的组播网卡上加入该组。 + * + *

不能只认 {@code getActiveNetwork()}:IPTV 常接在没有公网的以太网口上,Android 不会把 + * 这种网络当作活动网络;此时在 WiFi 上 {@code joinGroup} 会「成功」(不抛异常),但一个组播包 + * 都收不到,而基于异常的回退永远不会触发。多 join 几张网卡的代价只是几个 IGMP 报文, + * 换来的是多网卡盒子上能真正收到流。 + */ + private void joinMulticastGroup(MulticastSocket target, InetAddress address, + InetSocketAddress group) throws IOException { + List joined = new ArrayList<>(); + for (NetworkInterface ni : collectMulticastInterfaces()) { + try { + target.joinGroup(group, ni); + joined.add(ni); + } catch (IOException e) { + Log.d(TAG, "joinGroup on " + ni.getName() + " failed: " + e.getMessage()); + } + } + if (!joined.isEmpty()) { + joinedInterfaces = Collections.unmodifiableList(joined); + return; + } + // 一张都没成功:回退到由系统路由表选择出口网卡 + Log.w(TAG, "No interface accepted the multicast join; falling back to system routing"); + target.joinGroup(address); + joinedInterfaces = Collections.emptyList(); + } + + /** 候选网卡:活动网络优先(通常就是对的那张),其余可用组播网卡随后。 */ + private List collectMulticastInterfaces() { + List candidates = new ArrayList<>(); + NetworkInterface preferred = resolveActiveMulticastInterface(); + if (preferred != null) { + candidates.add(preferred); + } + try { + Enumeration all = NetworkInterface.getNetworkInterfaces(); + while (all != null && all.hasMoreElements()) { + NetworkInterface ni = all.nextElement(); + if (isUsableMulticastInterface(ni) && !containsByName(candidates, ni)) { + candidates.add(ni); + } + } + } catch (SocketException e) { + Log.w(TAG, "Unable to enumerate network interfaces: " + e.getMessage()); + } + return candidates; + } + + private static boolean containsByName(List list, NetworkInterface ni) { + for (NetworkInterface existing : list) { + if (existing.getName().equals(ni.getName())) { + return true; + } + } + return false; + } + + private static boolean isUsableMulticastInterface(NetworkInterface ni) { + try { + if (ni.isLoopback() || !ni.isUp() || !ni.supportsMulticast()) { + return false; + } + // 没有 IPv4 地址的网卡上 join IPv4 组播没有意义 + Enumeration addresses = ni.getInetAddresses(); + while (addresses.hasMoreElements()) { + if (addresses.nextElement() instanceof Inet4Address) { + return true; + } + } + return false; + } catch (SocketException e) { + return false; + } + } + + /** + * 原地离组再入组,刷新 IGMP 成员关系。socket 与绑定端口都不变,所以只会丢失这期间 + * (微秒级)到达的包。 + * + * @return 是否成功重新入组;失败时调用方应按断流报错 + */ + private boolean rejoinMulticastGroup() { + MulticastSocket active = socket; + InetSocketAddress group = joinedGroup; + if (active == null || group == null) { + return false; + } + Log.i(TAG, String.format(Locale.US, + "Rejoining multicast group %s (attempt %d/%d)", + group.getAddress().getHostAddress(), + stallPolicy.getRejoinCount(), stallPolicy.getMaxRejoinAttempts())); + List interfaces = joinedInterfaces; + // 已经不是成员了也无所谓,下面照样重新 join + leaveGroupQuietly(active, group, interfaces); + if (interfaces.isEmpty()) { + try { + active.joinGroup(group.getAddress()); + return true; + } catch (IOException e) { + Log.w(TAG, "Rejoin failed: " + e.getMessage()); + return false; + } + } + int rejoined = 0; + for (NetworkInterface ni : interfaces) { + try { + active.joinGroup(group, ni); + rejoined++; + } catch (IOException e) { + Log.w(TAG, "Rejoin on " + ni.getName() + " failed: " + e.getMessage()); + } + } + return rejoined > 0; + } + + private static void leaveGroupQuietly(MulticastSocket target, InetSocketAddress group, + List interfaces) { + if (interfaces.isEmpty()) { + try { + target.leaveGroup(group.getAddress()); + } catch (Exception e) { + Log.d(TAG, "leaveGroup failed (ignored): " + e.getMessage()); + } + return; + } + for (NetworkInterface ni : interfaces) { + try { + target.leaveGroup(group, ni); + } catch (Exception e) { + Log.d(TAG, "leaveGroup on " + ni.getName() + " failed (ignored): " + + e.getMessage()); + } + } + } + + /** + * 取当前活动网络对应的网卡。多网卡设备(盒子常同时有 eth0 和 wlan0)如果 join 错网卡, + * 组播流根本到不了 socket。 + */ + @Nullable + private NetworkInterface resolveActiveMulticastInterface() { + try { + ConnectivityManager cm = + (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) { + return null; + } + Network active = cm.getActiveNetwork(); + if (active == null) { + return null; + } + LinkProperties props = cm.getLinkProperties(active); + if (props == null || props.getInterfaceName() == null) { + return null; + } + NetworkInterface ni = NetworkInterface.getByName(props.getInterfaceName()); + if (ni == null) { + return null; + } + if (!ni.supportsMulticast()) { + Log.w(TAG, "Active interface " + ni.getName() + " does not support multicast"); + return null; + } + return ni; + } catch (Exception e) { + Log.w(TAG, "Unable to resolve active multicast interface: " + e.getMessage()); + return null; + } + } + + // ------------------------------------------------------------------ + // 消费线程(ExoPlayer Loader) + // ------------------------------------------------------------------ + + @Override + public int read(@NonNull byte[] buffer, int offset, int readLength) + throws UdpDataSource.UdpDataSourceException { + if (readLength == 0) { + return 0; + } + while (currentRemaining == 0) { + fillFromRing(); + } + int bytesRead = Math.min(currentRemaining, readLength); + System.arraycopy(consumerBuffer, currentOffset, buffer, offset, bytesRead); + currentOffset += bytesRead; + currentRemaining -= bytesRead; + bytesTransferred(bytesRead); + return bytesRead; + } + + private void fillFromRing() throws UdpDataSource.UdpDataSourceException { + PacketRingBuffer ring = ringBuffer; + if (ring == null) { + throw wrap(new IOException("Data source closed"), + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED); + } + // 必须比收包线程的完整恢复过程更长,否则会在它最后一次重新入组还没来得及收到包时 + // 就先行放弃,等于白配了重入组预算 + long waitMs = consumerWaitMs(socketTimeoutMs, stallPolicy.getMaxRejoinAttempts()); + int length; + try { + // 收包线程彻底失败时会写 readerError 并关闭环形缓冲,这里会被立刻唤醒, + // 所以这个超时只是兜底 + length = ring.poll(consumerBuffer, waitMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw wrap(new IOException(e), PlaybackException.ERROR_CODE_IO_UNSPECIFIED); + } + if (length > 0) { + currentOffset = 0; + currentRemaining = length; + return; + } + IOException error = readerError; + if (error != null) { + throw wrap(error, readerErrorCode); + } + if (length == PacketRingBuffer.RESULT_CLOSED) { + throw wrap(new IOException("Multicast receiver stopped"), + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED); + } + throw wrap(new SocketTimeoutException("No multicast data for " + waitMs + "ms"), + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT); + } + + /** + * 消费侧的等待时长:收包线程会先超时一次、再重新入组 {@code maxRejoinAttempts} 次, + * 每次各等一个 socket 超时,所以消费侧至少要等 {@code (maxRejoinAttempts + 1)} 个超时周期, + * 外加一点余量。 + */ + static long consumerWaitMs(int socketTimeoutMs, int maxRejoinAttempts) { + return (long) socketTimeoutMs * (Math.max(0, maxRejoinAttempts) + 1) + + CONSUMER_WAIT_SLACK_MS; + } + + @Override + @Nullable + public Uri getUri() { + return uri; + } + + @Override + public void close() { + running = false; + + MulticastSocket active = socket; + if (active != null) { + if (joinedGroup != null) { + leaveGroupQuietly(active, joinedGroup, joinedInterfaces); + } + // 关闭 socket 会让阻塞中的 receive() 立刻抛异常,收包线程据此退出 + closeQuietly(active); + socket = null; + } + + PacketRingBuffer ring = ringBuffer; + if (ring != null) { + ring.close(); + } + + Thread thread = readerThread; + if (thread != null && thread != Thread.currentThread()) { + try { + thread.join(500L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + readerThread = null; + + joinedGroup = null; + joinedInterfaces = Collections.emptyList(); + releaseLock(); + + if (opened) { + long now = SystemClock.elapsedRealtime(); + Log.i(TAG, String.format(Locale.US, + "Closing after %dms: %d packets / %d bytes, last packet %dms ago%s%s", + now - openedAtMs, totalPackets, totalBytes, now - lastPacketAtMs, + reorderBuffer != null + ? String.format(Locale.US, ", rtpLost=%d late=%d disc=%d malformed=%d", + reorderBuffer.getLostPackets(), reorderBuffer.getLatePackets(), + reorderBuffer.getDiscontinuities(), malformedPackets) + : "", + ring != null ? ", ringOverflow=" + ring.getDroppedPackets() : "")); + } + if (reorderBuffer != null) { + reorderBuffer.reset(); + } + ringBuffer = null; + readerError = null; + currentOffset = 0; + currentRemaining = 0; + uri = null; + if (opened) { + opened = false; + transferEnded(); + } + } + + private String describeJoinedInterfaces() { + List interfaces = joinedInterfaces; + if (interfaces.isEmpty()) { + return "system-routing"; + } + StringBuilder sb = new StringBuilder(); + for (NetworkInterface ni : interfaces) { + if (sb.length() > 0) { + sb.append('+'); + } + sb.append(ni.getName()); + } + return sb.toString(); + } + + private void releaseLock() { + if (lockAcquired) { + lockAcquired = false; + lockHolder.release(); + } + } + + private int actualReceiveBufferSize() { + try { + MulticastSocket active = socket; + return active != null ? active.getReceiveBufferSize() : -1; + } catch (Exception e) { + return -1; + } + } + + /** + * 内核会把 {@code SO_RCVBUF} 夹到 {@code net.core.rmem_max},申请 4MB 实际可能只拿到几百 KB。 + * 这是高码率组播花屏丢包的直接原因,必须显式告警而不是静默接受。 + */ + private void warnIfReceiveBufferShrunk(MulticastSocket target) { + try { + int actual = target.getReceiveBufferSize(); + if (actual < receiveBufferBytes) { + Log.w(TAG, String.format(Locale.US, + "SO_RCVBUF clamped by kernel: requested %d, got %d " + + "(net.core.rmem_max); relying on the %d-packet ring buffer", + receiveBufferBytes, actual, ringBufferPackets)); + } + } catch (Exception e) { + Log.w(TAG, "Unable to read SO_RCVBUF: " + e.getMessage()); + } + } + + private static void closeQuietly(@Nullable MulticastSocket s) { + if (s != null) { + try { + s.close(); + } catch (Exception ignored) { + } + } + } + + private static UdpDataSource.UdpDataSourceException wrap(Throwable cause, int errorCode) { + return new UdpDataSource.UdpDataSourceException(cause, errorCode); + } + + /** 按 scheme 产出 {@link MulticastDataSource} 的工厂。 */ + public static final class Factory implements DataSource.Factory { + private final Context context; + private final boolean rtpMode; + private final MulticastLockHolder lockHolder; + private int maxPacketSize = DEFAULT_MAX_PACKET_SIZE; + private int socketTimeoutMs = DEFAULT_SOCKET_TIMEOUT_MS; + private int receiveBufferBytes = DEFAULT_RECEIVE_BUFFER_BYTES; + private int ringBufferPackets = DEFAULT_RING_BUFFER_PACKETS; + + public Factory(Context context, boolean rtpMode, MulticastLockHolder lockHolder) { + this.context = context.getApplicationContext(); + this.rtpMode = rtpMode; + this.lockHolder = lockHolder; + } + + public Factory setSocketTimeoutMs(int socketTimeoutMs) { + this.socketTimeoutMs = socketTimeoutMs; + return this; + } + + public Factory setReceiveBufferBytes(int receiveBufferBytes) { + this.receiveBufferBytes = receiveBufferBytes; + return this; + } + + public Factory setMaxPacketSize(int maxPacketSize) { + this.maxPacketSize = maxPacketSize; + return this; + } + + public Factory setRingBufferPackets(int ringBufferPackets) { + this.ringBufferPackets = ringBufferPackets; + return this; + } + + @NonNull + @Override + public DataSource createDataSource() { + return new MulticastDataSource(context, rtpMode, maxPacketSize, socketTimeoutMs, + receiveBufferBytes, ringBufferPackets, lockHolder); + } + } +} diff --git a/app/src/main/java/com/whyun/witv/player/MulticastLockHolder.java b/app/src/main/java/com/whyun/witv/player/MulticastLockHolder.java new file mode 100644 index 0000000..aa6aa46 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/MulticastLockHolder.java @@ -0,0 +1,82 @@ +package com.whyun.witv.player; + +import android.content.Context; +import android.net.wifi.WifiManager; +import android.util.Log; + +import androidx.annotation.Nullable; + +/** + * WiFi 组播锁的引用计数持有者。 + * + *

Android 的 WiFi 驱动默认会在网卡层丢掉目的 MAC 不是本机的组播帧,不持有 + * {@link WifiManager.MulticastLock} 时 {@code udp://}/{@code rtp://} 组播一个包都收不到。 + * 组播锁很耗电,所以只在真正收组播时持有,停止播放立即释放。 + */ +public final class MulticastLockHolder { + + private static final String TAG = "MulticastLock"; + private static final String LOCK_TAG = "witv-multicast"; + + private final Context appContext; + + @Nullable + private WifiManager.MulticastLock lock; + private int refCount; + + public MulticastLockHolder(Context context) { + this.appContext = context.getApplicationContext(); + } + + /** 获取组播锁;可重入,与 {@link #release()} 成对调用。 */ + public synchronized void acquire() { + refCount++; + if (refCount > 1) { + return; + } + try { + WifiManager wifiManager = + (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE); + if (wifiManager == null) { + Log.w(TAG, "WifiManager unavailable; multicast may be filtered by the driver"); + return; + } + WifiManager.MulticastLock created = wifiManager.createMulticastLock(LOCK_TAG); + created.setReferenceCounted(false); + created.acquire(); + lock = created; + Log.i(TAG, "Multicast lock acquired"); + } catch (Exception e) { + // 缺少 CHANGE_WIFI_MULTICAST_STATE 或厂商 ROM 异常时不应中断播放: + // 有线网络本来就不需要组播锁。 + Log.w(TAG, "Failed to acquire multicast lock: " + e.getMessage()); + } + } + + /** 释放一次引用,计数归零时真正释放锁。 */ + public synchronized void release() { + if (refCount == 0) { + return; + } + refCount--; + if (refCount > 0) { + return; + } + if (lock != null) { + try { + if (lock.isHeld()) { + lock.release(); + } + Log.i(TAG, "Multicast lock released"); + } catch (Exception e) { + Log.w(TAG, "Failed to release multicast lock: " + e.getMessage()); + } + lock = null; + } + } + + /** 仅供测试/诊断:当前是否真正持有锁 */ + public synchronized boolean isHeld() { + return lock != null && lock.isHeld(); + } +} diff --git a/app/src/main/java/com/whyun/witv/player/MulticastStallPolicy.java b/app/src/main/java/com/whyun/witv/player/MulticastStallPolicy.java new file mode 100644 index 0000000..e9d6330 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/MulticastStallPolicy.java @@ -0,0 +1,57 @@ +package com.whyun.witv.player; + +/** + * 组播断流时「重新入组几次才放弃」的策略。 + * + *

组播转发依赖 IGMP 成员关系:上游路由器周期性发 General Query,主机要回 Membership Report, + * 否则交换机的 IGMP snooping 会把这个组剪掉、停止转发。Query/Report 任一方向丢失 + * (Wi-Fi 省电、AP 不转发组播管理帧、虚拟网络),就表现为「播几分钟后卡住,重进频道立刻恢复」 + * ——重进之所以有效,正是因为它重新 join 了一次,等于补发了一个 Membership Report。 + * + *

所以收包超时时先原地重新入组,而不是直接报错换源。健康的流永远不会触发(只有连续 + * 几秒一个包都没有才会),真正死掉的流则在有限次尝试后照常报错,不会无限挂住。 + */ +final class MulticastStallPolicy { + + /** 默认重入组次数。配合 3 秒收包超时,约 9 秒内仍无数据才判定断流。 */ + static final int DEFAULT_MAX_REJOIN_ATTEMPTS = 2; + + private final int maxRejoinAttempts; + private int consecutiveRejoins; + + MulticastStallPolicy(int maxRejoinAttempts) { + this.maxRejoinAttempts = Math.max(0, maxRejoinAttempts); + } + + /** 收到任意数据即视为已恢复,重置计数。 */ + void onPacketReceived() { + consecutiveRejoins = 0; + } + + /** + * 收包超时时调用。 + * + * @return true 表示应当重新入组后继续等待;false 表示已用尽尝试次数,调用方应报错 + */ + boolean shouldRejoin() { + if (consecutiveRejoins >= maxRejoinAttempts) { + return false; + } + consecutiveRejoins++; + return true; + } + + /** 自上次收到数据以来已经重新入组的次数。 */ + int getRejoinCount() { + return consecutiveRejoins; + } + + int getMaxRejoinAttempts() { + return maxRejoinAttempts; + } + + /** 重新打开数据源时清空状态。 */ + void reset() { + consecutiveRejoins = 0; + } +} diff --git a/app/src/main/java/com/whyun/witv/player/MulticastUrlUtil.java b/app/src/main/java/com/whyun/witv/player/MulticastUrlUtil.java new file mode 100644 index 0000000..bdc02c8 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/MulticastUrlUtil.java @@ -0,0 +1,167 @@ +package com.whyun.witv.player; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Locale; + +/** + * 组播直播地址({@code udp://} / {@code rtp://})的规范化与 udpxy 代理改写。 + * + *

m3u 源里组播地址写法很杂(VLC 风格 {@code udp://@group:port}、SSM 风格 + * {@code udp://@src@group:port}),先统一成 {@code scheme://group:port} 再交给播放器; + * 配置了组播转单播代理(udpxy)时则整条改写成 HTTP 单播地址。 + */ +public final class MulticastUrlUtil { + + public static final String SCHEME_UDP = "udp"; + public static final String SCHEME_RTP = "rtp"; + + private static final String SCHEME_SEPARATOR = "://"; + + private MulticastUrlUtil() { + } + + /** 取小写 scheme;无 {@code ://} 时返回空串。 */ + @NonNull + public static String schemeOf(@Nullable String url) { + if (url == null) { + return ""; + } + String trimmed = url.trim(); + int sep = trimmed.indexOf(SCHEME_SEPARATOR); + if (sep <= 0) { + return ""; + } + return trimmed.substring(0, sep).toLowerCase(Locale.US); + } + + /** 是否为需要本地收流的组播/UDP 地址({@code udp://} 或 {@code rtp://})。 */ + public static boolean isMulticastStreamUrl(@Nullable String url) { + String scheme = schemeOf(url); + return SCHEME_UDP.equals(scheme) || SCHEME_RTP.equals(scheme); + } + + /** + * 规范化组播地址:小写 scheme,去掉 {@code @}(VLC 风格)与 SSM 源地址段。 + * 非组播地址只做 trim 原样返回。 + * + *

+     * udp://@@239.1.1.1:1234      → udp://239.1.1.1:1234
+     * udp://@192.168.1.1@239.1.1.1:1234 → udp://239.1.1.1:1234
+     * RTP://239.1.1.1:1234        → rtp://239.1.1.1:1234
+     * 
+ */ + @NonNull + public static String normalize(@Nullable String url) { + if (url == null) { + return ""; + } + String trimmed = url.trim(); + String scheme = schemeOf(trimmed); + if (!SCHEME_UDP.equals(scheme) && !SCHEME_RTP.equals(scheme)) { + return trimmed; + } + String rest = trimmed.substring(scheme.length() + SCHEME_SEPARATOR.length()); + // SSM / VLC 写法里 group:port 永远在最后一个 '@' 之后;UDP 地址没有合法的 userinfo。 + int lastAt = rest.lastIndexOf('@'); + if (lastAt >= 0) { + rest = rest.substring(lastAt + 1); + } + if (rest.isEmpty()) { + return trimmed; + } + return scheme + SCHEME_SEPARATOR + rest; + } + + /** + * 规范化 udpxy 代理地址:补 {@code http://}、去掉末尾 {@code /} 和用户可能粘贴进来的 + * {@code /udp}、{@code /rtp} 后缀。空输入返回空串表示未配置。 + */ + @NonNull + public static String normalizeProxyBase(@Nullable String base) { + if (base == null) { + return ""; + } + String trimmed = base.trim(); + if (trimmed.isEmpty()) { + return ""; + } + String scheme = schemeOf(trimmed); + String authorityAndPath; + if (scheme.isEmpty()) { + scheme = "http"; + authorityAndPath = trimmed; + } else { + authorityAndPath = trimmed.substring(scheme.length() + SCHEME_SEPARATOR.length()); + } + authorityAndPath = stripTrailingSlashes(authorityAndPath); + String lower = authorityAndPath.toLowerCase(Locale.US); + if (lower.endsWith("/udp") || lower.endsWith("/rtp")) { + authorityAndPath = authorityAndPath.substring(0, authorityAndPath.length() - 4); + authorityAndPath = stripTrailingSlashes(authorityAndPath); + } + // 只剩 scheme(例如用户只填了 "http://")视为未配置 + if (authorityAndPath.isEmpty()) { + return ""; + } + return scheme + SCHEME_SEPARATOR + authorityAndPath; + } + + private static String stripTrailingSlashes(String value) { + int end = value.length(); + while (end > 0 && value.charAt(end - 1) == '/') { + end--; + } + return value.substring(0, end); + } + + /** + * 把已规范化的组播地址改写成 udpxy 单播地址: + * {@code rtp://239.1.1.1:1234} + {@code http://10.0.0.1:4022} + * → {@code http://10.0.0.1:4022/rtp/239.1.1.1:1234}。 + * + * @return 代理地址;入参不是组播地址或代理未配置时返回原地址 + */ + @NonNull + public static String toProxyUrl(@NonNull String normalizedUrl, @Nullable String proxyBase) { + String scheme = schemeOf(normalizedUrl); + if (!SCHEME_UDP.equals(scheme) && !SCHEME_RTP.equals(scheme)) { + return normalizedUrl; + } + String base = normalizeProxyBase(proxyBase); + if (base.isEmpty()) { + return normalizedUrl; + } + String rest = normalizedUrl.substring(scheme.length() + SCHEME_SEPARATOR.length()); + // udpxy 只认 group:port,截掉多余的 path / query + int cut = rest.length(); + for (int i = 0; i < rest.length(); i++) { + char c = rest.charAt(i); + if (c == '/' || c == '?' || c == '#') { + cut = i; + break; + } + } + String groupAndPort = rest.substring(0, cut); + if (groupAndPort.isEmpty()) { + return normalizedUrl; + } + return base + "/" + scheme + "/" + groupAndPort; + } + + /** + * 播放前的统一入口:规范化地址,并在配置了 udpxy 代理时改写成 HTTP 单播。 + * + * @param rawUrl m3u 里的原始地址 + * @param proxyBase udpxy 代理前缀,空表示直接收组播 + */ + @NonNull + public static String resolvePlaybackUrl(@Nullable String rawUrl, @Nullable String proxyBase) { + String normalized = normalize(rawUrl); + if (!isMulticastStreamUrl(normalized)) { + return normalized; + } + return toProxyUrl(normalized, proxyBase); + } +} diff --git a/app/src/main/java/com/whyun/witv/player/PacketRingBuffer.java b/app/src/main/java/com/whyun/witv/player/PacketRingBuffer.java new file mode 100644 index 0000000..a332ab3 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/PacketRingBuffer.java @@ -0,0 +1,123 @@ +package com.whyun.witv.player; + +import androidx.annotation.Nullable; + +/** + * 收包线程与消费线程之间的有界环形缓冲。 + * + *

存在的理由:UDP 组播是推模式,内核接收缓冲一满就永久丢包,没有重传。而 + * {@code SO_RCVBUF} 常被内核夹到 512KB——8Mbps 下只有约 0.5 秒的容错。如果直接由 + * ExoPlayer 的 Loader 线程去 {@code receive()},那条线程还要做 TS 解复用、写采样队列、 + * 分配内存,解码器一挣扎或 GC 一停顿就腾不出手收包,内核立刻开始突发丢包。 + * + *

所以让独立线程只管把 socket 抽干、把载荷塞进这里,消费侧慢一点也只是让这个缓冲变长, + * 不会变成丢包。缓冲满了才丢,而且是我们自己丢、丢得到统计。 + * + *

槽位预分配,收包路径无内存分配。 + */ +final class PacketRingBuffer { + + private final byte[][] slots; + private final int[] slotLengths; + private final int capacity; + private final int maxPacketSize; + + private int head; + private int count; + private boolean closed; + private long droppedPackets; + + /** + * @param capacity 可缓存的数据报个数 + * @param maxPacketSize 单个载荷最大字节数 + */ + PacketRingBuffer(int capacity, int maxPacketSize) { + if (capacity < 1) { + throw new IllegalArgumentException("capacity must be positive: " + capacity); + } + if (maxPacketSize < 1) { + throw new IllegalArgumentException("maxPacketSize must be positive: " + maxPacketSize); + } + this.capacity = capacity; + this.maxPacketSize = maxPacketSize; + this.slots = new byte[capacity][maxPacketSize]; + this.slotLengths = new int[capacity]; + } + + /** + * 放入一个载荷。缓冲已满时丢弃**最旧**的一个——直播场景保新不保旧, + * 而且丢弃发生在我们自己手里,可统计。 + * + * @return true 表示未发生丢弃;false 表示挤掉了一个旧包 + */ + synchronized boolean offer(byte[] src, int offset, int length) { + if (closed || length <= 0 || length > maxPacketSize) { + return !closed; + } + boolean dropped = false; + if (count == capacity) { + // 丢掉最旧的:头指针前移一格,腾出位置 + head = (head + 1) % capacity; + count--; + droppedPackets++; + dropped = true; + } + int writeIndex = (head + count) % capacity; + System.arraycopy(src, offset, slots[writeIndex], 0, length); + slotLengths[writeIndex] = length; + count++; + notifyAll(); + return !dropped; + } + + /** + * 取出下一个载荷,必要时阻塞等待。 + * + * @param dest 目标缓冲区,容量需 >= 构造时的 maxPacketSize + * @param timeoutMs 最长等待毫秒数 + * @return 写入 {@code dest} 的字节数;超时返回 -1;已关闭返回 -2 + */ + synchronized int poll(byte[] dest, long timeoutMs) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (count == 0) { + if (closed) { + return RESULT_CLOSED; + } + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + return RESULT_TIMEOUT; + } + wait(remaining); + } + int length = slotLengths[head]; + System.arraycopy(slots[head], 0, dest, 0, length); + head = (head + 1) % capacity; + count--; + return length; + } + + /** {@link #poll} 超时 */ + static final int RESULT_TIMEOUT = -1; + /** {@link #poll} 时缓冲已关闭且无残留数据 */ + static final int RESULT_CLOSED = -2; + + /** 关闭并唤醒所有等待者。已缓存但未取走的数据仍可继续 poll 出来。 */ + synchronized void close() { + closed = true; + notifyAll(); + } + + /** 因缓冲写满而被挤掉的包数 */ + synchronized long getDroppedPackets() { + return droppedPackets; + } + + /** 当前缓存的包数 */ + synchronized int size() { + return count; + } + + int getCapacity() { + return capacity; + } +} diff --git a/app/src/main/java/com/whyun/witv/player/PlaybackDecoderMode.java b/app/src/main/java/com/whyun/witv/player/PlaybackDecoderMode.java new file mode 100644 index 0000000..931a847 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/PlaybackDecoderMode.java @@ -0,0 +1,62 @@ +package com.whyun.witv.player; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.DefaultRenderersFactory; + +/** + * 解码方式:硬解(MediaCodec)与 FFmpeg 软解(NextLib)之间的优先级。 + * + *

IPTV 直播里硬解覆盖不全是常态——组播 TS 常见的 MPEG-2 视频、MP2/AC3 音频,很多电视盒子 + * 的 MediaCodec 要么不支持,要么声称支持但解出来黑屏/无声。带上 FFmpeg 软解后: + * + *

    + *
  • {@link #AUTO}:硬解优先,硬解不支持该编码时自动用软解补位。默认,绝大多数情况选它。
  • + *
  • {@link #PREFER_SOFTWARE}:软解优先。用于「硬解声称支持但实际解不出来」的问题盒子。
  • + *
  • {@link #HARDWARE_ONLY}:完全不加载 FFmpeg 渲染器,行为与未引入软解时一致。 + * 用于排查软解本身引入的问题,或在极低端 CPU 上避免误用软解。
  • + *
+ */ +@OptIn(markerClass = UnstableApi.class) +public enum PlaybackDecoderMode { + + AUTO("auto", DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON), + PREFER_SOFTWARE("prefer_software", DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER), + HARDWARE_ONLY("hardware_only", DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF); + + public static final PlaybackDecoderMode DEFAULT = AUTO; + + private final String id; + private final int extensionRendererMode; + + PlaybackDecoderMode(String id, int extensionRendererMode) { + this.id = id; + this.extensionRendererMode = extensionRendererMode; + } + + /** 持久化用的稳定标识,不要随枚举改名而变。 */ + @NonNull + public String getId() { + return id; + } + + /** 对应的 {@code DefaultRenderersFactory.EXTENSION_RENDERER_MODE_*}。 */ + public int getExtensionRendererMode() { + return extensionRendererMode; + } + + /** 未知或空标识一律回落到 {@link #DEFAULT},避免老版本或脏数据导致播放不可用。 */ + @NonNull + public static PlaybackDecoderMode fromId(@Nullable String id) { + if (id != null) { + for (PlaybackDecoderMode mode : values()) { + if (mode.id.equals(id)) { + return mode; + } + } + } + return DEFAULT; + } +} diff --git a/app/src/main/java/com/whyun/witv/player/PlayerManager.java b/app/src/main/java/com/whyun/witv/player/PlayerManager.java index 2e4691f..ed77ae3 100644 --- a/app/src/main/java/com/whyun/witv/player/PlayerManager.java +++ b/app/src/main/java/com/whyun/witv/player/PlayerManager.java @@ -4,6 +4,7 @@ import android.net.Uri; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import android.util.Log; import androidx.annotation.NonNull; @@ -18,17 +19,27 @@ import androidx.media3.datasource.DataSource; import androidx.media3.datasource.DefaultDataSource; import androidx.media3.datasource.DefaultHttpDataSource; -import androidx.media3.exoplayer.DefaultLoadControl; import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.RenderersFactory; import androidx.media3.exoplayer.source.BehindLiveWindowException; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter; +import androidx.media3.extractor.DefaultExtractorsFactory; +import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory; +import androidx.media3.extractor.ts.TsExtractor; import androidx.media3.ui.PlayerView; +import androidx.media3.exoplayer.util.EventLogger; + +import com.whyun.witv.BuildConfig; import com.whyun.witv.WiTVApp; import com.whyun.witv.data.PreferenceManager; import com.whyun.witv.data.db.entity.ChannelSource; +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.FfmpegLibrary; +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory; + import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -90,16 +101,55 @@ static boolean isBehindLiveWindowError(@NonNull PlaybackException error) { private ExoPlayer player; @Nullable private HlsSegmentPrefetcher hlsSegmentPrefetcher; + @Nullable + private SwitchableLoadControl loadControl; + @Nullable + private MulticastLockHolder multicastLockHolder; + @Nullable + private PreferenceManager preferenceManager; + /** 当前 ExoPlayer 实例构建时采用的解码方式 */ + @Nullable + private PlaybackDecoderMode activeDecoderMode; private PlayerView playerView; private Callback callback; private List currentSources = new ArrayList<>(); private int currentSourceIndex = 0; + /** 当前线路经规范化/udpxy 改写后真正交给播放器的地址 */ + @Nullable + private String currentResolvedUrl; private boolean isRetrying = false; private int playGeneration = 0; private final Handler handler = new Handler(Looper.getMainLooper()); private Runnable timeoutRunnable; + /** 起播成功后又卡在缓冲的看门狗(见 {@link #onRebufferStall}) */ + private Runnable rebufferWatchdogRunnable; + /** 连续「卡住→原地重开」的次数,用尽后才换源 */ + private int consecutiveStallRecoveries; + /** + * 当前线路是否已经成功起播过。没起播过的缓冲由起播超时({@link #startTimeout})看管, + * 两者不能同时武装,否则同一时刻会各做一次动作。 + */ + private boolean hasStartedPlayback; + /** 当前线路起播后又回到缓冲的次数 */ + private int rebufferCount; + /** 最近一次进入 READY 的时刻,用于算「撑了多久就又卡了」 */ + private long lastReadyAtMs; + + /** + * 原地重开几次仍救不回来才换源。组播只有一条线路时换源等于放弃,所以优先原地恢复。 + */ + static final int MAX_STALL_RECOVERIES = 2; + + /** + * 播放持续这么久才算「真正恢复」,恢复预算才会重置。 + * + *

不能在收到 READY 时就无条件重置:坏源常见的形态正是「重开→READY 几十毫秒→又卡」, + * 那样每次看门狗都被当成第一次尝试,{@link #MAX_STALL_RECOVERIES} 永远到不了, + * 播放器会无限重开同一条坏线路而不换源。 + */ + static final long STALL_RECOVERY_RESET_AFTER_MS = 30_000L; private static String playbackStateName(int state) { switch (state) { @@ -124,16 +174,12 @@ public void onPlaybackStateChanged(int playbackState) { return; } if (playbackState == Player.STATE_READY) { - cancelTimeout(); - isRetrying = false; - Log.i(TAG, "Playback started, source index: " + currentSourceIndex); - if (callback != null) { - callback.onPlaybackStarted(currentSourceIndex, currentSources.size()); - } + onPlaybackReady(); } else if (playbackState == Player.STATE_ENDED) { cancelTimeout(); + cancelRebufferWatchdog(); } else if (playbackState == Player.STATE_BUFFERING) { - Log.d(TAG, "Buffering..."); + onEnteredBuffering(); } } @@ -144,6 +190,7 @@ public void onPlayerError(@NonNull PlaybackException error) { return; } cancelTimeout(); + cancelRebufferWatchdog(); if (isBehindLiveWindowError(error)) { String url = currentSourceIndex < currentSources.size() @@ -176,23 +223,62 @@ public void onPlayerError(@NonNull PlaybackException error) { } }; + /** 进入 {@code STATE_READY}。注意这里**不**重置恢复预算,见 {@link #STALL_RECOVERY_RESET_AFTER_MS}。 */ + void onPlaybackReady() { + cancelTimeout(); + cancelRebufferWatchdog(); + hasStartedPlayback = true; + lastReadyAtMs = SystemClock.elapsedRealtime(); + isRetrying = false; + Log.i(TAG, "Playback started, source index: " + currentSourceIndex); + if (callback != null) { + callback.onPlaybackStarted(currentSourceIndex, currentSources.size()); + } + } + + /** 进入 {@code STATE_BUFFERING}。 */ + void onEnteredBuffering() { + if (hasStartedPlayback) { + long playedMs = SystemClock.elapsedRealtime() - lastReadyAtMs; + rebufferCount++; + if (playedMs >= STALL_RECOVERY_RESET_AFTER_MS) { + // 稳定播放了足够久,这次卡顿与之前的无关,重新给满恢复预算 + consecutiveStallRecoveries = 0; + } + // 「起播后几十毫秒就又卡」说明不是数据不够,而是渲染器 ready 不了 + // (解码跟不上实时等),和「缓冲慢慢耗干」是两种完全不同的故障。 + Log.w(TAG, String.format(Locale.US, + "Rebuffer #%d on source %d/%d after only %dms of playback", + rebufferCount, currentSourceIndex + 1, currentSources.size(), playedMs)); + } else { + Log.d(TAG, "Buffering..."); + } + // 起播超时在 READY 时就被取消了,之后再卡住本来无人看管: + // 数据还在进但播放器消化不动(TS 时间戳错乱等)会无限停在这里。 + startRebufferWatchdog(); + } + public PlayerManager(Context context) { this.context = context; } + /** 懒初始化:测试会绕过 {@link #initialize} 直接注入播放器。 */ + private PreferenceManager preferenceManager() { + if (preferenceManager == null) { + preferenceManager = new PreferenceManager(context); + } + return preferenceManager; + } + @OptIn(markerClass = UnstableApi.class) public void initialize(PlayerView playerView) { this.playerView = playerView; - PreferenceManager preferenceManager = new PreferenceManager(context); - - DefaultLoadControl loadControl = new DefaultLoadControl.Builder() - .setBufferDurationsMs( - 35_000, // minBufferMs - 90_000, // maxBufferMs - 6000, // bufferForPlaybackMs - 15_000 // bufferForPlaybackAfterRebufferMs - ) - .build(); + PreferenceManager preferenceManager = preferenceManager(); + + // HLS 直播与 UDP/RTP 组播的缓冲取舍相反,用一个可切换的 LoadControl 承载两套参数, + // 换源时按地址切换,无需重建 ExoPlayer。 + SwitchableLoadControl loadControl = new SwitchableLoadControl(); + this.loadControl = loadControl; DefaultHttpDataSource.Factory httpDataSourceFactory = new DefaultHttpDataSource.Factory() .setConnectTimeoutMs(HTTP_CONNECT_TIMEOUT_MS) @@ -218,23 +304,129 @@ public void initialize(PlayerView playerView) { DataSource.Factory mediaDataSourceFactory = hlsSegmentPrefetcher != null ? hlsSegmentPrefetcher.getPlaybackDataSourceFactory() : networkDataSourceFactory; - DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, + DefaultDataSource.Factory httpChainFactory = new DefaultDataSource.Factory(context, new M3u8RewritingDataSource.Factory( networkDataSourceFactory, mediaDataSourceFactory, hlsSegmentPrefetcher)); + + // udp:// / rtp:// 由自建组播数据源接管(可配 SO_RCVBUF、按活动网卡 join、RTP 剥头重排), + // 其余 scheme 仍走上面的 HTTP/HLS 链路。 + multicastLockHolder = new MulticastLockHolder(context); + DataSource.Factory dataSourceFactory = new MulticastAwareDataSourceFactory( + httpChainFactory, + new MulticastDataSource.Factory(context, /* rtpMode= */ false, multicastLockHolder), + new MulticastDataSource.Factory(context, /* rtpMode= */ true, multicastLockHolder)); + + // 直播 TS 调参只给 udp:// / rtp:// 用:单节目模式起播更快、允许非 IDR 关键帧, + // 但 MODE_SINGLE_PMT 的「只有一个 PMT」假定对 HTTP 上来源不明的 TS 并不安全。 + // HLS 走的是 DefaultHlsExtractorFactory,两档都不影响它。 + ExtractorsFactory extractorsFactory = new MulticastAwareExtractorsFactory( + new DefaultExtractorsFactory() + .setTsExtractorMode(TsExtractor.MODE_SINGLE_PMT) + .setTsExtractorFlags( + DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES), + new DefaultExtractorsFactory()); + DefaultMediaSourceFactory mediaSourceFactory = - new DefaultMediaSourceFactory(dataSourceFactory); + new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory); DefaultBandwidthMeter bandwidthMeter = WiTVApp.getInstance().getOrCreateBandwidthMeter(); player = new ExoPlayer.Builder(context) .setLoadControl(loadControl) .setBandwidthMeter(bandwidthMeter) .setMediaSourceFactory(mediaSourceFactory) + .setRenderersFactory(buildRenderersFactory(preferenceManager)) .build(); playerView.setPlayer(player); player.addListener(playerListener); + if (BuildConfig.DEBUG) { + // 掉帧数、解码器初始化、状态变化原因、带宽——排查「解码跟不上实时」所需的信息 + // 只有官方 EventLogger 有。日志很吵,所以只在 debug 构建挂。 + player.addAnalyticsListener(new EventLogger(TAG + "-ev")); + } + } + + /** + * 组装渲染器工厂:在平台 MediaCodec 之外挂上 NextLib 的 FFmpeg 软解。 + * + *

组播 TS 常见的 MPEG-2 视频与 MP2/AC3 音频,不少电视盒子的硬解不支持或实现有问题, + * 没有软解兜底就是黑屏或无声。 + */ + @OptIn(markerClass = UnstableApi.class) + private RenderersFactory buildRenderersFactory(PreferenceManager preferenceManager) { + PlaybackDecoderMode mode = preferenceManager.getPlaybackDecoderMode(); + activeDecoderMode = mode; + Log.i(TAG, String.format(Locale.US, + "Decoder mode: %s (extensionRendererMode=%d), ffmpeg=%s", + mode.getId(), mode.getExtensionRendererMode(), ffmpegLibrarySummary())); + return new NextRenderersFactory(context) + .setExtensionRendererMode(mode.getExtensionRendererMode()) + // 某个 MediaCodec 解码器 configure 失败时,依次尝试同一渲染器里的其它 + // MediaCodec 解码器(例如 c2.android.avc.decoder 失败后试 c2.qti.avc.decoder)。 + // 注意它**不会**退到 FFmpeg 渲染器——渲染器早在 supportsFormat 阶段就选定了; + // 硬解整体不可用时请用「软解优先」档。 + .setEnableDecoderFallback(true); + } + + private static String ffmpegLibrarySummary() { + try { + if (!FfmpegLibrary.isAvailable()) { + return "unavailable"; + } + String version = FfmpegLibrary.getVersion(); + return version != null ? version : "available"; + } catch (Throwable t) { + // 原生库缺失/ABI 不匹配时不应让播放器构建失败 + return "load-failed: " + t.getClass().getSimpleName(); + } + } + + /** 构建播放器时生效的解码方式;未初始化时为 null。 */ + @Nullable + public PlaybackDecoderMode getActiveDecoderMode() { + return activeDecoderMode; + } + + /** + * 解码方式变更后重建播放器并续播当前频道。 + * + *

{@code RenderersFactory} 只能在 {@code ExoPlayer} 构建时指定,之后改不了,所以必须重建。 + * 调用方需要在前后重新挂载自己加在 {@code ExoPlayer} 上的监听器 + * (见 {@code PlayerActivity.onPlaybackDecoderModeChanged})。 + * + * @return 是否真的重建了(未初始化过则返回 false) + */ + public boolean reinitializeForDecoderModeChange() { + if (playerView == null) { + return false; + } + List sources = new ArrayList<>(currentSources); + int index = currentSourceIndex; + + cancelTimeout(); + // 让旧播放器的残留回调失效 + playGeneration++; + if (hlsSegmentPrefetcher != null) { + hlsSegmentPrefetcher.release(); + hlsSegmentPrefetcher = null; + } + if (player != null) { + player.removeListener(playerListener); + player.release(); + player = null; + } + + initialize(playerView); + + if (!sources.isEmpty()) { + currentSources = sources; + currentSourceIndex = Math.max(0, Math.min(index, sources.size() - 1)); + isRetrying = false; + playCurrentSource(); + } + return true; } public void setCallback(Callback callback) { @@ -244,6 +436,10 @@ public void setCallback(Callback callback) { public void playChannel(List sources) { playGeneration++; cancelTimeout(); + cancelRebufferWatchdog(); + consecutiveStallRecoveries = 0; + hasStartedPlayback = false; + rebufferCount = 0; stopPlayer(); if (sources == null || sources.isEmpty()) { @@ -261,6 +457,7 @@ public void playChannel(List sources) { private void playCurrentSource() { if (currentSourceIndex >= currentSources.size()) { isRetrying = false; + currentResolvedUrl = null; if (hlsSegmentPrefetcher != null) { hlsSegmentPrefetcher.onPlaybackSourceChanged(Uri.EMPTY); } @@ -274,25 +471,50 @@ private void playCurrentSource() { callback.onSourceSwitching(currentSourceIndex, currentSources.size()); } - String url = currentSources.get(currentSourceIndex).url; - Log.i(TAG, String.format(Locale.US, "Trying source %d/%d: %s", - currentSourceIndex + 1, currentSources.size(), url)); + String rawUrl = currentSources.get(currentSourceIndex).url; + // 组播地址先规范化(去掉 VLC 风格的 @ / SSM 源地址段),配了 udpxy 则改写成 HTTP 单播 + boolean multicastOrigin = MulticastUrlUtil.isMulticastStreamUrl(rawUrl); + String url = MulticastUrlUtil.resolvePlaybackUrl(rawUrl, preferenceManager().getUdpxyProxyBase()); + currentResolvedUrl = url; + if (multicastOrigin && !url.equals(rawUrl)) { + Log.i(TAG, String.format(Locale.US, "Trying source %d/%d: %s (via udpxy: %s)", + currentSourceIndex + 1, currentSources.size(), rawUrl, url)); + } else { + Log.i(TAG, String.format(Locale.US, "Trying source %d/%d: %s", + currentSourceIndex + 1, currentSources.size(), url)); + } Uri uri = Uri.parse(url); if (hlsSegmentPrefetcher != null) { hlsSegmentPrefetcher.onPlaybackSourceChanged(uri); } + hasStartedPlayback = false; + rebufferCount = 0; + cancelRebufferWatchdog(); player.stop(); player.clearMediaItems(); - MediaItem mediaItem = buildMediaItem(url); + // 组播(含经 udpxy 代理的)是实时流,没有可回拉的服务端缓冲,必须用低延迟缓冲档; + // 必须在 prepare() 之前切换。 + if (loadControl != null) { + loadControl.setProfile(multicastOrigin + ? SwitchableLoadControl.Profile.MULTICAST + : SwitchableLoadControl.Profile.STREAMING); + } + + MediaItem mediaItem = buildMediaItem(url, multicastOrigin); player.setMediaItem(mediaItem); player.prepare(); player.setPlayWhenReady(true); startTimeout(); } - private MediaItem buildMediaItem(String url) { + /** + * @param url 已规范化(可能已改写为 udpxy 地址)的播放地址 + * @param multicastOrigin 原始地址是否为 {@code udp://}/{@code rtp://};经 udpxy 代理后 scheme + * 变成 http 但载荷仍是裸 MPEG-TS,必须据此显式指定容器类型 + */ + private MediaItem buildMediaItem(String url, boolean multicastOrigin) { Uri uri = Uri.parse(url); String lowerUrl = url.toLowerCase(Locale.US); @Nullable String scheme = uri.getScheme(); @@ -308,6 +530,11 @@ private MediaItem buildMediaItem(String url) { if ("rtmp".equals(lowerScheme)) { return builder.build(); } + // UDP/RTP 组播:地址没有扩展名,靠嗅探识别 TS 会拖慢起播,直接指定 MPEG-TS。 + if (multicastOrigin) { + builder.setMimeType(MimeTypes.VIDEO_MP2T); + return builder.build(); + } if (lowerUrl.contains(".m3u8") || lowerUrl.contains("/hls/") || lowerUrl.contains("type=m3u8")) { @@ -364,6 +591,8 @@ private void switchToNextSource(@NonNull String reason) { "Switching source — reason: %s | failedSource: %d/%d | url: %s", reason, failedIndex + 1, currentSources.size(), failedUrl)); isRetrying = true; + // 换到另一条线路,恢复预算重新给满 + consecutiveStallRecoveries = 0; currentSourceIndex++; playCurrentSource(); } @@ -377,6 +606,7 @@ private void stopPlayer() { public void manualSwitchSource(int index) { if (index >= 0 && index < currentSources.size()) { + consecutiveStallRecoveries = 0; String url = currentSources.get(index).url; Log.i(TAG, String.format(Locale.US, "Manual switch source — target: %d/%d | url: %s", @@ -387,9 +617,67 @@ public void manualSwitchSource(int index) { } } + /** + * 起播成功后重新进入缓冲时武装看门狗。已武装则不重复计时,避免缓冲状态反复进出把计时无限推后。 + */ + private void startRebufferWatchdog() { + // 还没起播过:这段缓冲归起播超时管,不要重复武装 + if (!hasStartedPlayback || rebufferWatchdogRunnable != null) { + return; + } + long timeoutMs = preferenceManager().getSourceSwitchTimeoutMs(); + final int generation = playGeneration; + rebufferWatchdogRunnable = () -> { + rebufferWatchdogRunnable = null; + if (generation != playGeneration) { + return; + } + if (player == null || player.getPlaybackState() != Player.STATE_BUFFERING) { + return; + } + if (!player.getPlayWhenReady()) { + // 用户已暂停或退到后台:恢复动作会把 playWhenReady 重新置真, + // 等于在后台偷偷续播。此处只继续看管,不动作。 + return; + } + onRebufferStall(timeoutMs); + }; + handler.postDelayed(rebufferWatchdogRunnable, timeoutMs); + } + + private void cancelRebufferWatchdog() { + if (rebufferWatchdogRunnable != null) { + handler.removeCallbacks(rebufferWatchdogRunnable); + rebufferWatchdogRunnable = null; + } + } + + /** + * 卡在缓冲超时后的恢复:先原地重开当前线路,多次无效再换源。 + * + *

这里刻意不直接换源。实测遇到过「组播数据以满码率持续进来、播放器却永远停在 BUFFERING」 + * (TS 突发丢包导致时间戳不连续,缓冲时长算不出来),这种情况重新 prepare 一次即可恢复: + * 新的解复用器、新的采样队列、时间戳重新对齐。而且单线路频道换源就等于放弃播放。 + * 真正死掉的流会在重开后被起播超时接住,照常换源。 + */ + void onRebufferStall(long timeoutMs) { + if (consecutiveStallRecoveries >= MAX_STALL_RECOVERIES) { + switchToNextSource(String.format(Locale.US, + "rebuffer_stall: still buffering after %d recovery attempt(s)", + consecutiveStallRecoveries)); + return; + } + consecutiveStallRecoveries++; + Log.w(TAG, String.format(Locale.US, + "Stalled in BUFFERING for %dms with source %d/%d - restarting playback (%d/%d)", + timeoutMs, currentSourceIndex + 1, currentSources.size(), + consecutiveStallRecoveries, MAX_STALL_RECOVERIES)); + playCurrentSource(); + } + private void startTimeout() { cancelTimeout(); - long timeoutMs = new PreferenceManager(context).getSourceSwitchTimeoutMs(); + long timeoutMs = preferenceManager().getSourceSwitchTimeoutMs(); final int generation = playGeneration; timeoutRunnable = () -> { if (generation != playGeneration) { @@ -432,7 +720,16 @@ public int getSourceCount() { return currentSources.size(); } - /** 当前正在尝试播放的线路地址;无有效线路时为 null。 */ + /** + * 当前实际交给播放器的地址:组播已规范化,配了 udpxy 时为改写后的 HTTP 地址。 + * 与 {@link #getCurrentPlaybackUrl()}(信号源里配置的原始地址)区分,便于排查组播问题。 + */ + @Nullable + public String getCurrentResolvedPlaybackUrl() { + return currentResolvedUrl; + } + + /** 当前正在尝试播放的线路地址(信号源中配置的原始写法);无有效线路时为 null。 */ @Nullable public String getCurrentPlaybackUrl() { if (currentSources == null || currentSources.isEmpty()) { @@ -447,17 +744,24 @@ public String getCurrentPlaybackUrl() { public void pause() { if (player != null) { player.setPlayWhenReady(false); + // 暂停期间不看管卡顿,避免看门狗把播放重新拉起来 + cancelRebufferWatchdog(); } } public void resume() { if (player != null) { player.setPlayWhenReady(true); + if (hasStartedPlayback && player.getPlaybackState() == Player.STATE_BUFFERING) { + // 暂停时取消过,恢复后若仍卡着就重新看管 + startRebufferWatchdog(); + } } } public void release() { cancelTimeout(); + cancelRebufferWatchdog(); if (hlsSegmentPrefetcher != null) { hlsSegmentPrefetcher.release(); hlsSegmentPrefetcher = null; diff --git a/app/src/main/java/com/whyun/witv/player/RtpPacketUtil.java b/app/src/main/java/com/whyun/witv/player/RtpPacketUtil.java new file mode 100644 index 0000000..1e35490 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/RtpPacketUtil.java @@ -0,0 +1,95 @@ +package com.whyun.witv.player; + +/** + * RFC 3550 RTP 固定头解析。组播 IPTV 里 RTP 载荷通常是 MPEG-TS(PT=33), + * 必须剥掉 12 字节固定头(含 CSRC / 扩展头 / 尾部 padding)后才能交给 TsExtractor。 + * + *

+ *  0                   1                   2                   3
+ *  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ * |V=2|P|X|  CC   |M|     PT      |       sequence number         |
+ * |                           timestamp                           |
+ * |                             SSRC                              |
+ * |                      CSRC (0..15 × 4 bytes)                   |
+ * 
+ */ +public final class RtpPacketUtil { + + /** RTP 固定头长度 */ + public static final int FIXED_HEADER_SIZE = 12; + /** 本实现只接受 RFC 3550 的版本号 2 */ + public static final int RTP_VERSION = 2; + /** MPEG-2 Transport Stream 的静态载荷类型 */ + public static final int PAYLOAD_TYPE_MP2T = 33; + + /** {@link #parse} 输出数组长度 */ + public static final int RESULT_SIZE = 4; + /** 载荷在原数组中的起始下标 */ + public static final int RESULT_PAYLOAD_OFFSET = 0; + /** 载荷字节数 */ + public static final int RESULT_PAYLOAD_LENGTH = 1; + /** 16 位序号 */ + public static final int RESULT_SEQUENCE = 2; + /** 7 位载荷类型 */ + public static final int RESULT_PAYLOAD_TYPE = 3; + + private RtpPacketUtil() { + } + + /** + * 解析一个 RTP 包。 + * + * @param buf 数据报缓冲区 + * @param offset 包在缓冲区中的起始下标 + * @param length 包长度 + * @param out 长度至少 {@link #RESULT_SIZE} 的输出数组,成功时按 {@code RESULT_*} 下标填充 + * @return 解析成功返回 true;不是合法 RTP 包(版本不符、长度不足、头比包长)返回 false + */ + public static boolean parse(byte[] buf, int offset, int length, int[] out) { + if (buf == null || out == null || out.length < RESULT_SIZE) { + return false; + } + if (length < FIXED_HEADER_SIZE || offset < 0 || offset + length > buf.length) { + return false; + } + int b0 = buf[offset] & 0xFF; + if ((b0 >>> 6) != RTP_VERSION) { + return false; + } + boolean hasPadding = ((b0 >>> 5) & 0x01) == 1; + boolean hasExtension = ((b0 >>> 4) & 0x01) == 1; + int csrcCount = b0 & 0x0F; + + int headerLength = FIXED_HEADER_SIZE + 4 * csrcCount; + if (hasExtension) { + if (length < headerLength + 4) { + return false; + } + int extensionWords = ((buf[offset + headerLength + 2] & 0xFF) << 8) + | (buf[offset + headerLength + 3] & 0xFF); + headerLength += 4 + 4 * extensionWords; + } + if (headerLength >= length) { + return false; + } + + int payloadLength = length - headerLength; + if (hasPadding) { + int padding = buf[offset + length - 1] & 0xFF; + // padding 计数包含自身,必须落在载荷范围内 + if (padding <= 0 || padding > payloadLength) { + return false; + } + payloadLength -= padding; + if (payloadLength <= 0) { + return false; + } + } + + out[RESULT_PAYLOAD_OFFSET] = offset + headerLength; + out[RESULT_PAYLOAD_LENGTH] = payloadLength; + out[RESULT_SEQUENCE] = ((buf[offset + 2] & 0xFF) << 8) | (buf[offset + 3] & 0xFF); + out[RESULT_PAYLOAD_TYPE] = buf[offset + 1] & 0x7F; + return true; + } +} diff --git a/app/src/main/java/com/whyun/witv/player/RtpReorderBuffer.java b/app/src/main/java/com/whyun/witv/player/RtpReorderBuffer.java new file mode 100644 index 0000000..77191d1 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/RtpReorderBuffer.java @@ -0,0 +1,214 @@ +package com.whyun.witv.player; + +/** + * RTP 抖动/乱序重排缓冲。按 16 位序号把乱序到达的载荷重新排好再吐给解复用器。 + * + *

策略: + *

    + *
  • 下一个期望序号已到达 → 立即吐出,无额外延迟;
  • + *
  • 出现空洞但缓冲里积压不足 {@code capacity/2} 个包 → 先等,给乱序包留出到达时间;
  • + *
  • 积压达到阈值仍没补上 → 判定丢包,跳到最近的已缓冲包继续(计入 {@link #getLostPackets()});
  • + *
  • 序号跳变超过一个窗口(换台、信号中断)→ 丢弃积压并以新序号重新起点。
  • + *
+ * + *

槽位按 {@code seq & (capacity-1)} 直接寻址,窗口内不会冲突,收包路径无内存分配。 + * 非线程安全,仅供单个 {@link MulticastDataSource} 的收流线程使用。 + */ +public final class RtpReorderBuffer { + + /** 默认窗口大小(包数),必须是 2 的幂 */ + public static final int DEFAULT_CAPACITY = 32; + + private static final int SEQ_MASK = 0xFFFF; + private static final int SEQ_HALF = 0x8000; + + /** + * 连续这么多个包都被判为「迟到」就认定发送端换了序号空间,按新起点重新同步。 + * + *

发送端重启会把序号重置到一个更小的值。如果新起点落在当前期望值「之前」的半个序号空间里, + * 每个新包都会被当成迟到丢弃,最坏要丢 32767 个包(760pkt/s 下约 43 秒黑屏)才会自然追上。 + * 正常网络不会出现连续几十个迟到包,所以这个阈值不会误伤。 + */ + private static final int LATE_PACKETS_BEFORE_RESYNC = 64; + + private final int capacity; + private final int indexMask; + private final int flushThreshold; + private final int maxPayloadSize; + + private final byte[][] slots; + private final int[] slotLength; + private final int[] slotSequence; + private final boolean[] slotUsed; + + private boolean started; + private int expectedSequence; + private int pending; + + private int consecutiveLatePackets; + + private long lostPackets; + private long latePackets; + private long discontinuities; + + /** + * @param capacity 窗口包数,必须是 2 的幂且 >= 2 + * @param maxPayloadSize 单个载荷最大字节数 + */ + public RtpReorderBuffer(int capacity, int maxPayloadSize) { + if (capacity < 2 || (capacity & (capacity - 1)) != 0) { + throw new IllegalArgumentException("capacity must be a power of two >= 2: " + capacity); + } + if (maxPayloadSize <= 0) { + throw new IllegalArgumentException("maxPayloadSize must be positive: " + maxPayloadSize); + } + this.capacity = capacity; + this.indexMask = capacity - 1; + this.flushThreshold = Math.max(1, capacity / 2); + this.maxPayloadSize = maxPayloadSize; + this.slots = new byte[capacity][maxPayloadSize]; + this.slotLength = new int[capacity]; + this.slotSequence = new int[capacity]; + this.slotUsed = new boolean[capacity]; + } + + /** + * 放入一个收到的 RTP 载荷。 + * + * @param sequence 16 位 RTP 序号 + * @param src 载荷所在缓冲区 + * @param srcOffset 载荷起始下标 + * @param length 载荷字节数 + */ + public void offer(int sequence, byte[] src, int srcOffset, int length) { + if (length <= 0 || length > maxPayloadSize) { + return; + } + int seq = sequence & SEQ_MASK; + if (!started) { + started = true; + expectedSequence = seq; + } + int diff = (seq - expectedSequence) & SEQ_MASK; + if (diff >= SEQ_HALF) { + // 序号早于当前期望:迟到或重复,直接丢弃 + latePackets++; + if (++consecutiveLatePackets < LATE_PACKETS_BEFORE_RESYNC) { + return; + } + // 连续这么多个都「迟到」只能是发送端重启换了序号空间,按新起点重来 + discontinuities++; + clearSlots(); + expectedSequence = seq; + diff = 0; + } + consecutiveLatePackets = 0; + if (diff >= capacity) { + // 跳变超出窗口:丢弃积压,以当前包为新起点 + discontinuities++; + clearSlots(); + expectedSequence = seq; + } + int idx = seq & indexMask; + if (slotUsed[idx] && slotSequence[idx] == seq) { + latePackets++; + return; + } + if (!slotUsed[idx]) { + pending++; + } + System.arraycopy(src, srcOffset, slots[idx], 0, length); + slotLength[idx] = length; + slotSequence[idx] = seq; + slotUsed[idx] = true; + } + + /** + * 取出下一个按序可用的载荷。 + * + * @param dest 目标缓冲区,容量需 >= 构造时的 maxPayloadSize + * @return 写入 {@code dest} 的字节数;暂时没有可吐出的包时返回 -1(调用方应继续收包) + */ + public int poll(byte[] dest) { + if (!started || pending == 0) { + return -1; + } + int idx = expectedSequence & indexMask; + if (slotUsed[idx] && slotSequence[idx] == expectedSequence) { + return emit(idx, dest); + } + if (pending < flushThreshold) { + // 可能只是乱序,再等等 + return -1; + } + int bestIdx = -1; + int bestDiff = Integer.MAX_VALUE; + for (int i = 0; i < capacity; i++) { + if (!slotUsed[i]) { + continue; + } + int diff = (slotSequence[i] - expectedSequence) & SEQ_MASK; + if (diff >= SEQ_HALF) { + continue; + } + if (diff < bestDiff) { + bestDiff = diff; + bestIdx = i; + } + } + if (bestIdx < 0) { + clearSlots(); + return -1; + } + lostPackets += bestDiff; + expectedSequence = slotSequence[bestIdx]; + return emit(bestIdx, dest); + } + + private int emit(int idx, byte[] dest) { + int length = slotLength[idx]; + System.arraycopy(slots[idx], 0, dest, 0, length); + slotUsed[idx] = false; + pending--; + expectedSequence = (expectedSequence + 1) & SEQ_MASK; + return length; + } + + /** 关闭/重开数据源时清空状态与统计。 */ + public void reset() { + clearSlots(); + started = false; + expectedSequence = 0; + consecutiveLatePackets = 0; + lostPackets = 0; + latePackets = 0; + discontinuities = 0; + } + + private void clearSlots() { + for (int i = 0; i < capacity; i++) { + slotUsed[i] = false; + } + pending = 0; + } + + /** 当前缓冲中的包数 */ + public int getPendingCount() { + return pending; + } + + /** 判定为丢失(空洞被跳过)的包数 */ + public long getLostPackets() { + return lostPackets; + } + + /** 迟到或重复而被丢弃的包数 */ + public long getLatePackets() { + return latePackets; + } + + /** 序号跳变超窗口导致缓冲重置的次数 */ + public long getDiscontinuities() { + return discontinuities; + } +} diff --git a/app/src/main/java/com/whyun/witv/player/SwitchableLoadControl.java b/app/src/main/java/com/whyun/witv/player/SwitchableLoadControl.java new file mode 100644 index 0000000..8f8fd8e --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/SwitchableLoadControl.java @@ -0,0 +1,182 @@ +package com.whyun.witv.player; + +import androidx.annotation.NonNull; +import androidx.annotation.OptIn; +import androidx.annotation.VisibleForTesting; +import androidx.media3.common.C; +import androidx.media3.common.Timeline; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.DefaultLoadControl; +import androidx.media3.exoplayer.LoadControl; +import androidx.media3.exoplayer.analytics.PlayerId; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.source.TrackGroupArray; +import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.DefaultAllocator; + +/** + * 可在「HTTP/HLS 直播」与「UDP/RTP 组播」两套缓冲策略之间切换的 {@link LoadControl}。 + * + *

两者的取舍完全相反: + *

    + *
  • HLS 直播能从服务端回拉历史分片,所以主动离 live edge 远一点、缓冲厚一点,用延迟换抗抖动;
  • + *
  • 组播是实时推流,服务端没有可回拉的缓冲——缓冲再厚也只是单纯增加开播等待, + * 所以起播门槛要低得多(6s → 1.5s)。
  • + *
+ * + *

两个委托共享同一个 {@link DefaultAllocator},因此切换策略不会换掉播放器正在使用的内存池, + * 可以在 {@code player.stop()} 之后、{@code prepare()} 之前安全切换,无需重建 ExoPlayer。 + * 生命周期回调同时转发给两个委托,保证未激活的那个状态不会漂移。 + * + *

注意 {@code DefaultLoadControl.getAllocator()} 返回的是按 playerId 过滤的包装器, + * 字节用量记在各自实例里。{@link #getAllocator} 固定取 streaming 委托的包装器,因此只有它的 + * 字节统计是准的——multicast 委托因此开启了 {@code prioritizeTimeOverSizeThresholds}, + * 只按时长决策,不会去读自己看不到的字节计数。 + */ +@OptIn(markerClass = UnstableApi.class) +public final class SwitchableLoadControl implements LoadControl { + + /** 缓冲策略 */ + public enum Profile { + /** HTTP / HLS / DASH 等可回拉的流 */ + STREAMING, + /** udp:// rtp:// 实时组播 */ + MULTICAST + } + + // HLS 直播:厚缓冲换抗抖动 + private static final int STREAMING_MIN_BUFFER_MS = 35_000; + private static final int STREAMING_MAX_BUFFER_MS = 90_000; + private static final int STREAMING_BUFFER_FOR_PLAYBACK_MS = 6_000; + private static final int STREAMING_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 15_000; + + // 组播:实时流,优先压低开播等待 + private static final int MULTICAST_MIN_BUFFER_MS = 8_000; + private static final int MULTICAST_MAX_BUFFER_MS = 30_000; + private static final int MULTICAST_BUFFER_FOR_PLAYBACK_MS = 1_500; + private static final int MULTICAST_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 3_000; + + private final DefaultAllocator sharedAllocator; + private final DefaultLoadControl streamingDelegate; + private final DefaultLoadControl multicastDelegate; + + private volatile Profile profile = Profile.STREAMING; + + public SwitchableLoadControl() { + DefaultAllocator sharedAllocator = + new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE); + this.sharedAllocator = sharedAllocator; + this.streamingDelegate = new DefaultLoadControl.Builder() + .setAllocator(sharedAllocator) + .setBufferDurationsMs( + STREAMING_MIN_BUFFER_MS, + STREAMING_MAX_BUFFER_MS, + STREAMING_BUFFER_FOR_PLAYBACK_MS, + STREAMING_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) + .build(); + this.multicastDelegate = new DefaultLoadControl.Builder() + .setAllocator(sharedAllocator) + .setBufferDurationsMs( + MULTICAST_MIN_BUFFER_MS, + MULTICAST_MAX_BUFFER_MS, + MULTICAST_BUFFER_FOR_PLAYBACK_MS, + MULTICAST_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) + .setPrioritizeTimeOverSizeThresholds(true) + .build(); + } + + /** + * 切换缓冲策略。必须在 {@code player.prepare()} 之前调用({@link PlayerManager} 在每次 + * 换源时、{@code stop()} 之后调用)。 + */ + public void setProfile(Profile profile) { + this.profile = profile; + } + + public Profile getProfile() { + return profile; + } + + /** 按播放地址挑选策略 */ + public static Profile profileForUrl(String url) { + return MulticastUrlUtil.isMulticastStreamUrl(url) ? Profile.MULTICAST : Profile.STREAMING; + } + + /** 两个委托共用的内存池;仅用于测试验证「切档不换池」这一前提。 */ + @VisibleForTesting + DefaultAllocator getSharedAllocator() { + return sharedAllocator; + } + + private LoadControl active() { + return profile == Profile.MULTICAST ? multicastDelegate : streamingDelegate; + } + + // --- 生命周期:两个委托都要收到,避免未激活的一侧状态漂移 --- + + @Override + public void onPrepared(@NonNull PlayerId playerId) { + streamingDelegate.onPrepared(playerId); + multicastDelegate.onPrepared(playerId); + } + + @Override + public void onStopped(@NonNull PlayerId playerId) { + streamingDelegate.onStopped(playerId); + multicastDelegate.onStopped(playerId); + } + + @Override + public void onReleased(@NonNull PlayerId playerId) { + streamingDelegate.onReleased(playerId); + multicastDelegate.onReleased(playerId); + } + + @Override + public void onTracksSelected(@NonNull Parameters parameters, + @NonNull TrackGroupArray trackGroups, + @NonNull ExoTrackSelection[] trackSelections) { + streamingDelegate.onTracksSelected(parameters, trackGroups, trackSelections); + multicastDelegate.onTracksSelected(parameters, trackGroups, trackSelections); + } + + // --- 决策:只问当前策略 --- + + @NonNull + @Override + public Allocator getAllocator(@NonNull PlayerId playerId) { + // 固定取 streaming 委托:两者底层是同一个内存池,但字节用量统计各记各的, + // 必须始终从同一侧取,播放器的分配才会被完整记账。 + return streamingDelegate.getAllocator(playerId); + } + + @Override + public long getBackBufferDurationUs(@NonNull PlayerId playerId) { + return active().getBackBufferDurationUs(playerId); + } + + @Override + public boolean retainBackBufferFromKeyframe(@NonNull PlayerId playerId) { + return active().retainBackBufferFromKeyframe(playerId); + } + + @Override + public boolean shouldContinueLoading(@NonNull Parameters parameters) { + return active().shouldContinueLoading(parameters); + } + + @Override + public boolean shouldContinuePreloading(@NonNull PlayerId playerId, + @NonNull Timeline timeline, + @NonNull MediaSource.MediaPeriodId mediaPeriodId, + long bufferedDurationUs) { + return active().shouldContinuePreloading(playerId, timeline, mediaPeriodId, + bufferedDurationUs); + } + + @Override + public boolean shouldStartPlayback(@NonNull Parameters parameters) { + return active().shouldStartPlayback(parameters); + } +} diff --git a/app/src/main/java/com/whyun/witv/server/WebServer.java b/app/src/main/java/com/whyun/witv/server/WebServer.java index 0376894..8b02a9a 100644 --- a/app/src/main/java/com/whyun/witv/server/WebServer.java +++ b/app/src/main/java/com/whyun/witv/server/WebServer.java @@ -294,6 +294,7 @@ private Response getSettings() { M3USource active = db.m3uSourceDao().getActive(); JsonObject settings = new JsonObject(); settings.addProperty("epgUrl", active != null ? (active.epgUrl != null ? active.epgUrl : "") : ""); + settings.addProperty("udpxyProxyBase", new PreferenceManager(context).getUdpxyProxyBase()); return jsonOk(gson.toJson(settings)); } @@ -306,8 +307,16 @@ private Response updateSettings(IHTTPSession session) throws IOException { active.epgUrl = json.get("epgUrl").getAsString(); db.m3uSourceDao().update(active); } + // 组播转单播代理:遥控器输入 URL 很痛苦,Web 管理页是更合适的入口 + if (json.has("udpxyProxyBase")) { + new PreferenceManager(context).setUdpxyProxyBase( + json.get("udpxyProxyBase").getAsString()); + } - return jsonOk("{\"success\":true}"); + JsonObject result = new JsonObject(); + result.addProperty("success", true); + result.addProperty("udpxyProxyBase", new PreferenceManager(context).getUdpxyProxyBase()); + return jsonOk(gson.toJson(result)); } // --- Favorite handlers --- diff --git a/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java b/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java index bf2fbb2..273a3e6 100644 --- a/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java +++ b/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java @@ -893,6 +893,27 @@ public void onSourceSwitchTimeoutChanged() { } } + /** + * 解码方式变更:RenderersFactory 只能在构建 ExoPlayer 时指定,必须重建播放器, + * 重建前后要把本页挂在旧实例上的监听器摘下、重新挂到新实例上。 + */ + @Override + public boolean onPlaybackDecoderModeChanged() { + if (playerManager == null) { + return false; + } + ExoPlayer oldPlayer = playerManager.getPlayer(); + if (oldPlayer != null) { + oldPlayer.removeListener(mediaInfoListener); + } + boolean rebuilt = playerManager.reinitializeForDecoderModeChange(); + ExoPlayer newPlayer = playerManager.getPlayer(); + if (newPlayer != null) { + newPlayer.addListener(mediaInfoListener); + } + return rebuilt; + } + @Override public void showPlaybackMediaInfoDialog() { if (currentChannel == null || playerManager == null) { @@ -1031,6 +1052,14 @@ private void bindChannelGroupList() { } private void updateSelectedGroup(int position, boolean requestChannelFocus) { + // 分组焦点可能是频道列表回收带焦点行、焦点逃到根节点重新分发时同步落过来的, + // 此时两个列表都可能正在布局中,setSelectedIndex 的 notifyDataSetChanged 与 + // 下面的 setAdapter 都会抛 IllegalStateException。见 RecyclerViewUpdateGate。 + if (RecyclerViewUpdateGate.postponeIfBusy(channelListOverlay, + () -> updateSelectedGroup(position, requestChannelFocus), + channelListOverlay, channelGroupListOverlay)) { + return; + } if (position < 0 || position >= visibleChannelGroups.size()) { return; } @@ -1049,6 +1078,12 @@ private void updateSelectedGroup(int position, boolean requestChannelFocus) { } private void bindChannelListForSelectedGroup(boolean requestChannelFocus) { + // 也可能被 updateSelectedGroup 之外的路径直接调用,这里再挡一次 + if (RecyclerViewUpdateGate.postponeIfBusy(channelListOverlay, + () -> bindChannelListForSelectedGroup(requestChannelFocus), + channelListOverlay, channelGroupListOverlay)) { + return; + } int selectedIndexInGroup = findCurrentChannelIndexInVisibleChannels(); ChannelListAdapter adapter = new ChannelListAdapter(visibleChannels, selectedIndexInGroup, currentFavoriteIds, channel -> { cancelChannelListIdleHide(); diff --git a/app/src/main/java/com/whyun/witv/ui/RecyclerViewUpdateGate.java b/app/src/main/java/com/whyun/witv/ui/RecyclerViewUpdateGate.java new file mode 100644 index 0000000..a24575a --- /dev/null +++ b/app/src/main/java/com/whyun/witv/ui/RecyclerViewUpdateGate.java @@ -0,0 +1,67 @@ +package com.whyun.witv.ui; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +/** + * 把「在 RecyclerView 布局/滚动计算期间执行会抛异常」的更新推迟到下一帧。 + * + *

{@code setAdapter()} 与 {@code notify*()} 在 RecyclerView 正在布局或滚动时调用都是非法的。 + * 电视端很容易踩到这个雷,因为焦点变化会在回收过程中同步回调: + * + *

    + *
  1. 频道列表惯性滚动,回收掉当前带焦点的那一行;
  2. + *
  3. {@code ViewGroup.removeViewInternal} 发现焦点没了,调 {@code rootViewRequestFocus()};
  4. + *
  5. 焦点从根节点重新分发,落到分组列表的某一项上,同步触发它的 + * {@code OnFocusChangeListener};
  6. + *
  7. 监听器里换频道列表的 Adapter —— 而第 1 步的回收还没结束,于是 + * {@code IllegalStateException: Cannot call removeView(At) within removeView(At)}。
  8. + *
+ */ +final class RecyclerViewUpdateGate { + + private RecyclerViewUpdateGate() { + } + + /** 给定的 RecyclerView 中是否有正在布局或滚动计算的(null 视为不忙)。 */ + static boolean isBusy(@Nullable RecyclerView... views) { + if (views == null) { + return false; + } + for (RecyclerView view : views) { + if (view != null && view.isComputingLayout()) { + return true; + } + } + return false; + } + + /** + * 列表正忙则把 {@code retry} 推迟到下一帧,否则什么都不做。 + * + *

注意本方法在「不忙」时**不会**执行 {@code retry}——{@code retry} 通常就是调用方自身, + * 执行它会无限递归。正确用法是: + * + *

+     * if (RecyclerViewUpdateGate.postponeIfBusy(host, () -> doIt(arg), listA, listB)) {
+     *     return;
+     * }
+     * // ...真正的更新逻辑
+     * 
+ * + * @param host 用于 {@code post} 的视图;为 null 时无法推迟,只能让调用方继续执行 + * @param retry 推迟后要重新执行的入口(会再走一遍本检查) + * @param guarded 需要检查是否处于布局/滚动中的列表 + * @return true 表示已推迟,调用方应立即返回;false 表示当前可以安全更新 + */ + static boolean postponeIfBusy(@Nullable RecyclerView host, + @NonNull Runnable retry, + @Nullable RecyclerView... guarded) { + if (host != null && isBusy(guarded)) { + host.post(retry); + return true; + } + return false; + } +} diff --git a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java index cbaf511..0f4b46f 100644 --- a/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java +++ b/app/src/main/java/com/whyun/witv/ui/SettingsCollapsibleFragment.java @@ -27,6 +27,9 @@ import com.whyun.witv.BuildConfig; import com.whyun.witv.R; import com.whyun.witv.data.PreferenceManager; +import com.whyun.witv.player.PlaybackDecoderMode; + +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.FfmpegLibrary; import com.whyun.witv.data.db.AppDatabase; import com.whyun.witv.data.db.entity.ChannelSource; import com.whyun.witv.data.db.entity.M3USource; @@ -54,6 +57,8 @@ public class SettingsCollapsibleFragment extends Fragment public static final int CAT_PLAYBACK = 4; public static final int CAT_HELP = 5; public static final int CAT_SOURCE_TIMEOUT = 6; + public static final int CAT_MULTICAST = 7; + public static final int CAT_DECODER = 8; private SettingsPanelHost host; @@ -419,6 +424,10 @@ private void refreshMainMenuItems() { ctx.getString(R.string.settings_group_epg))); items.add(new SettingsMainMenuAdapter.Item(CAT_PLAYBACK, ctx.getString(R.string.settings_group_playback))); + items.add(new SettingsMainMenuAdapter.Item(CAT_MULTICAST, + ctx.getString(R.string.settings_group_multicast))); + items.add(new SettingsMainMenuAdapter.Item(CAT_DECODER, + ctx.getString(R.string.settings_group_decoder))); items.add(new SettingsMainMenuAdapter.Item(CAT_HELP, ctx.getString(R.string.settings_help_title))); mainMenuAdapter.setItems(items); @@ -564,6 +573,26 @@ private List buildSubmenuRows(int category) { ctx.getString(R.string.reverse_channel_keys), ctx.getString(R.string.reverse_channel_keys_hint))); break; + case CAT_MULTICAST: + rows.add(new SettingsPanelAdapter.MulticastProxyRow( + preferenceManager.getUdpxyProxyBase())); + break; + case CAT_DECODER: { + if (!isFfmpegSoftwareDecoderAvailable()) { + // ABI 不匹配或原生库被裁掉时,软解相关选项其实无效,先讲清楚 + rows.add(new SettingsPanelAdapter.EmptyHintRow( + ctx.getString(R.string.decoder_mode_ffmpeg_missing))); + } + PlaybackDecoderMode current = preferenceManager.getPlaybackDecoderMode(); + for (PlaybackDecoderMode mode : PlaybackDecoderMode.values()) { + rows.add(new SettingsPanelAdapter.DecoderModeRow( + mode, + ctx.getString(decoderModeTitleRes(mode)), + ctx.getString(decoderModeDescriptionRes(mode)), + mode == current)); + } + break; + } case CAT_HELP: if (host.shouldShowPlaybackMediaInfoHelp()) { rows.add(new SettingsPanelAdapter.HelpSubRow( @@ -787,6 +816,67 @@ public void onSourceTimeoutSeconds(int seconds) { rebuildSubmenuIfOpen(); } + /** FFmpeg 原生库是否可加载;加载失败不应让设置页崩溃。 */ + private static boolean isFfmpegSoftwareDecoderAvailable() { + try { + return FfmpegLibrary.isAvailable(); + } catch (Throwable t) { + return false; + } + } + + private static int decoderModeTitleRes(PlaybackDecoderMode mode) { + switch (mode) { + case PREFER_SOFTWARE: + return R.string.decoder_mode_prefer_software; + case HARDWARE_ONLY: + return R.string.decoder_mode_hardware_only; + case AUTO: + default: + return R.string.decoder_mode_auto; + } + } + + private static int decoderModeDescriptionRes(PlaybackDecoderMode mode) { + switch (mode) { + case PREFER_SOFTWARE: + return R.string.decoder_mode_prefer_software_desc; + case HARDWARE_ONLY: + return R.string.decoder_mode_hardware_only_desc; + case AUTO: + default: + return R.string.decoder_mode_auto_desc; + } + } + + @Override + public void onPlaybackDecoderMode(PlaybackDecoderMode mode) { + if (mode == preferenceManager.getPlaybackDecoderMode()) { + return; + } + preferenceManager.setPlaybackDecoderMode(mode); + boolean rebuilt = host.onPlaybackDecoderModeChanged(); + Toast.makeText(requireContext(), + rebuilt + ? getString(R.string.decoder_mode_saved, + getString(decoderModeTitleRes(mode))) + : getString(R.string.decoder_mode_saved_next_playback, + getString(decoderModeTitleRes(mode))), + Toast.LENGTH_SHORT).show(); + rebuildSubmenuIfOpen(); + } + + @Override + public void onSaveUdpxyProxy(String proxyBase) { + preferenceManager.setUdpxyProxyBase(proxyBase); + String saved = preferenceManager.getUdpxyProxyBase(); + Toast.makeText(requireContext(), + saved.isEmpty() + ? getString(R.string.multicast_proxy_cleared) + : getString(R.string.multicast_proxy_saved), + Toast.LENGTH_SHORT).show(); + } + @Override public void onHelpSubmenuClick(SettingsPanelAdapter.HelpSubRow.Kind kind) { switch (kind) { diff --git a/app/src/main/java/com/whyun/witv/ui/SettingsPanelAdapter.java b/app/src/main/java/com/whyun/witv/ui/SettingsPanelAdapter.java index 3623d9c..ca6976d 100644 --- a/app/src/main/java/com/whyun/witv/ui/SettingsPanelAdapter.java +++ b/app/src/main/java/com/whyun/witv/ui/SettingsPanelAdapter.java @@ -15,6 +15,8 @@ import com.whyun.witv.R; import com.whyun.witv.data.db.entity.ChannelSource; import com.whyun.witv.data.db.entity.M3USource; +import com.whyun.witv.player.MulticastUrlUtil; +import com.whyun.witv.player.PlaybackDecoderMode; import java.util.Collections; import java.util.List; @@ -33,6 +35,8 @@ public class SettingsPanelAdapter extends RecyclerView.Adapter rows = Collections.emptyList(); @@ -217,9 +261,13 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int return new M3UVH(inf.inflate(R.layout.item_source, parent, false)); case VT_STREAM: case VT_SOURCE_TIMEOUT: + case VT_DECODER_MODE: return new StreamVH(inf.inflate(R.layout.item_settings_stream_row, parent, false)); case VT_EPG: return new EpgVH(inf.inflate(R.layout.item_settings_epg, parent, false)); + case VT_MULTICAST_PROXY: + return new MulticastProxyVH( + inf.inflate(R.layout.item_settings_multicast_proxy, parent, false)); case VT_CHECK: return new CheckVH(inf.inflate(R.layout.item_settings_check, parent, false)); case VT_HELP_SUB: @@ -245,11 +293,15 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int positi } else if (holder instanceof StreamVH) { if (row instanceof StreamRow) { ((StreamVH) holder).bind((StreamRow) row, listener); + } else if (row instanceof DecoderModeRow) { + ((StreamVH) holder).bind((DecoderModeRow) row, listener); } else { ((StreamVH) holder).bind((SourceTimeoutRow) row, listener); } } else if (holder instanceof EpgVH) { ((EpgVH) holder).bind(((EpgRow) row).epgUrl, listener); + } else if (holder instanceof MulticastProxyVH) { + ((MulticastProxyVH) holder).bind(((MulticastProxyRow) row).proxyBase, listener); } else if (holder instanceof CheckVH) { ((CheckVH) holder).bind((CheckRow) row, listener); } else if (holder instanceof HelpSubVH) { @@ -333,6 +385,24 @@ void bind(StreamRow sr, Listener listener) { }); } + void bind(DecoderModeRow row, Listener listener) { + label.setText(row.title); + url.setText(row.description); + url.setVisibility(View.VISIBLE); + currentBadge.setVisibility(row.selected ? View.VISIBLE : View.GONE); + itemView.setOnClickListener(v -> listener.onPlaybackDecoderMode(row.mode)); + itemView.setOnKeyListener((v, keyCode, event) -> { + if (event.getAction() != KeyEvent.ACTION_DOWN) { + return false; + } + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { + listener.onPlaybackDecoderMode(row.mode); + return true; + } + return false; + }); + } + void bind(SourceTimeoutRow row, Listener listener) { label.setText(itemView.getContext().getString(R.string.source_timeout_seconds_format, row.seconds)); url.setVisibility(View.GONE); @@ -372,6 +442,51 @@ void bind(String epgUrl, Listener listener) { } } + static final class MulticastProxyVH extends RecyclerView.ViewHolder { + final EditText input; + final TextView preview; + final Button save; + final Button clear; + + MulticastProxyVH(@NonNull View itemView) { + super(itemView); + input = itemView.findViewById(R.id.multicast_proxy_input); + preview = itemView.findViewById(R.id.multicast_proxy_preview); + save = itemView.findViewById(R.id.btn_save_multicast_proxy); + clear = itemView.findViewById(R.id.btn_clear_multicast_proxy); + } + + void bind(String proxyBase, Listener listener) { + if (!proxyBase.equals(input.getText().toString())) { + input.setText(proxyBase); + } + updatePreview(proxyBase); + save.setOnClickListener(v -> { + String normalized = + MulticastUrlUtil.normalizeProxyBase(input.getText().toString()); + // 回填规范化结果,让用户直接看到实际会用的地址 + input.setText(normalized); + updatePreview(normalized); + listener.onSaveUdpxyProxy(normalized); + }); + clear.setOnClickListener(v -> { + input.setText(""); + updatePreview(""); + listener.onSaveUdpxyProxy(""); + }); + } + + /** 用一条示例频道展示改写效果,比单看前缀直观 */ + private void updatePreview(String proxyBase) { + if (proxyBase == null || proxyBase.isEmpty()) { + preview.setText(R.string.multicast_proxy_preview_direct); + } else { + preview.setText(preview.getContext() + .getString(R.string.multicast_proxy_preview_format, proxyBase)); + } + } + } + static final class CheckVH extends RecyclerView.ViewHolder { final CheckBox check; final TextView hint; diff --git a/app/src/main/java/com/whyun/witv/ui/SettingsPanelHost.java b/app/src/main/java/com/whyun/witv/ui/SettingsPanelHost.java index f3348c5..b60ee4d 100644 --- a/app/src/main/java/com/whyun/witv/ui/SettingsPanelHost.java +++ b/app/src/main/java/com/whyun/witv/ui/SettingsPanelHost.java @@ -24,6 +24,16 @@ public interface SettingsPanelHost { default void onPlaybackOverlayPreferenceChanged() { } + /** + * 解码方式变更后由播放页重建播放器。RenderersFactory 只能在 ExoPlayer 构建时指定, + * 改不了,所以必须重建并重新挂载播放页自己的监听器。 + * + * @return 是否已重建并续播(未在播放页或尚未初始化时为 false) + */ + default boolean onPlaybackDecoderModeChanged() { + return false; + } + /** 超时换源时长变更后由播放页刷新 PlayerManager 计时。 */ default void onSourceSwitchTimeoutChanged() { } diff --git a/app/src/main/res/layout/item_settings_multicast_proxy.xml b/app/src/main/res/layout/item_settings_multicast_proxy.xml new file mode 100644 index 0000000..cafaac8 --- /dev/null +++ b/app/src/main/res/layout/item_settings_multicast_proxy.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + +