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
67 changes: 67 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,73 @@

格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。

## [Unreleased]

### Added

- **Web 管理地址二维码**:播放页与设置页各放一个,手机扫一下即可打开管理页,
省去在电视上用遥控器逐字符输入 URL。固定黑白、留 2 个模块的静区——
这是贴在深色背景上的小图,没有白边很多手机扫不出来。
IP 尚未解析出来时不画码:把 `0.0.0.0` 编进去,扫出来是个连不上的地址,比不给码更误导人
- **EPG 与 M3U 支持 gzip 地址**(`GzipAwareStreams`)。公开 EPG 源基本只提供 `.xml.gz`——
XMLTV 节目单动辄几十 MB。这类地址返回的是 `Content-Type: application/gzip` 而非
`Content-Encoding: gzip`,OkHttp 的透明解压不生效,拿到的是裸 gzip 字节。
判断依据是**魔数** `0x1F 0x8B` 而不是 URL 后缀或 `Content-Type`:两者都不可靠,
而 XML 与 m3u 都不可能以这两个字节开头。`.m3u.gz` 同样支持
- 新增图文使用手册 [`docs/user-guide.md`](docs/user-guide.md),13 张真机截图,
覆盖首次添加播放源、看电视、各设置分类与 Web 管理页;
组播章节附上代理与直收的实测对比数据
- **解码方式新增「音频软解 + 视频硬解」档**(`PlaybackDecoderMode.SOFTWARE_AUDIO`)。
实测某些盒子声称支持 AC-3/E-AC-3 直通,`MediaCodecAudioRenderer` 便以直通方式胜出、
压根不解码,而 HDMI 下游实际解不了,表现为**杜比声道完全没声音**;
但改用原有的「软解优先」会连视频一起转软解,老盒子软解 1080p 跟不上实时又变成卡顿。
新档位让音频走 FFmpeg 绕开直通、视频留在硬解,两边都成立

### Fixed

- **修复 4K 频道间换台后所有频道都放不出来**。症状是一串
`native_window_api_connect returned an error (-22)` + `Failed to initialize decoder`,
此后每一个频道都起不来。根因在 Surface 而不在流:上一个解码器(尤其 4K sideband 那种)
释放是异步的,`SurfaceView` 的 BufferQueue 还停在它配置的格式上没断开
(`dumpsys SurfaceFlinger` 里那块 buffer 仍是 3840x2160),新建的 MediaCodec 连不上。
实测换源、重建 `ExoPlayer`、前后台切换、静置均无效,只有重启进程能恢复——
因为 `PlayerView` 复用的是同一个 `SurfaceView`。
现在收到 `ERROR_CODE_DECODER_INIT_FAILED` 时不再当成「这路流坏了」去换源
(换源是错的,一路换到底只会把所有源都误判成坏的),
而是把 `PlayerView` 收起再放出,逼 `SurfaceView` 走一遍
`surfaceDestroyed` / `surfaceCreated`,然后用新播放器原地重试当前这一路,只重试一次
- **修复 Web 管理页里的中文变成 `?`**。给播放源起中文名保存后全是问号:
NanoHTTPD 的 `session.parseBody()` 内部是 `new String(postBytes, contentType.getEncoding())`,
而 `getEncoding()` 在 `Content-Type` 不带 charset 时默认返回 **US-ASCII**,
浏览器 `fetch` 发 `application/json` 时正是不带的。改为自己按 `Content-Length`
读原始字节再按 UTF-8 解码,不依赖请求头;并加 1 MiB 上限——服务监听在局域网上且没有鉴权,
按 `Content-Length` 预分配可被一个超大值直接 OOM 掉
- **修复开着 VPN 时 Web 地址显示隧道地址**。`getActiveNetwork()` 此时返回的就是 VPN,
而排除虚拟接口的逻辑在它后面、根本到不了。那个地址写到界面上局域网里的手机连不上,
而物理网卡的地址其实还可用。现在先按接口名过滤,虚拟接口一律退回枚举网卡
- **修复切换解码方式后播放失败**(`ERROR_CODE_DECODER_INIT_FAILED`)。重建播放器时先
`player.release()` 再由 `initialize()` 调 `playerView.setPlayer(newPlayer)`,而后者会对**旧**
播放器调 `clearVideoSurfaceView()` 去解绑 Surface——此时旧播放器已释放,消息被直接丢弃
(`Ignoring messages sent after release`),Surface 永远停在 connected 状态,新建的
MediaCodec 连不上:`native_window_api_connect returned an error (-22)`,两个解码器依次失败。
改为**先 `playerView.setPlayer(null)` 解绑、再 release**;`release()` 里同样处理。
该缺陷在 v1.3.0 中已存在
- **修复 Web 管理地址显示为 `0.0.0.0`**。原实现只读
`WifiManager.getConnectionInfo().getIpAddress()`,而电视盒子接网线是常态,有线连接时该接口恒返回 0。
新增 `DeviceIpUtil`:优先取系统认定的活动网络地址,取不到再枚举网卡,
有线优先于无线并排除 `p2p`/`dummy`/`tun` 等虚拟接口

