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

## [Unreleased]

### Changed

- **频道菜单更适合遥控器操作**:焦点行改为实心蓝底加白色描边,和「当前在播」行的浅色底区分开,
在播行另加 ▶ 标记;每行显示全局频道号,与数字键换台的编号一致;
副标题改为该频道正在播出的节目名(后台逐行查询,没有节目单时仍显示分组名);
右侧节目单区底部加上按键提示
- **「启动时刷新 M3U」改为后台进行**:先用本地缓存的频道列表起播,刷新完成后静默换上新列表
(频道 id 不变,正在播的节目不受影响;频道菜单开着时不重建,下次打开即是新列表)。
以前要刷新完才起播,源站慢时 p230 实测近 50 秒不出画面、频道菜单也打不开,现在约 1 秒起播。
缓存为空(首次使用)时仍等刷新结果,期间提示「正在更新频道列表…」。
正在播的是刷新前的旧地址且全部失败时,用刷新后的新地址自动重播一次
- `ChannelRepository.loadSource` 写库包进事务:播放源是先删后插的,刷新改到后台后,
中途换台可能读到空的播放源列表
- M3U 刷新解析出 0 个频道时按刷新失败处理、不写库。以前源站临时返回空页面或错误页,
会把本地缓存的频道全部删光;改为先用缓存起播后,这份缓存更不能被一次坏响应清掉。
设置页与 Web 管理页的刷新同样受益

### Fixed

- 频道菜单里切换分组后焦点会掉到播放画面上:分组列表刷新选中态用的是 `notifyDataSetChanged`,
带焦点的行被重新绑定后焦点丢失。改为只刷新新旧两行
- 频道列表按右键焦点会落到右侧节目单的 `ScrollView` 上,此时按返回会直接关掉整个菜单。
`ScrollView` 构造时强制设为可获焦,XML 里关不掉,改在代码里关掉
- 换台或启动后 5 秒内打开频道菜单,会被底部信息条的自动隐藏计时器一起关掉
- **修复 CCTV-4K 在「硬解优先」下卡顿**(`LevelTolerantVideoRenderer`)。码流标的是
`hvc1.2.4.L180`(HEVC Main10 Level 6.0),p230 的 `OMX.amlogic.hevc.decoder.awesome`
只声明到 Level 5.1,硬解渲染器报「超出能力」、FFmpeg 报「支持」,轨道选择于是把 4K 10bit
交给了 CPU 软解,几秒就丢一两百帧;同为 4K 的求索纪录标的是 Level 5.1,走硬解所以正常。
切到「仅硬解」后 CCTV-4K 播放正常,说明只是 Level 标得保守。现在硬件解码器声明支持该
Profile、且分辨率/帧率在能力内时,只是 Level 超出也按「支持」处理,硬解胜出;
Profile 不支持等真解不了的情况仍由 FFmpeg 补位。「全部软解优先」与「仅硬解」不受影响

## [1.5.0] - 2026-10-05

本版让 WiTV 可以直接当电视盒子的桌面:开机就是电视,按 Home 回到正在播的画面,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import android.content.Context;

import androidx.annotation.VisibleForTesting;

