diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc08de..44eeeb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 回到正在播的画面, diff --git a/app/src/main/java/com/whyun/witv/data/repository/ChannelRepository.java b/app/src/main/java/com/whyun/witv/data/repository/ChannelRepository.java index bc465a8..51f9144 100644 --- a/app/src/main/java/com/whyun/witv/data/repository/ChannelRepository.java +++ b/app/src/main/java/com/whyun/witv/data/repository/ChannelRepository.java @@ -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; @@ -36,6 +38,7 @@ */ public class ChannelRepository { + private final AppDatabase db; private final ChannelDao channelDao; private final ChannelSourceDao channelSourceDao; private final M3USourceDao m3uSourceDao; @@ -43,15 +46,20 @@ public class ChannelRepository { 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; } /** @@ -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 existingChannels = channelDao.getBySource(sourceId); - Map 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 existingChannels = channelDao.getBySource(sourceId); + Map 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 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 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 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 toDelete = new ArrayList<>(); - for (Map.Entry entry : existingMap.entrySet()) { - if (!matchedKeys.contains(entry.getKey())) { - toDelete.add(entry.getValue().id); - } + List 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 toDelete = new ArrayList<>(); + for (Map.Entry 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); } } diff --git a/app/src/main/java/com/whyun/witv/player/LevelTolerantVideoRenderer.java b/app/src/main/java/com/whyun/witv/player/LevelTolerantVideoRenderer.java new file mode 100644 index 0000000..e3de8e0 --- /dev/null +++ b/app/src/main/java/com/whyun/witv/player/LevelTolerantVideoRenderer.java @@ -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}:码流只是 Level 标得比硬解声明的高时,仍然报告「支持」, + * 让硬解在轨道选择里胜过 FFmpeg 软解。 + * + *

实测: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 往往标得保守,实际码率、分辨率仍在硬解能力内。 + * + *

只放宽 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 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 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) { + 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(); + } + } +} diff --git a/app/src/main/java/com/whyun/witv/player/WiTVRenderersFactory.java b/app/src/main/java/com/whyun/witv/player/WiTVRenderersFactory.java index 74a9682..840167a 100644 --- a/app/src/main/java/com/whyun/witv/player/WiTVRenderersFactory.java +++ b/app/src/main/java/com/whyun/witv/player/WiTVRenderersFactory.java @@ -6,10 +6,12 @@ import androidx.annotation.NonNull; import androidx.annotation.OptIn; import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.DefaultRenderersFactory; import androidx.media3.exoplayer.Renderer; import androidx.media3.exoplayer.audio.AudioRendererEventListener; import androidx.media3.exoplayer.audio.AudioSink; import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import androidx.media3.exoplayer.video.MediaCodecVideoRenderer; import androidx.media3.exoplayer.video.VideoRendererEventListener; import java.util.ArrayList; @@ -31,6 +33,9 @@ * *

所以这里分别覆写两个 {@code build*Renderers},各自传入 * {@link PlaybackDecoderMode} 给出的模式,忽略框架传进来的那个全局值。 + * + *

视频硬解优先、FFmpeg 补位时,硬解渲染器再包一层 {@link LevelTolerantVideoRenderer}: + * 码流 Level 标得比硬解声明的高时(CCTV-4K),仍让硬解胜出,而不是落到软解上卡顿。 */ @OptIn(markerClass = UnstableApi.class) final class WiTVRenderersFactory extends NextRenderersFactory { @@ -67,8 +72,20 @@ protected void buildVideoRenderers(@NonNull Context context, @NonNull VideoRendererEventListener eventListener, long allowedVideoJoiningTimeMs, @NonNull ArrayList out) { - super.buildVideoRenderers(context, decoderMode.getVideoExtensionRendererMode(), + int videoMode = decoderMode.getVideoExtensionRendererMode(); + int start = out.size(); + super.buildVideoRenderers(context, videoMode, mediaCodecSelector, enableDecoderFallback, eventHandler, eventListener, allowedVideoJoiningTimeMs, out); + if (videoMode != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) { + // PREFER 是用户明确要软解优先;OFF 没有软解可抢,都不需要放宽 + return; + } + for (int i = start; i < out.size(); i++) { + Renderer renderer = out.get(i); + if (renderer instanceof MediaCodecVideoRenderer) { + out.set(i, new LevelTolerantVideoRenderer(renderer, context, mediaCodecSelector)); + } + } } } diff --git a/app/src/main/java/com/whyun/witv/ui/ChannelGroupListAdapter.java b/app/src/main/java/com/whyun/witv/ui/ChannelGroupListAdapter.java index aca343b..63aa9ac 100644 --- a/app/src/main/java/com/whyun/witv/ui/ChannelGroupListAdapter.java +++ b/app/src/main/java/com/whyun/witv/ui/ChannelGroupListAdapter.java @@ -23,6 +23,8 @@ public interface OnGroupFocusedListener { void onGroupFocused(int position, String groupName); } + private static final Object PAYLOAD_SELECTION = new Object(); + private final List groups; private int selectedIndex; @Nullable @@ -43,8 +45,17 @@ public void setSelectedIndex(int selectedIndex) { if (this.selectedIndex == selectedIndex) { return; } + int previous = this.selectedIndex; this.selectedIndex = selectedIndex; - notifyDataSetChanged(); + // 不能 notifyDataSetChanged:没有 stable id 时 RecyclerView 找不回带焦点的那一行, + // 焦点会退回根布局的第一个可聚焦控件(播放器),菜单开着却没法再操作。 + // 带 payload 只刷新选中态,复用原 ViewHolder,焦点保持不动。 + if (previous >= 0 && previous < groups.size()) { + notifyItemChanged(previous, PAYLOAD_SELECTION); + } + if (selectedIndex >= 0 && selectedIndex < groups.size()) { + notifyItemChanged(selectedIndex, PAYLOAD_SELECTION); + } } @NonNull @@ -78,6 +89,15 @@ public void onBindViewHolder(@NonNull VH holder, int position) { }); } + @Override + public void onBindViewHolder(@NonNull VH holder, int position, @NonNull List payloads) { + if (!payloads.isEmpty() && payloads.contains(PAYLOAD_SELECTION)) { + holder.itemView.setSelected(position == selectedIndex); + return; + } + super.onBindViewHolder(holder, position, payloads); + } + @Override public int getItemCount() { return groups.size(); diff --git a/app/src/main/java/com/whyun/witv/ui/ChannelListAdapter.java b/app/src/main/java/com/whyun/witv/ui/ChannelListAdapter.java index 145d8b4..b4de720 100644 --- a/app/src/main/java/com/whyun/witv/ui/ChannelListAdapter.java +++ b/app/src/main/java/com/whyun/witv/ui/ChannelListAdapter.java @@ -16,6 +16,7 @@ import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Set; public class ChannelListAdapter extends RecyclerView.Adapter { @@ -34,6 +35,21 @@ public interface OnChannelLongClickListener { boolean onChannelLongClick(Channel channel); } + /** + * 列表行副标题的「正在播出」节目来源。查询要走数据库,由宿主异步完成后调 + * {@link #notifyCurrentProgramLoaded(long)} 刷新对应行。 + */ + public interface CurrentProgramSource { + /** 已缓存的节目名;null 表示还没查过,"" 表示查过但没有节目信息 */ + @Nullable + String getCachedCurrentProgram(Channel channel); + + /** 发起异步查询(宿主负责去重) */ + void requestCurrentProgram(Channel channel); + } + + private static final Object PAYLOAD_CURRENT_PROGRAM = new Object(); + private final List channels; private final int selectedIndex; private final OnChannelClickListener listener; @@ -42,6 +58,10 @@ public interface OnChannelLongClickListener { @Nullable private final OnChannelLongClickListener longClickListener; private Set favoriteIds = Collections.emptySet(); + /** 频道 id → 全局频道号(与数字键换台一致);没有时退回组内序号 */ + private Map channelNumbers = Collections.emptyMap(); + @Nullable + private CurrentProgramSource currentProgramSource; public ChannelListAdapter(List channels, int selectedIndex, OnChannelClickListener listener) { this(channels, selectedIndex, Collections.emptySet(), listener, null, null); @@ -81,6 +101,23 @@ public void setFavoriteIds(Set ids) { notifyDataSetChanged(); } + public void setChannelNumbers(Map numbers) { + channelNumbers = numbers != null ? numbers : Collections.emptyMap(); + } + + public void setCurrentProgramSource(@Nullable CurrentProgramSource source) { + currentProgramSource = source; + } + + /** 某个频道的当前节目查好了,只刷新副标题,不重绑整行(避免打断焦点) */ + public void notifyCurrentProgramLoaded(long channelId) { + for (int i = 0; i < channels.size(); i++) { + if (channels.get(i).id == channelId) { + notifyItemChanged(i, PAYLOAD_CURRENT_PROGRAM); + } + } + } + @NonNull @Override public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @@ -94,14 +131,10 @@ public void onBindViewHolder(@NonNull VH holder, int position) { Channel channel = channels.get(position); holder.nameView.setText(channel.displayName); - holder.numberView.setText(String.valueOf(position + 1)); + Integer number = channelNumbers.get(channel.id); + holder.numberView.setText(String.valueOf(number != null ? number : position + 1)); - if (channel.groupTitle != null && !channel.groupTitle.isEmpty()) { - holder.epgView.setText(channel.groupTitle); - holder.epgView.setVisibility(View.VISIBLE); - } else { - holder.epgView.setVisibility(View.GONE); - } + bindSubtitle(holder, channel, true); if (channel.logoUrl != null && !channel.logoUrl.isEmpty()) { Glide.with(holder.itemView.getContext()) @@ -118,6 +151,9 @@ public void onBindViewHolder(@NonNull VH holder, int position) { } holder.itemView.setSelected(position == selectedIndex); + if (holder.playingView != null) { + holder.playingView.setVisibility(position == selectedIndex ? View.VISIBLE : View.GONE); + } holder.itemView.setOnClickListener(v -> { if (listener != null) listener.onChannelClick(channel); }); @@ -141,6 +177,33 @@ public void onBindViewHolder(@NonNull VH holder, int position) { } } + @Override + public void onBindViewHolder(@NonNull VH holder, int position, @NonNull List payloads) { + if (!payloads.isEmpty() && payloads.contains(PAYLOAD_CURRENT_PROGRAM)) { + bindSubtitle(holder, channels.get(position), false); + return; + } + super.onBindViewHolder(holder, position, payloads); + } + + /** 副标题优先显示正在播出的节目,没有(或还没查到)时退回分组名 */ + private void bindSubtitle(@NonNull VH holder, Channel channel, boolean requestIfMissing) { + String program = null; + if (currentProgramSource != null) { + program = currentProgramSource.getCachedCurrentProgram(channel); + if (program == null && requestIfMissing) { + currentProgramSource.requestCurrentProgram(channel); + } + } + String subtitle = program != null && !program.isEmpty() ? program : channel.groupTitle; + if (subtitle != null && !subtitle.isEmpty()) { + holder.epgView.setText(subtitle); + holder.epgView.setVisibility(View.VISIBLE); + } else { + holder.epgView.setVisibility(View.GONE); + } + } + @Override public int getItemCount() { return channels.size(); @@ -152,6 +215,7 @@ static class VH extends RecyclerView.ViewHolder { TextView numberView; ImageView logoView; ImageView favoriteView; + ImageView playingView; VH(@NonNull View itemView) { super(itemView); @@ -160,6 +224,7 @@ static class VH extends RecyclerView.ViewHolder { numberView = itemView.findViewById(R.id.channel_number); logoView = itemView.findViewById(R.id.channel_logo); favoriteView = itemView.findViewById(R.id.channel_favorite); + playingView = itemView.findViewById(R.id.channel_playing); itemView.setFocusable(true); itemView.setFocusableInTouchMode(true); } 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 0f05e2d..f7d53ab 100644 --- a/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java +++ b/app/src/main/java/com/whyun/witv/ui/PlayerActivity.java @@ -60,10 +60,12 @@ import java.util.ArrayList; import java.text.SimpleDateFormat; import java.util.Date; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -100,6 +102,11 @@ public class PlayerActivity extends FragmentActivity implements PlayerManager.Ca private RecyclerView channelGroupListOverlay; private RecyclerView channelListOverlay; private ChannelGroupListAdapter channelGroupAdapter; + @Nullable + private ChannelListAdapter channelListAdapter; + /** 频道菜单各行副标题用的「正在播出」节目名,每次打开菜单清空;"" 表示没有节目信息。仅主线程访问 */ + private final Map channelListProgramCache = new HashMap<>(); + private final Set channelListProgramPending = new HashSet<>(); private TextView channelListEpgChannelName; private TextView channelListEpgContent; private int channelListEpgLoadSeq = 0; @@ -121,10 +128,22 @@ public class PlayerActivity extends FragmentActivity implements PlayerManager.Ca private final Handler handler = new Handler(Looper.getMainLooper()); private final ExecutorService executor = Executors.newSingleThreadExecutor(); + /** 频道菜单逐行查当前节目,单独一条线程,免得排在换台/右侧 EPG 预览前面 */ + private final ExecutorService channelListProgramExecutor = Executors.newSingleThreadExecutor(); + /** 启动时刷新 M3U 要走网络,可能卡很久,不能占着 {@link #executor} */ + private final ExecutorService startupRefreshExecutor = Executors.newSingleThreadExecutor(); + /** + * 正在播的是启动刷新前缓存里的播放源。开「启动时刷新」通常就是因为地址会变, + * 旧地址全部失败时要用刷新后的地址再播一次。以下三个仅主线程访问。 + */ + private boolean playingPreRefreshSources; + private boolean startupRefreshSucceeded; + private boolean allSourcesFailedShowing; private final SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm", Locale.getDefault()); private boolean overlayVisible = false; - private final Runnable hideOverlayRunnable = () -> hideOverlay(); + // 只收起底部信息条:换台/启动后 5 秒内用户可能已经打开了频道菜单,不能连菜单一起关掉 + private final Runnable hideOverlayRunnable = () -> hideEpgOverlay(); private AlertDialog exitDialog; private AlertDialog appSwitcherDialog; @@ -322,6 +341,11 @@ private void initViews() { channelListOverlay = findViewById(R.id.channel_list_overlay); channelListEpgChannelName = findViewById(R.id.channel_list_epg_channel_name); channelListEpgContent = findViewById(R.id.channel_list_epg_content); + // ScrollView 构造时会强制 setFocusable(true),XML 里关不掉;不关的话频道列表按右键 + // 焦点会落到这里,之后返回键直接关掉整个菜单,而不是先回到频道列表 + ScrollView channelListEpgScroll = findViewById(R.id.channel_list_epg_scroll); + channelListEpgScroll.setFocusable(false); + channelListEpgScroll.setFocusableInTouchMode(false); refreshWebHintButton = findViewById(R.id.btn_refresh_web_hint); channelGroupListOverlay.setLayoutManager(new LinearLayoutManager(this)); channelListOverlay.setLayoutManager(new LinearLayoutManager(this)); @@ -460,55 +484,133 @@ private void updateMediaInfoText() { mediaInfoAudioColumn.setText(cols.audioColumn); } + /** + * 先用本地缓存的频道列表起播,「启动时刷新 M3U」放到后台线程做。 + * + *

以前是刷新完才起播,而且刷新占着 {@link #executor}:源站慢或卡住时(实测超时要等近 50 秒), + * 这段时间画面不出、频道菜单也打不开。缓存为空(首次使用)时才等刷新结果再播。 + */ private void loadAndPlay() { + final boolean refresh = preferenceManager.isRefreshM3uOnStartupEnabled(); executor.execute(() -> { - AppDatabase db = AppDatabase.getInstance(this); - if (preferenceManager.isRefreshM3uOnStartupEnabled()) { - tryRefreshActiveM3uSource(db); + boolean played = loadCachedChannelsAndPlay(!refresh); + if (!refresh) { + return; } - currentChannel = resolveInitialChannel(db); - allChannels = sourceId > 0 ? db.channelDao().getBySource(sourceId) : new ArrayList<>(); - - if (currentChannel != null) { - for (int i = 0; i < allChannels.size(); i++) { - if (allChannels.get(i).id == currentChannelId) { - currentChannelIndex = i; - break; - } - } + if (played) { + runOnUiThread(() -> playingPreRefreshSources = true); } - - if (currentChannel != null) { - preferenceManager.saveLastChannel(currentChannelId, sourceId); - isFavorite = channelRepository.isFavorite(currentChannelId); - List sources = channelRepository.getChannelSources(currentChannelId); + if (!played) { runOnUiThread(() -> { - showEmptyState(false); - updateChannelInfo(); - dismissAllSourcesFailedToast(); - playerManager.playChannel(sources); - showOverlayTemporarily(); + switchingToast.setText(R.string.refreshing_channel_list); + switchingToast.setVisibility(View.VISIBLE); }); - } else { - runOnUiThread(() -> { - showEmptyState(true); - hideOverlay(); - switchingToast.setVisibility(View.GONE); + } + startupRefreshExecutor.execute(() -> { + boolean refreshed = tryRefreshActiveM3uSource(AppDatabase.getInstance(this)); + if (isFinishing() || isDestroyed() || executor.isShutdown()) { + return; + } + executor.execute(() -> { + if (!played) { + runOnUiThread(() -> switchingToast.setVisibility(View.GONE)); + loadCachedChannelsAndPlay(true); + } else if (refreshed) { + reloadChannelsAfterRefresh(); + } }); + }); + }); + } + + /** + * 从数据库取起播频道和频道列表并开始播放,须在 {@link #executor} 上调用。 + * + * @param showEmptyWhenMissing 没有可播频道时是否显示空状态页(后面还要等刷新结果时不显示) + * @return 是否已开始播放 + */ + private boolean loadCachedChannelsAndPlay(boolean showEmptyWhenMissing) { + AppDatabase db = AppDatabase.getInstance(this); + currentChannel = resolveInitialChannel(db); + allChannels = sourceId > 0 ? db.channelDao().getBySource(sourceId) : new ArrayList<>(); + + if (currentChannel != null) { + for (int i = 0; i < allChannels.size(); i++) { + if (allChannels.get(i).id == currentChannelId) { + currentChannelIndex = i; + break; + } } + } + + if (currentChannel != null) { + preferenceManager.saveLastChannel(currentChannelId, sourceId); + isFavorite = channelRepository.isFavorite(currentChannelId); + List sources = channelRepository.getChannelSources(currentChannelId); + runOnUiThread(() -> { + showEmptyState(false); + updateChannelInfo(); + dismissAllSourcesFailedToast(); + playerManager.playChannel(sources); + showOverlayTemporarily(); + }); + return true; + } + if (showEmptyWhenMissing) { + runOnUiThread(() -> { + showEmptyState(true); + hideOverlay(); + switchingToast.setVisibility(View.GONE); + }); + } + return false; + } + + /** + * 后台刷新 M3U 后换上新的频道列表,不打断正在播的节目。须在 {@link #executor} 上调用。 + * + *

刷新按 tvgId / 名称原地更新,频道 id 不变,所以只需按 id 找回当前频道的位置。 + * 频道菜单开着时不重建它,免得打断用户选台;下次打开菜单就是新列表。 + */ + private void reloadChannelsAfterRefresh() { + if (sourceId <= 0) { + return; + } + List refreshed = AppDatabase.getInstance(this).channelDao().getBySource(sourceId); + runOnUiThread(() -> { + if (isFinishing() || refreshed.isEmpty()) { + return; + } + allChannels = refreshed; + startupRefreshSucceeded = true; + for (int i = 0; i < refreshed.size(); i++) { + if (refreshed.get(i).id == currentChannelId) { + currentChannelIndex = i; + currentChannel = refreshed.get(i); + if (playingPreRefreshSources && allSourcesFailedShowing) { + playChannel(currentChannel); + } + return; + } + } + // 当前频道已从源里删掉:照常播完,上下键从列表开头继续 + currentChannelIndex = Math.min(currentChannelIndex, refreshed.size() - 1); }); } - private void tryRefreshActiveM3uSource(AppDatabase db) { + /** @return 是否刷新成功 */ + private boolean tryRefreshActiveM3uSource(AppDatabase db) { com.whyun.witv.data.db.entity.M3USource activeSource = db.m3uSourceDao().getActive(); if (activeSource == null || activeSource.url == null || activeSource.url.isEmpty()) { - return; + return false; } try { channelRepository.loadSource(activeSource); + return true; } catch (Exception e) { // Keep existing channel cache if startup refresh fails. android.util.Log.w("PlayerActivity", "Failed to refresh active M3U on startup", e); + return false; } } @@ -547,6 +649,7 @@ private void reloadForActiveSourceChange(long newSourceId) { showEmptyState(false); updateChannelInfo(); dismissAllSourcesFailedToast(); + playingPreRefreshSources = false; playerManager.playChannel(sources); showOverlayTemporarily(); }); @@ -690,6 +793,7 @@ private Channel findFirstChannelFromActiveSource(AppDatabase db) { } private void playChannel(Channel channel) { + playingPreRefreshSources = false; currentChannel = channel; currentChannelId = channel.id; preferenceManager.saveLastChannel(channel.id, sourceId); @@ -707,6 +811,7 @@ private void playChannel(Channel channel) { /** 用户切换频道或重新发起播放时关闭「所有源失败」等常驻提示;成功开播时仍由 onPlaybackStarted 处理。 */ private void dismissAllSourcesFailedToast() { + allSourcesFailedShowing = false; switchingToast.setVisibility(View.GONE); } @@ -847,6 +952,11 @@ private void showOverlayTemporarily() { handler.postDelayed(hideOverlayRunnable, 5000); } + private void hideEpgOverlay() { + epgOverlay.setVisibility(View.GONE); + overlayVisible = false; + } + private void hideOverlay() { cancelChannelListIdleHide(); epgOverlay.setVisibility(View.GONE); @@ -868,9 +978,7 @@ private void updateChannelListEpgPanel(Channel ch) { return; } channelListEpgChannelName.setText(ch.displayName); - boolean hasEpgKey = (ch.tvgId != null && !ch.tvgId.isEmpty()) - || (ch.tvgName != null && !ch.tvgName.isEmpty()); - if (!hasEpgKey) { + if (!hasEpgKey(ch)) { channelListEpgContent.setText(getString(R.string.no_epg)); return; } @@ -888,6 +996,64 @@ private void updateChannelListEpgPanel(Channel ch) { }); } + private static boolean hasEpgKey(Channel ch) { + return (ch.tvgId != null && !ch.tvgId.isEmpty()) + || (ch.tvgName != null && !ch.tvgName.isEmpty()); + } + + private final ChannelListAdapter.CurrentProgramSource channelListProgramSource = + new ChannelListAdapter.CurrentProgramSource() { + @Nullable + @Override + public String getCachedCurrentProgram(Channel channel) { + if (!hasEpgKey(channel)) { + return ""; + } + return channelListProgramCache.get(channel.id); + } + + @Override + public void requestCurrentProgram(Channel channel) { + requestChannelListProgram(channel); + } + }; + + private void requestChannelListProgram(Channel ch) { + if (!channelListProgramPending.add(ch.id)) { + return; + } + channelListProgramExecutor.execute(() -> { + String title = ""; + long now = System.currentTimeMillis(); + for (EpgProgram p : epgRepository.getCurrentAndNext(ch.tvgId, ch.tvgName)) { + if (now >= p.startTime && now < p.endTime && p.title != null) { + title = p.title; + break; + } + } + final String result = title; + runOnUiThread(() -> { + channelListProgramPending.remove(ch.id); + if (isFinishing()) { + return; + } + channelListProgramCache.put(ch.id, result); + notifyChannelListProgramLoaded(ch.id); + }); + }); + } + + private void notifyChannelListProgramLoaded(long channelId) { + if (channelListAdapter == null) { + return; + } + if (RecyclerViewUpdateGate.postponeIfBusy(channelListOverlay, + () -> notifyChannelListProgramLoaded(channelId), channelListOverlay)) { + return; + } + channelListAdapter.notifyCurrentProgramLoaded(channelId); + } + /** * 连续节目列表;正在播出的一条用主题色加粗,其余沿用 TextView 默认 secondary 色。 */ @@ -1151,6 +1317,8 @@ private void showChannelList() { Set favIds = new HashSet<>(channelRepository.getAllFavoriteChannelIds()); runOnUiThread(() -> { currentFavoriteIds = favIds; + // 节目随时间变化,每次打开菜单都重新查 + channelListProgramCache.clear(); visibleChannelGroups = buildVisibleChannelGroups(allChannels); selectedChannelGroupIndex = findCurrentChannelGroupIndex(); visibleChannels = getChannelsForGroup(visibleChannelGroups.get(selectedChannelGroupIndex)); @@ -1183,7 +1351,7 @@ private void bindChannelGroupList() { private void updateSelectedGroup(int position, boolean requestChannelFocus) { // 分组焦点可能是频道列表回收带焦点行、焦点逃到根节点重新分发时同步落过来的, - // 此时两个列表都可能正在布局中,setSelectedIndex 的 notifyDataSetChanged 与 + // 此时两个列表都可能正在布局中,setSelectedIndex 的 notifyItemChanged 与 // 下面的 setAdapter 都会抛 IllegalStateException。见 RecyclerViewUpdateGate。 if (RecyclerViewUpdateGate.postponeIfBusy(channelListOverlay, () -> updateSelectedGroup(position, requestChannelFocus), @@ -1214,8 +1382,10 @@ private void bindChannelListForSelectedGroup(boolean requestChannelFocus) { channelListOverlay, channelGroupListOverlay)) { return; } - int selectedIndexInGroup = findCurrentChannelIndexInVisibleChannels(); - ChannelListAdapter adapter = new ChannelListAdapter(visibleChannels, selectedIndexInGroup, currentFavoriteIds, channel -> { + int playingIndexInGroup = indexOfCurrentChannelInVisibleChannels(); + int selectedIndexInGroup = Math.max(0, playingIndexInGroup); + // 只给真正在播的那一行打「选中」标记;当前分组里没有在播频道时传 -1,别把第一行标成在播 + ChannelListAdapter adapter = new ChannelListAdapter(visibleChannels, playingIndexInGroup, currentFavoriteIds, channel -> { cancelChannelListIdleHide(); if (channelListPanel != null) { channelListPanel.setVisibility(View.GONE); @@ -1234,11 +1404,14 @@ private void bindChannelListForSelectedGroup(boolean requestChannelFocus) { toggleFavoriteForChannel(ch); return true; }); + adapter.setChannelNumbers(buildChannelNumbers()); + adapter.setCurrentProgramSource(channelListProgramSource); + channelListAdapter = adapter; channelListOverlay.setAdapter(adapter); if (!visibleChannels.isEmpty()) { - Channel target = visibleChannels.get(Math.max(0, selectedIndexInGroup)); + Channel target = visibleChannels.get(selectedIndexInGroup); updateChannelListEpgPanel(target); - channelListOverlay.scrollToPosition(Math.max(0, selectedIndexInGroup)); + channelListOverlay.scrollToPosition(selectedIndexInGroup); } else { channelListEpgChannelName.setText(""); channelListEpgContent.setText(getString(R.string.no_epg)); @@ -1315,12 +1488,28 @@ private List getChannelsForGroup(String groupName) { } private int findCurrentChannelIndexInVisibleChannels() { + return Math.max(0, indexOfCurrentChannelInVisibleChannels()); + } + + /** 当前在播频道在 visibleChannels 中的位置,不在当前分组时返回 -1 */ + private int indexOfCurrentChannelInVisibleChannels() { for (int i = 0; i < visibleChannels.size(); i++) { if (visibleChannels.get(i).id == currentChannelId) { return i; } } - return 0; + return -1; + } + + /** 频道 id → 全局频道号(从 1 开始),与数字键换台用的是同一套编号 */ + private Map buildChannelNumbers() { + Map numbers = new HashMap<>(); + if (allChannels != null) { + for (int i = 0; i < allChannels.size(); i++) { + numbers.put(allChannels.get(i).id, i + 1); + } + } + return numbers; } private String normalizeChannelGroup(Channel channel) { @@ -1602,12 +1791,19 @@ public void onPlayerRebuilt() { @Override public void onAllSourcesFailed() { + if (playingPreRefreshSources && startupRefreshSucceeded && currentChannel != null) { + // 缓存里的旧地址都失效了,而刷新已经拿到新地址:直接用新地址重播(只重试这一次) + playChannel(currentChannel); + return; + } + allSourcesFailedShowing = true; switchingToast.setText(getString(R.string.all_sources_failed)); switchingToast.setVisibility(View.VISIBLE); } @Override public void onPlaybackStarted(int sourceIndex, int total) { + allSourcesFailedShowing = false; switchingToast.setVisibility(View.GONE); sourceInfoView.setText(String.format(Locale.getDefault(), getString(R.string.source_count) + " (当前: %d)", total, sourceIndex + 1)); @@ -1671,6 +1867,8 @@ protected void onDestroy() { } playerManager.release(); executor.shutdown(); + channelListProgramExecutor.shutdown(); + startupRefreshExecutor.shutdown(); } @Override diff --git a/app/src/main/res/drawable/channel_menu_item_bg.xml b/app/src/main/res/drawable/channel_menu_item_bg.xml new file mode 100644 index 0000000..8296ee7 --- /dev/null +++ b/app/src/main/res/drawable/channel_menu_item_bg.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_now_playing.xml b/app/src/main/res/drawable/ic_now_playing.xml new file mode 100644 index 0000000..6da976c --- /dev/null +++ b/app/src/main/res/drawable/ic_now_playing.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 892cfed..e8ffeb9 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -298,40 +298,57 @@ android:clipToPadding="false" android:padding="8dp" /> - + android:orientation="vertical"> - + android:layout_height="0dp" + android:layout_weight="1" + android:fillViewport="true" + android:scrollbars="vertical"> - + android:orientation="vertical" + android:paddingHorizontal="14dp" + android:paddingTop="14dp" + android:paddingBottom="20dp"> - - - + + + + + + + + diff --git a/app/src/main/res/layout/item_channel_card.xml b/app/src/main/res/layout/item_channel_card.xml index 0830257..79d311b 100644 --- a/app/src/main/res/layout/item_channel_card.xml +++ b/app/src/main/res/layout/item_channel_card.xml @@ -2,7 +2,8 @@ + + #E6000000 #4CAF50 #F44336 + #1E88E5 + #4D42A5F5 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1f2a61..c53a288 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -58,6 +58,9 @@ 当前播出 即将播出 暂无节目信息 + 正在更新频道列表… + ←→ 切换分组/频道 确认:播放 长按:收藏 + 正在播放 播放源 %d 个 频道 %d 个 我的收藏 diff --git a/app/src/test/java/com/whyun/witv/data/repository/ChannelRepositoryTest.java b/app/src/test/java/com/whyun/witv/data/repository/ChannelRepositoryTest.java new file mode 100644 index 0000000..0b192e4 --- /dev/null +++ b/app/src/test/java/com/whyun/witv/data/repository/ChannelRepositoryTest.java @@ -0,0 +1,101 @@ +package com.whyun.witv.data.repository; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import android.content.Context; + +import androidx.room.Room; +import androidx.test.core.app.ApplicationProvider; + +import com.whyun.witv.data.db.AppDatabase; +import com.whyun.witv.data.db.entity.Channel; +import com.whyun.witv.data.db.entity.M3USource; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.IOException; +import java.util.List; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Response; +import okhttp3.ResponseBody; + +@RunWith(RobolectricTestRunner.class) +public class ChannelRepositoryTest { + + private static final String TWO_CHANNELS = "#EXTM3U\n" + + "#EXTINF:-1 tvg-id=\"cctv1\",CCTV-1\n" + + "http://example.com/cctv1\n" + + "#EXTINF:-1 tvg-id=\"cctv2\",CCTV-2\n" + + "http://example.com/cctv2\n"; + + private AppDatabase db; + private M3USource source; + /** 假源站下一次返回的内容 */ + private String playlist; + + @Before + public void setUp() { + Context context = ApplicationProvider.getApplicationContext(); + db = Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + source = new M3USource("test", "http://example.com/list.m3u", null, 0L, true); + source.id = db.m3uSourceDao().insert(source); + } + + @After + public void tearDown() { + db.close(); + } + + private ChannelRepository repository() { + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(chain -> new Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(ResponseBody.create(playlist, + MediaType.get("audio/x-mpegurl; charset=utf-8"))) + .build()) + .build(); + return new ChannelRepository(db, client); + } + + @Test + public void loadSourceSavesParsedChannels() throws IOException { + playlist = TWO_CHANNELS; + repository().loadSource(source); + + List channels = db.channelDao().getBySource(source.id); + assertEquals(2, channels.size()); + assertEquals(1, db.channelSourceDao().getByChannel(channels.get(0).id).size()); + } + + /** 源站临时返回空页面时不能把本地缓存的频道删光——播放页是先用缓存起播的。 */ + @Test + public void emptyPlaylistKeepsCachedChannels() throws IOException { + playlist = TWO_CHANNELS; + repository().loadSource(source); + + playlist = "#EXTM3U\n"; + try { + repository().loadSource(source); + fail("Empty playlist should be reported as a failed refresh"); + } catch (IOException expected) { + // 按刷新失败处理 + } + + List channels = db.channelDao().getBySource(source.id); + assertEquals(2, channels.size()); + assertEquals(1, db.channelSourceDao().getByChannel(channels.get(0).id).size()); + } +} diff --git a/app/src/test/java/com/whyun/witv/player/LevelTolerantVideoRendererTest.java b/app/src/test/java/com/whyun/witv/player/LevelTolerantVideoRendererTest.java new file mode 100644 index 0000000..d4c6cda --- /dev/null +++ b/app/src/test/java/com/whyun/witv/player/LevelTolerantVideoRendererTest.java @@ -0,0 +1,91 @@ +package com.whyun.witv.player; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCodecInfo.CodecProfileLevel; +import android.media.MediaFormat; + +import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; +import androidx.media3.exoplayer.mediacodec.MediaCodecInfo; +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.MediaCodecInfoBuilder; + +import java.util.Collections; + +/** + * 用实测的 CCTV-4K 码流参数({@code hvc1.2.4.L180.90},Main10 Level 6.0)对 S905X 那种 + * 「Main10 只声明到 Level 5.1」的硬解做判断。 + */ +@RunWith(RobolectricTestRunner.class) +public class LevelTolerantVideoRendererTest { + + private static final String CCTV_4K_CODECS = "hvc1.2.4.L180.90"; + + private static Format hevc4k(String codecs) { + return new Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .setCodecs(codecs) + .setWidth(3840) + .setHeight(2160) + .build(); + } + + private static MediaCodecSelector decoder(boolean hardware, int... profiles) { + CodecProfileLevel[] profileLevels = new CodecProfileLevel[profiles.length]; + for (int i = 0; i < profiles.length; i++) { + CodecProfileLevel profileLevel = new CodecProfileLevel(); + profileLevel.profile = profiles[i]; + profileLevel.level = CodecProfileLevel.HEVCMainTierLevel51; + profileLevels[i] = profileLevel; + } + CodecCapabilities capabilities = MediaCodecInfoBuilder.CodecCapabilitiesBuilder.newBuilder() + .setMediaFormat(MediaFormat.createVideoFormat(MimeTypes.VIDEO_H265, 3840, 2160)) + .setProfileLevels(profileLevels) + .setColorFormats(new int[] {CodecCapabilities.COLOR_FormatYUV420Flexible}) + .build(); + String name = hardware ? "OMX.amlogic.hevc.decoder.awesome" : "OMX.google.hevc.decoder"; + MediaCodecInfo info = MediaCodecInfo.newInstance(name, MimeTypes.VIDEO_H265, + MimeTypes.VIDEO_H265, capabilities, hardware, !hardware, hardware, false, false); + return (mimeType, secure, tunneling) -> Collections.singletonList(info); + } + + private static boolean exceedsOnlyByLevel(MediaCodecSelector selector, Format format) + throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + return LevelTolerantVideoRenderer.exceedsOnlyByLevel(context, selector, format); + } + + @Test + public void hardwareMain10DecoderAcceptsLevelSixStream() throws Exception { + MediaCodecSelector selector = decoder(true, CodecProfileLevel.HEVCProfileMain10); + assertTrue(exceedsOnlyByLevel(selector, hevc4k(CCTV_4K_CODECS))); + } + + /** 解码器不支持 10bit 是真解不了,必须照旧交给 FFmpeg。 */ + @Test + public void unsupportedProfileIsNotRelaxed() throws Exception { + MediaCodecSelector selector = decoder(true, CodecProfileLevel.HEVCProfileMain); + assertFalse(exceedsOnlyByLevel(selector, hevc4k(CCTV_4K_CODECS))); + } + + @Test + public void softwareDecoderIsNotRelaxed() throws Exception { + MediaCodecSelector selector = decoder(false, CodecProfileLevel.HEVCProfileMain10); + assertFalse(exceedsOnlyByLevel(selector, hevc4k(CCTV_4K_CODECS))); + } + + @Test + public void formatWithoutCodecsStringIsNotRelaxed() throws Exception { + MediaCodecSelector selector = decoder(true, CodecProfileLevel.HEVCProfileMain10); + assertFalse(exceedsOnlyByLevel(selector, hevc4k(null))); + } +} diff --git a/app/src/test/java/com/whyun/witv/player/WiTVRenderersFactoryTest.java b/app/src/test/java/com/whyun/witv/player/WiTVRenderersFactoryTest.java index f59255e..07d2634 100644 --- a/app/src/test/java/com/whyun/witv/player/WiTVRenderersFactoryTest.java +++ b/app/src/test/java/com/whyun/witv/player/WiTVRenderersFactoryTest.java @@ -1,6 +1,7 @@ package com.whyun.witv.player; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import android.content.Context; @@ -29,9 +30,9 @@ @RunWith(RobolectricTestRunner.class) public class WiTVRenderersFactoryTest { - private static List rendererNames(PlaybackDecoderMode mode, int trackType) { + private static Renderer[] createRenderers(PlaybackDecoderMode mode) { Context context = ApplicationProvider.getApplicationContext(); - Renderer[] renderers = new WiTVRenderersFactory(context, mode).createRenderers( + return new WiTVRenderersFactory(context, mode).createRenderers( new Handler(Looper.getMainLooper()), new VideoRendererEventListener() { }, @@ -41,15 +42,28 @@ private static List rendererNames(PlaybackDecoderMode mode, int trackTyp }, metadata -> { }); + } + + /** 用 {@code getName()} 而不是类名:硬解视频渲染器可能被 {@link LevelTolerantVideoRenderer} 包住。 */ + private static List rendererNames(PlaybackDecoderMode mode, int trackType) { List names = new ArrayList<>(); - for (Renderer renderer : renderers) { + for (Renderer renderer : createRenderers(mode)) { if (renderer.getTrackType() == trackType) { - names.add(renderer.getClass().getSimpleName()); + names.add(renderer.getName()); } } return names; } + private static boolean wrapsHardwareVideo(PlaybackDecoderMode mode) { + for (Renderer renderer : createRenderers(mode)) { + if (renderer instanceof LevelTolerantVideoRenderer) { + return true; + } + } + return false; + } + private static int indexOf(List names, String simpleName) { return names.indexOf(simpleName); } @@ -106,4 +120,13 @@ public void hardwareOnlyLoadsNoFfmpegRenderers() { assertTrue(indexOf(audio, "MediaCodecAudioRenderer") >= 0); assertTrue(indexOf(video, "MediaCodecVideoRenderer") >= 0); } + + /** 只有视频「硬解优先、软解补位」时才需要放宽 Level,其余模式保持原样。 */ + @Test + public void onlyHardwareFirstVideoModesWrapMediaCodecVideoRenderer() { + assertTrue(wrapsHardwareVideo(PlaybackDecoderMode.AUTO)); + assertTrue(wrapsHardwareVideo(PlaybackDecoderMode.SOFTWARE_AUDIO)); + assertFalse(wrapsHardwareVideo(PlaybackDecoderMode.PREFER_SOFTWARE)); + assertFalse(wrapsHardwareVideo(PlaybackDecoderMode.HARDWARE_ONLY)); + } }