### Changed

- **Web 管理端口由 9978 改为 9979**,并把这个数字收敛成 `WebServer.PORT` / `buildUrl()`——
它此前在 Application、播放页、设置页三处硬编码,改端口要同时改三处且很容易漏
- 解码方式由单一全局开关改为**音频与视频分别取值**(`WiTVRenderersFactory`)。
`DefaultRenderersFactory` 只有一个 `extensionRendererMode`,音视频共用,
而这两者的最佳取舍常常相反;现在分别覆写 `buildAudioRenderers` 与 `buildVideoRenderers`
- 「软解优先」更名为「全部软解优先」,描述中补充了低端设备上 1080p 可能卡顿的提示
- 新增单元测试 `WiTVRenderersFactoryTest`,直接断言产出的渲染器顺序
(顺序由 NextLib 的插入逻辑决定,升级时可能变化,只断言配置值不够)

## [1.3.0] - 2026-10-03

本版主题是 **UDP / RTP 组播直播**,并引入 FFmpeg 软解补齐电视盒子硬解常缺的编码。
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ Android TV M3U 直播播放器,使用原生 ExoPlayer 播放,支持多源自

![](docs/images/epg.png)

完整的图文使用说明见 [docs/user-guide.md](docs/user-guide.md)。

## 功能

- **M3U 播放源管理** — 支持添加多个 M3U/M3U8 播放源地址,在历史源之间快速切换
- **多源自动切换** — 同一频道聚合多个播放地址,播放失败时自动尝试下一个源
- **EPG 节目预告** — 自动读取 M3U 中的 `x-tvg-url` 属性,也支持手动配置 XMLTV 地址
- **局域网 Web 管理** — 内置 HTTP 服务器(端口 9978),通过手机/电脑浏览器管理播放源和设置
- **局域网 Web 管理** — 内置 HTTP 服务器(端口 9979),通过手机/电脑浏览器管理播放源和设置
- **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 Down Expand Up @@ -78,7 +80,7 @@ APK 输出路径:`app/build/outputs/apk/debug/app-debug.apk`
## 使用方式