import com.whyun.witv.data.BomAwareText;
import com.whyun.witv.data.GzipAwareStreams;
import com.whyun.witv.data.db.AppDatabase;
Expand Down Expand Up @@ -36,22 +38,28 @@
*/
public class ChannelRepository {

private final AppDatabase db;
private final ChannelDao channelDao;
private final ChannelSourceDao channelSourceDao;
private final M3USourceDao m3uSourceDao;
private final FavoriteChannelDao favoriteChannelDao;
private final OkHttpClient httpClient;

public ChannelRepository(Context context) {
AppDatabase db = AppDatabase.getInstance(context);
this(AppDatabase.getInstance(context), new OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build());
}

@VisibleForTesting
ChannelRepository(AppDatabase db, OkHttpClient httpClient) {
this.db = db;
this.channelDao = db.channelDao();
this.channelSourceDao = db.channelSourceDao();
this.m3uSourceDao = db.m3uSourceDao();
this.favoriteChannelDao = db.favoriteChannelDao();
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
this.httpClient = httpClient;
}

/**
Expand Down Expand Up @@ -92,80 +100,88 @@ public M3UParser.ParseResult loadSource(M3USource source) throws IOException {
String content = BomAwareText.decode(
raw, contentType != null ? contentType.charset() : null);
M3UParser.ParseResult result = new M3UParser().parse(content);
// 一个频道都没解析出来,多半是源站临时返回了空页面或错误页。照常写库会把本地缓存的频道
// 全部删光,而播放页是先用缓存起播的,所以按刷新失败处理,保留原有频道
if (result.channels == null || result.channels.isEmpty()) {
throw new IOException("M3U contains no channels: " + source.url);
}
// channel_sources 是先删后插的:不放进事务的话,播放页刷新途中换台会读到空的播放源列表
db.runInTransaction(() -> saveParsedSource(source, result));
return result;
}
}

long sourceId = source.id;
private void saveParsedSource(M3USource source, M3UParser.ParseResult result) {
long sourceId = source.id;

// Build map of existing channels keyed by natural key
// (tvgId if non-empty, else displayName — same key M3UParser uses for aggregation)
List<Channel> existingChannels = channelDao.getBySource(sourceId);
Map<String, Channel> existingMap = new LinkedHashMap<>();
for (Channel ch : existingChannels) {
String key = (ch.tvgId != null && !ch.tvgId.isEmpty()) ? ch.tvgId : ch.displayName;
existingMap.put(key, ch);
}
// Build map of existing channels keyed by natural key
// (tvgId if non-empty, else displayName — same key M3UParser uses for aggregation)
List<Channel> existingChannels = channelDao.getBySource(sourceId);
Map<String, Channel> existingMap = new LinkedHashMap<>();
for (Channel ch : existingChannels) {
String key = (ch.tvgId != null && !ch.tvgId.isEmpty()) ? ch.tvgId : ch.displayName;
existingMap.put(key, ch);
}

// Delete all channel_sources; they will be re-created with fresh URLs
channelSourceDao.deleteBySource(sourceId);

Set<String> matchedKeys = new HashSet<>();
int sortOrder = 0;
for (M3UParser.ParsedChannel parsed : result.channels) {
String key = (parsed.tvgId != null && !parsed.tvgId.isEmpty())
? parsed.tvgId : parsed.displayName;

Channel existing = existingMap.get(key);
long channelId;

if (existing != null) {
matchedKeys.add(key);
existing.tvgId = parsed.tvgId;
existing.tvgName = parsed.tvgName;
existing.displayName = parsed.displayName;
existing.logoUrl = parsed.logoUrl;
existing.groupTitle = parsed.groupTitle;
existing.sortOrder = sortOrder++;
channelDao.update(existing);
channelId = existing.id;
} else {
Channel channel = new Channel(
sourceId,
parsed.tvgId,
parsed.tvgName,
parsed.displayName,
parsed.logoUrl,
parsed.groupTitle,
sortOrder++
);
channelId = channelDao.insert(channel);
}

List<ChannelSource> sources = new ArrayList<>();
for (int i = 0; i < parsed.sourceUrls.size(); i++) {
sources.add(new ChannelSource(channelId, parsed.sourceUrls.get(i), i));
}
if (!sources.isEmpty()) {
channelSourceDao.insertAll(sources);
}
// Delete all channel_sources; they will be re-created with fresh URLs
channelSourceDao.deleteBySource(sourceId);

Set<String> matchedKeys = new HashSet<>();
int sortOrder = 0;
for (M3UParser.ParsedChannel parsed : result.channels) {
String key = (parsed.tvgId != null && !parsed.tvgId.isEmpty())
? parsed.tvgId : parsed.displayName;

Channel existing = existingMap.get(key);
long channelId;

if (existing != null) {
matchedKeys.add(key);
existing.tvgId = parsed.tvgId;
existing.tvgName = parsed.tvgName;
existing.displayName = parsed.displayName;
existing.logoUrl = parsed.logoUrl;
existing.groupTitle = parsed.groupTitle;
existing.sortOrder = sortOrder++;
channelDao.update(existing);
channelId = existing.id;
} else {
Channel channel = new Channel(
sourceId,
parsed.tvgId,
parsed.tvgName,
parsed.displayName,
parsed.logoUrl,
parsed.groupTitle,
sortOrder++
);
channelId = channelDao.insert(channel);
}

// Remove channels that no longer exist in the source
List<Long> toDelete = new ArrayList<>();
for (Map.Entry<String, Channel> entry : existingMap.entrySet()) {
if (!matchedKeys.contains(entry.getKey())) {
toDelete.add(entry.getValue().id);
}
List<ChannelSource> sources = new ArrayList<>();
for (int i = 0; i < parsed.sourceUrls.size(); i++) {
sources.add(new ChannelSource(channelId, parsed.sourceUrls.get(i), i));
}
if (!toDelete.isEmpty()) {
channelDao.deleteByIds(toDelete);
if (!sources.isEmpty()) {
channelSourceDao.insertAll(sources);
}
}

// Update source EPG URL if found in parse result
if (result.epgUrl != null && !result.epgUrl.isEmpty()) {
source.epgUrl = result.epgUrl;
m3uSourceDao.update(source);
// Remove channels that no longer exist in the source
List<Long> toDelete = new ArrayList<>();
for (Map.Entry<String, Channel> entry : existingMap.entrySet()) {
if (!matchedKeys.contains(entry.getKey())) {
toDelete.add(entry.getValue().id);
}
}
if (!toDelete.isEmpty()) {
channelDao.deleteByIds(toDelete);
}

return result;
// Update source EPG URL if found in parse result
if (result.epgUrl != null && !result.epgUrl.isEmpty()) {
source.epgUrl = result.epgUrl;
m3uSourceDao.update(source);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package com.whyun.witv.player;

import android.content.Context;
import android.media.MediaCodecInfo.CodecProfileLevel;
import android.util.Log;
import android.util.Pair;

import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlaybackException;
import androidx.media3.exoplayer.ForwardingRenderer;
import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.RendererCapabilities;
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo;
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector;
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil;

import java.util.List;

/**
* 包住 {@code MediaCodecVideoRenderer}:码流只是 <b>Level</b> 标得比硬解声明的高时,仍然报告「支持」,
* 让硬解在轨道选择里胜过 FFmpeg 软解。
*
* <p>实测:CCTV-4K 组播标的是 {@code hvc1.2.4.L180}(HEVC Main10 Level 6.0),而 S905X 的
* {@code OMX.amlogic.hevc.decoder.awesome} 只声明到 Level 5.1。硬解渲染器于是报
* {@code FORMAT_EXCEEDS_CAPABILITIES},FFmpeg 渲染器报 {@code FORMAT_HANDLED},轨道选择取支持度更高的,
* 视频就落到了 CPU 软解 4K 10bit 上,持续大量丢帧。可同一个台在「仅硬解」下播放正常——
* 编码器的 Level 往往标得保守,实际码率、分辨率仍在硬解能力内。
*
* <p>只放宽 Level:mime、Profile 必须被某个硬件解码器声明支持,去掉 codecs 串后的分辨率/帧率也必须在它的
* 能力内;Profile 不支持(比如解码器根本不支持 10bit)等真正解不了的情况照旧交给 FFmpeg 补位。
*/
@OptIn(markerClass = UnstableApi.class)
final class LevelTolerantVideoRenderer extends ForwardingRenderer {

private static final String TAG = "LevelTolerantVideo";

private final RendererCapabilities capabilities;

LevelTolerantVideoRenderer(@NonNull Renderer renderer, @NonNull Context context,
@NonNull MediaCodecSelector mediaCodecSelector) {
super(renderer);
capabilities = new Capabilities(renderer.getCapabilities(), context, mediaCodecSelector);
}

@NonNull
@Override
public RendererCapabilities getCapabilities() {
return capabilities;
}

/**
* 硬解只是因为 Level 不够才被判为超出能力时返回 true。
*/
@VisibleForTesting
static boolean exceedsOnlyByLevel(@NonNull Context context,
@NonNull MediaCodecSelector mediaCodecSelector,
@NonNull Format format)
throws MediaCodecUtil.DecoderQueryException {
String mimeType = format.sampleMimeType;
if (mimeType == null || format.codecs == null || format.drmInitData != null) {
return false;
}
Pair<Integer, Integer> profileAndLevel = MediaCodecUtil.getCodecProfileAndLevel(format);
if (profileAndLevel == null) {
return false;
}
int profile = profileAndLevel.first;
// 去掉 codecs 串后,isFormatSupported 就不再检查 Profile/Level,只看 mime 和分辨率/帧率
Format withoutCodecs = format.buildUpon().setCodecs(null).build();
List<MediaCodecInfo> decoderInfos =
mediaCodecSelector.getDecoderInfos(mimeType, false, false);
for (MediaCodecInfo info : decoderInfos) {
if (info.hardwareAccelerated
&& supportsProfile(info, profile)
&& info.isFormatSupported(context, withoutCodecs)) {
return true;
}
}
return false;
}

private static boolean supportsProfile(MediaCodecInfo info, int profile) {
for (CodecProfileLevel profileLevel : info.getProfileLevels()) {
if (profileLevel.profile == profile) {
Comment on lines +84 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict level relaxation to verified decoder cases

When a stream genuinely exceeds a decoder's advertised level, matching the profile and resolution/frame rate does not establish that the decoder can handle it: codec levels also constrain properties such as bitrate, sample rate, and decoded-picture buffering. This loop therefore promotes every over-level AVC/HEVC/etc. format to FORMAT_HANDLED, causing the hardware renderer to win over FFmpeg even on devices whose level declaration is accurate; those streams can then fail or stutter instead of using the previously selected software fallback. Limit the workaround to known affected codecs/decoders or otherwise verify the constraints represented by the level before returning true.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不修改。确实存在码流真超出 Level、硬解却被判为支持的情况,但只在 1080p 及以下才有实际影响,因为 4K 交给软解在电视盒子上本来就跑不动。会出现这种情况的是硬解只声明到 Level 4.1 一类的老设备,年代已经比较久,项目不再支持,所以保留目前的放宽条件。

return true;
}
}
return false;
}

private static final class Capabilities implements RendererCapabilities {

private final RendererCapabilities delegate;
private final Context context;
private final MediaCodecSelector mediaCodecSelector;

Capabilities(RendererCapabilities delegate, Context context,
MediaCodecSelector mediaCodecSelector) {
this.delegate = delegate;
this.context = context;
this.mediaCodecSelector = mediaCodecSelector;
}

@NonNull
@Override
public String getName() {
return delegate.getName();
}

@Override
public int getTrackType() {
return delegate.getTrackType();
}

@Override
public int supportsFormat(@NonNull Format format) throws ExoPlaybackException {
int support = delegate.supportsFormat(format);
if (RendererCapabilities.getFormatSupport(support) != C.FORMAT_EXCEEDS_CAPABILITIES) {
return support;
}
try {
if (!exceedsOnlyByLevel(context, mediaCodecSelector, format)) {
return support;
}
} catch (MediaCodecUtil.DecoderQueryException e) {
Log.w(TAG, "Decoder query failed, keep original support", e);
return support;
}
Log.i(TAG, "Only codec level exceeds hardware decoder, treat as handled: "
+ format.codecs);
return (support & ~FORMAT_SUPPORT_MASK) | C.FORMAT_HANDLED;
}

@Override
public int supportsMixedMimeTypeAdaptation() throws ExoPlaybackException {
return delegate.supportsMixedMimeTypeAdaptation();
}

@Override
public void setListener(@NonNull Listener listener) {
delegate.setListener(listener);
}

@Override
public void clearListener() {
delegate.clearListener();
}
}
}
Loading
Loading