Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<proxy>/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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 导航支持,数字键直接跳转频道

## 遥控器操作
Expand All @@ -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 |
Expand Down
11 changes: 11 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
}

Expand All @@ -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 版本>-<nextlib 版本>,必须与下面的 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"
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!--
udp:// / rtp:// 组播收流必需:WiFi 驱动默认在网卡层丢弃目的 MAC 非本机的组播帧,
不持有 MulticastLock 时一个包都收不到。见 MulticastLockHolder。
-->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />

<uses-feature
android:name="android.software.leanback"
Expand Down
21 changes: 19 additions & 2 deletions app/src/main/assets/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,19 +215,36 @@ async function loadSettings() {
const res = await fetch(`${API}/api/settings`);
const settings = await res.json();
document.getElementById('epgUrl').value = settings.epgUrl || '';
document.getElementById('udpxyProxyBase').value = settings.udpxyProxyBase || '';
renderUdpxyPreview(settings.udpxyProxyBase || '');
} catch (err) {
console.error('Failed to load settings:', err);
}
}

// 用一条示例频道展示改写效果,比单看前缀直观
function renderUdpxyPreview(base) {
const el = document.getElementById('udpxyPreview');
if (!el) return;
el.textContent = base
? `udp:// 和 rtp:// 频道将改走:rtp://239.1.1.1:1234 → ${base}/rtp/239.1.1.1:1234`
: '未配置,udp:// 和 rtp:// 频道直接收组播(需设备所在网络能收到运营商组播)';
}

async function saveSettings() {
const epgUrl = document.getElementById('epgUrl').value.trim();
const udpxyProxyBase = document.getElementById('udpxyProxyBase').value.trim();
try {
await fetch(`${API}/api/settings`, {
const res = await fetch(`${API}/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ epgUrl })
body: JSON.stringify({ epgUrl, udpxyProxyBase })
});
const data = await res.json();
// 后端会规范化(补 scheme、去掉末尾 / 和 /udp /rtp),回填实际生效的值
const normalized = data.udpxyProxyBase || '';
document.getElementById('udpxyProxyBase').value = normalized;
renderUdpxyPreview(normalized);
showToast('设置已保存', 'success');
} catch (err) {
showToast('保存失败', 'error');
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/assets/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ <h2>设置</h2>
<button class="btn btn-secondary" onclick="reloadEpg()">刷新EPG</button>
</div>
</div>
<div class="form-group">
<label for="udpxyProxyBase">组播转单播代理 (udpxy)</label>
<div class="input-row">
<input type="url" id="udpxyProxyBase" placeholder="http://192.168.1.1:4022">
<button class="btn btn-secondary" onclick="saveSettings()">保存</button>
</div>
<p class="empty-hint" id="udpxyPreview"></p>
</div>
</section>
</div>

Expand Down
39 changes: 39 additions & 0 deletions app/src/main/java/com/whyun/witv/data/PreferenceManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -128,6 +135,38 @@ public static long normalizeSourceSwitchTimeoutMs(long ms) {
return DEFAULT_SOURCE_SWITCH_TIMEOUT_MS;
}

/**
* 组播转单播代理(udpxy)前缀,例如 {@code http://192.168.1.1:4022}。
*
* <p>绝大多数家宽环境拿不到运营商组播(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};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading
Loading