1. 安装 APK 到 Android TV 设备
2. 启动应用,主界面会显示局域网管理地址(如 `http://192.168.1.100:9978`)
2. 启动应用,主界面会显示局域网管理地址(如 `http://192.168.1.100:9979`)
3. 在手机或电脑浏览器中打开该地址
4. 添加 M3U 播放源地址
5. 返回 TV 端即可看到频道列表,选择频道开始播放
Expand Down
4 changes: 4 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ dependencies {
// NanoHTTPD
implementation "org.nanohttpd:nanohttpd:2.3.1"

// 二维码生成(Web 管理地址扫码)。纯 Java、零运行时传递依赖,
// 已核对不含 java.time / stream / function,minSdk 23 无需 desugaring。
implementation "com.google.zxing:core:3.5.3"

// OkHttp + Gson
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation "com.google.code.gson:gson:2.10.1"
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/assets/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ document.getElementById('addSourceForm').addEventListener('submit', async (e) =>
try {
const res = await fetch(`${API}/api/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ name: name || url, url })
});
if (!res.ok) throw new Error(await res.text());
Expand Down Expand Up @@ -237,7 +237,7 @@ async function saveSettings() {
try {
const res = await fetch(`${API}/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ epgUrl, udpxyProxyBase })
});
const data = await res.json();
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/whyun/witv/WiTVApp.java
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public void notifyActiveSourceChanged(long sourceId) {

private void startWebServer() {
try {
webServer = new WebServer(this, 9978);
webServer = new WebServer(this, WebServer.PORT);
webServer.start();
} catch (Exception e) {
e.printStackTrace();
Expand Down
62 changes: 62 additions & 0 deletions app/src/main/java/com/whyun/witv/data/BomAwareText.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.whyun.witv.data;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

/**
* 按「BOM > 响应声明的 charset > UTF-8」的顺序解码文本。
*
* <p>这是 OkHttp {@code ResponseBody.string()} 的行为。之前 m3u 走的就是它,后来为了支持 gzip
* 改成自己读流,如果顺手写成硬编码 UTF-8,GBK 编码的直播源(国内 IPTV 列表里相当常见)
* 频道名和分组名就会整片变成乱码——所以这里必须把那套规则补回来。
*
* <p>BOM 优先于 Content-Type 里声明的 charset:BOM 是文件自己带的,而 Content-Type 是服务器
* 配置出来的,后者经常是随手填的默认值。
*/
public final class BomAwareText {

private BomAwareText() {
}

/**
* @param bytes 完整内容
* @param declared 响应头里声明的编码,没有则传 {@code null}
*/
@NonNull
public static String decode(@NonNull byte[] bytes, @Nullable Charset declared) {
Bom bom = detectBom(bytes);
if (bom != null) {
// BOM 的那几个字节必须跳过,否则解出来的字符串以 U+FEFF 开头,
// m3u 解析器连 #EXTM3U 都认不出来
return new String(bytes, bom.length, bytes.length - bom.length, bom.charset);
}
return new String(bytes, declared != null ? declared : StandardCharsets.UTF_8);
}

@Nullable
private static Bom detectBom(@NonNull byte[] b) {
if (b.length >= 3 && b[0] == (byte) 0xEF && b[1] == (byte) 0xBB && b[2] == (byte) 0xBF) {
return new Bom(3, StandardCharsets.UTF_8);
}
if (b.length >= 2 && b[0] == (byte) 0xFE && b[1] == (byte) 0xFF) {
return new Bom(2, StandardCharsets.UTF_16BE);
}
if (b.length >= 2 && b[0] == (byte) 0xFF && b[1] == (byte) 0xFE) {
return new Bom(2, StandardCharsets.UTF_16LE);
}
return null;
}

private static final class Bom {
final int length;
final Charset charset;

Bom(int length, Charset charset) {
this.length = length;
this.charset = charset;
}
}
}
62 changes: 62 additions & 0 deletions app/src/main/java/com/whyun/witv/data/GzipAwareStreams.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.whyun.witv.data;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.zip.GZIPInputStream;

/**
* 按内容自动解压 gzip 的输入流包装。
*
* <p>XMLTV 节目单动辄几十 MB,公开 EPG 源基本都提供 {@code .xml.gz};m3u 也有 {@code .m3u.gz}。
* OkHttp 的透明解压只在响应带 {@code Content-Encoding: gzip} 时生效,而这类地址返回的是
* {@code Content-Type: application/gzip}——「内容本身就是压缩文件」与「传输编码」是两回事,
* 后者 OkHttp 不会碰,拿到的是裸 gzip 字节。
*
* <p>判断依据是 <b>魔数</b>而非 URL 后缀或 {@code Content-Type}:两者都不可靠,
* 有的源地址不带 {@code .gz} 却返回 gzip,有的把 {@code Content-Type} 标成 {@code text/xml}。
* gzip 固定以 {@code 0x1F 0x8B} 开头,而 XML 与 m3u 都不可能以这两个字节开头,不会误判。
*/
public final class GzipAwareStreams {

private static final int GZIP_MAGIC_BYTE_1 = 0x1F;
private static final int GZIP_MAGIC_BYTE_2 = 0x8B;

private GzipAwareStreams() {
}

/**
* 内容是 gzip 就返回解压流,否则原样返回(已带缓冲)。
*
* @param source 原始流;调用方仍需负责关闭返回的流
*/
@NonNull
public static InputStream maybeDecompress(@NonNull InputStream source) throws IOException {
// GZIPInputStream 内部也会缓冲,但这里必须自己包一层才能 mark/reset 做嗅探
BufferedInputStream buffered = source instanceof BufferedInputStream
? (BufferedInputStream) source
: new BufferedInputStream(source);
return isGzip(buffered) ? new GZIPInputStream(buffered) : buffered;
}

/**
* 窥探前两个字节判断是否为 gzip,并把流复位,不消耗数据。
*
* <p>流不足两个字节(空响应、截断)时一律按非 gzip 处理,交给后续解析器报错,
* 这里不额外抛异常。
*/
@VisibleForTesting
static boolean isGzip(@NonNull BufferedInputStream in) throws IOException {
in.mark(2);
try {
int first = in.read();
int second = in.read();
return first == GZIP_MAGIC_BYTE_1 && second == GZIP_MAGIC_BYTE_2;
} finally {
in.reset();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import android.content.Context;

import com.whyun.witv.data.BomAwareText;
import com.whyun.witv.data.GzipAwareStreams;
import com.whyun.witv.data.db.AppDatabase;
import com.whyun.witv.data.db.dao.ChannelDao;
import com.whyun.witv.data.db.dao.ChannelSourceDao;
Expand All @@ -13,6 +15,8 @@
import com.whyun.witv.data.db.entity.M3USource;
import com.whyun.witv.data.parser.M3UParser;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
Expand All @@ -22,6 +26,7 @@
import java.util.Set;
import java.util.concurrent.TimeUnit;

import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
Expand Down Expand Up @@ -56,6 +61,16 @@ public ChannelRepository(Context context) {
* @return The parse result
* @throws IOException if network request fails
*/
private static byte[] readFully(InputStream inputStream) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int read;
while ((read = inputStream.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
return out.toByteArray();
}

public M3UParser.ParseResult loadSource(M3USource source) throws IOException {
Request request = new Request.Builder()
.url(source.url)
Expand All @@ -65,7 +80,17 @@ public M3UParser.ParseResult loadSource(M3USource source) throws IOException {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("Failed to fetch M3U: " + response.code());
}
String content = response.body().string();
// 同样支持 .m3u.gz:按魔数判断,不依赖 URL 后缀或 Content-Type
byte[] raw;
try (InputStream inputStream =
GzipAwareStreams.maybeDecompress(response.body().byteStream())) {
raw = readFully(inputStream);
}
// 编码按「BOM > 响应声明 > UTF-8」判断,和原来 ResponseBody.string() 的行为一致。
// 国内直播源有不少是 GBK 的,硬编码 UTF-8 会让频道名整片变成乱码
MediaType contentType = response.body().contentType();
String content = BomAwareText.decode(
raw, contentType != null ? contentType.charset() : null);
M3UParser.ParseResult result = new M3UParser().parse(content);

long sourceId = source.id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import android.content.Context;

import com.whyun.witv.data.GzipAwareStreams;
import com.whyun.witv.data.db.AppDatabase;
import com.whyun.witv.data.db.dao.EpgChannelDao;
import com.whyun.witv.data.db.dao.EpgDao;
Expand Down Expand Up @@ -55,7 +56,10 @@ public void loadEpg(String epgUrl) throws IOException, XmlPullParserException {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("Failed to fetch EPG: " + response.code());
}
try (InputStream inputStream = response.body().byteStream()) {
// 公开 EPG 源基本都提供 .xml.gz;这类响应是 Content-Type: application/gzip,
// 不带 Content-Encoding,OkHttp 不会自动解压,必须自己按魔数判断
try (InputStream inputStream =
GzipAwareStreams.maybeDecompress(response.body().byteStream())) {
EpgParser.ParseResult result = new EpgParser().parseFull(inputStream);

epgDao.deleteAll();
Expand Down
Loading
Loading