Skip to content

Commit 3c2a514

Browse files
committed
chore(release): v1.1.0
See CHANGELOG.md for details. Made-with: Cursor
1 parent 162f623 commit 3c2a514

26 files changed

Lines changed: 1404 additions & 147 deletions

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@
44

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

7+
## [1.1.0] - 2026-03-22
8+
9+
### Added
10+
11+
- HLS 播放列表客户端修正:识别源站将 `#EXT-X-MEDIA-SEQUENCE` 误写为「最后一片序号」时,改写为第一片序号;修正 `##EXT-X-VERSION` 双井号(`HlsMediaSequenceFixUtil` + `M3u8RewritingDataSource` 注入 `DefaultDataSource`
12+
- 对应单元测试 `HlsMediaSequenceFixUtilTest`
13+
- 首页「设置」分类:可点击的快捷行(`SettingsShortcutEntry` / `SettingsShortcutPresenter`),仅确认后打开设置,避免焦点路过即弹出
14+
- 设置帮助拆分为子项:媒体信息、帮助说明、关于应用;关于中展示构建时间(`BuildConfig.BUILD_TIME_MILLIS`
15+
- 频道列表长按切换该行收藏(`ChannelListAdapter`
16+
- 超时换源偏好可在非播放页设置(`MainActivity` / `SettingsActivity` 等展示该分组)
17+
18+
### Changed
19+
20+
- Media3 升级至 **1.10.0-rc01**`minSdk` 提升至 **23**(与 Media3 1.9+ / AndroidX 对齐)
21+
- 首页设置浮层改为锚定窗口**左下角**`MainActivity` `onPause` 时收起设置层,避免后台 Activity 仍挂起可见浮层
22+
- 无 M3U 源时空状态:隐藏 Browse 根视图并为「刷新」请求焦点,避免 Leanback 抢走焦点
23+
- 设置抽屉内从**左侧子菜单****右键** 显式回到**右侧主菜单**当前分类,修复切换线路后 `notifyDataSetChanged` 导致焦点链断裂、无法返回父菜单的问题
24+
25+
### Fixed
26+
27+
- `SettingsCollapsibleFragment`:切换播放线路后子菜单刷新,方向键右键无法回到主菜单
28+
29+
[1.1.0]: https://github.com/whyun-android/witv/compare/v1.0.2...v1.1.0
30+
731
## [1.0.2] - 2026-03-21
832

933
### Added

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,18 @@ Android TV M3U 直播播放器,使用原生 ExoPlayer 播放,支持多源自
1414

1515
| 按键 | 功能 |
1616
|------|------|
17-
| 确认键 / OK | 显示频道列表和 EPG 信息 |
18-
| 上 / 下键 | 切换上一个 / 下一个频道 |
19-
| 左键 | 显示频道列表 |
17+
| 确认键 / OK | 显示频道列表(不自动展开 EPG 信息栏);列表在焦点停留无操作约 10 秒后自动关闭 |
18+
| 上 / 下键 | 切换上一个 / 下一个频道(可在设置中反转方向) |
19+
| 信息键 / 空格 | 显示 / 隐藏 EPG 与信号信息面板 |
2020
| 右键 / 返回键 | 关闭频道列表和信息面板 |
2121
| 数字键 0-9 | 输入频道号直接跳转 |
22-
| 菜单键 | 打开设置页面 |
22+
| 菜单键 | 打开设置面板;首页左侧选中「设置」分类即打开设置层(右侧不再显示设置按钮) |
2323

2424
## 技术栈
2525

2626
| 组件 | 技术 |
2727
|------|------|
28-
| 播放器 | Media3 ExoPlayer 1.5.1 |
28+
| 播放器 | Media3 ExoPlayer 1.10.0-rc01 |
2929
| TV 界面 | Leanback 1.2.0 |
3030
| 数据库 | Room 2.6.1 |
3131
| HTTP 服务器 | NanoHTTPD 2.3.1 |

app/build.gradle

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@ android {
66
namespace 'com.whyun.witv'
77
compileSdk 36
88

9+
buildFeatures {
10+
buildConfig true
11+
}
12+
913
defaultConfig {
1014
applicationId "com.whyun.witv"
11-
minSdk 21
15+
// Media3 1.9+ 与 AndroidX 对齐,要求 minSdk 23
16+
minSdk 23
1217
targetSdk 34
13-
versionCode 3
14-
versionName "1.0.2"
18+
versionCode 4
19+
versionName "1.1.0"
20+
buildConfigField "long", "BUILD_TIME_MILLIS", "${System.currentTimeMillis()}L"
1521
}
1622

1723
buildTypes {
@@ -28,17 +34,19 @@ android {
2834
}
2935

3036
dependencies {
37+
def media3_version = "1.10.0-rc01"
38+
3139
testImplementation "junit:junit:4.13.2"
3240
testImplementation "org.robolectric:robolectric:4.14.1"
3341
testImplementation "androidx.test:core:1.6.1"
3442

35-
// Media3 ExoPlayer
36-
implementation "androidx.media3:media3-exoplayer:1.5.1"
37-
implementation "androidx.media3:media3-exoplayer-hls:1.5.1"
38-
implementation "androidx.media3:media3-exoplayer-dash:1.5.1"
39-
implementation "androidx.media3:media3-ui:1.5.1"
40-
implementation "androidx.media3:media3-session:1.5.1"
41-
implementation "androidx.media3:media3-common:1.5.1"
43+
// Media3 ExoPlayer(RC,升级后请在目标盒子上做播放回归)
44+
implementation "androidx.media3:media3-exoplayer:$media3_version"
45+
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
46+
implementation "androidx.media3:media3-exoplayer-dash:$media3_version"
47+
implementation "androidx.media3:media3-ui:$media3_version"
48+
implementation "androidx.media3:media3-session:$media3_version"
49+
implementation "androidx.media3:media3-common:$media3_version"
4250

4351
// Leanback TV
4452
implementation "androidx.leanback:leanback:1.2.0-alpha04"

app/src/main/java/com/whyun/witv/data/PreferenceManager.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ public class PreferenceManager {
1212
private static final String KEY_LAST_PLAY_STREAM_INDEX = "last_play_stream_index";
1313
private static final String KEY_AUTO_PLAY_LAST = "auto_play_last";
1414
private static final String KEY_SHOW_LOAD_SPEED_OVERLAY = "show_load_speed_overlay";
15+
private static final String KEY_REVERSE_CHANNEL_KEYS = "reverse_channel_keys";
16+
private static final String KEY_SOURCE_SWITCH_TIMEOUT_MS = "source_switch_timeout_ms";
17+
18+
/** 单线路超时未起播则换源;可选值见 {@link #normalizeSourceSwitchTimeoutMs(long)} */
19+
public static final long DEFAULT_SOURCE_SWITCH_TIMEOUT_MS = 15_000L;
20+
private static final long[] ALLOWED_SOURCE_TIMEOUTS_MS = {
21+
5_000L, 10_000L, 15_000L, 20_000L, 25_000L, 30_000L
22+
};
1523
private static final String KEY_LAST_EPG_AUTO_REFRESH_AT = "last_epg_auto_refresh_at";
1624
private static final String KEY_LAST_EPG_AUTO_REFRESH_URL = "last_epg_auto_refresh_url";
1725

@@ -75,6 +83,37 @@ public boolean isShowLoadSpeedOverlay() {
7583
return prefs.getBoolean(KEY_SHOW_LOAD_SPEED_OVERLAY, false);
7684
}
7785

86+
/** 上/下键换台方向与默认相反 */
87+
public void setReverseChannelKeys(boolean enabled) {
88+
prefs.edit().putBoolean(KEY_REVERSE_CHANNEL_KEYS, enabled).apply();
89+
}
90+
91+
public boolean isReverseChannelKeysEnabled() {
92+
return prefs.getBoolean(KEY_REVERSE_CHANNEL_KEYS, false);
93+
}
94+
95+
public long getSourceSwitchTimeoutMs() {
96+
long v = prefs.getLong(KEY_SOURCE_SWITCH_TIMEOUT_MS, DEFAULT_SOURCE_SWITCH_TIMEOUT_MS);
97+
return normalizeSourceSwitchTimeoutMs(v);
98+
}
99+
100+
public void setSourceSwitchTimeoutMs(long ms) {
101+
prefs.edit().putLong(KEY_SOURCE_SWITCH_TIMEOUT_MS, normalizeSourceSwitchTimeoutMs(ms)).apply();
102+
}
103+
104+
public static long normalizeSourceSwitchTimeoutMs(long ms) {
105+
for (long a : ALLOWED_SOURCE_TIMEOUTS_MS) {
106+
if (a == ms) {
107+
return ms;
108+
}
109+
}
110+
return DEFAULT_SOURCE_SWITCH_TIMEOUT_MS;
111+
}
112+
113+
public static int[] getAllowedSourceTimeoutSeconds() {
114+
return new int[]{5, 10, 15, 20, 25, 30};
115+
}
116+
78117
public void clearLastChannel() {
79118
prefs.edit()
80119
.remove(KEY_LAST_CHANNEL_ID)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.whyun.witv.player;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import java.util.regex.Matcher;
6+
import java.util.regex.Pattern;
7+
8+
/**
9+
* 修正部分 IPTV 源错误的 media playlist:将误设为「最后一片序号」的
10+
* {@code #EXT-X-MEDIA-SEQUENCE} 改回「第一片序号」。仅在检测到典型错误模式时改写。
11+
*/
12+
public final class HlsMediaSequenceFixUtil {
13+
14+
private static final Pattern MEDIA_SEQUENCE_LINE = Pattern.compile(
15+
"^#EXT-X-MEDIA-SEQUENCE:(\\d+)\\s*$");
16+
private static final Pattern KEY2_PARAM = Pattern.compile("[?&]key2=(\\d+)");
17+
private static final Pattern TS_SUFFIX = Pattern.compile("(\\d+)\\.ts$");
18+
19+
private HlsMediaSequenceFixUtil() {
20+
}
21+
22+
/**
23+
* 修正 playlist 文本(UTF-8 语义);无需改写时返回原字符串引用。
24+
*/
25+
public static String fixPlaylistIfNeeded(String playlist) {
26+
if (playlist == null || playlist.isEmpty() || !playlist.contains("#EXTM3U")) {
27+
return playlist;
28+
}
29+
String withVersionFix = playlist.replace("##EXT-X-VERSION:", "#EXT-X-VERSION:");
30+
if (!withVersionFix.contains("#EXT-X-MEDIA-SEQUENCE")) {
31+
return withVersionFix;
32+
}
33+
34+
String[] lines = withVersionFix.split("\\r\\n|\\n|\\r", -1);
35+
int mediaSeqLineIndex = -1;
36+
long declaredM = -1L;
37+
for (int i = 0; i < lines.length; i++) {
38+
String trimmed = lines[i].trim();
39+
Matcher m = MEDIA_SEQUENCE_LINE.matcher(trimmed);
40+
if (m.matches()) {
41+
mediaSeqLineIndex = i;
42+
declaredM = Long.parseLong(m.group(1));
43+
break;
44+
}
45+
}
46+
if (mediaSeqLineIndex < 0) {
47+
return withVersionFix;
48+
}
49+
50+
List<Long> segmentIds = new ArrayList<>();
51+
for (int i = 0; i < lines.length - 1; i++) {
52+
if (lines[i].trim().startsWith("#EXTINF")) {
53+
String next = lines[i + 1].trim();
54+
if (!next.isEmpty() && !next.startsWith("#")) {
55+
Long id = extractSegmentId(next);
56+
if (id != null) {
57+
segmentIds.add(id);
58+
}
59+
}
60+
}
61+
}
62+
63+
if (segmentIds.size() < 2) {
64+
return withVersionFix;
65+
}
66+
67+
long s0 = segmentIds.get(0);
68+
long sLast = segmentIds.get(segmentIds.size() - 1);
69+
for (int j = 0; j < segmentIds.size(); j++) {
70+
if (segmentIds.get(j) != s0 + j) {
71+
return withVersionFix;
72+
}
73+
}
74+
if (declaredM != sLast || declaredM == s0) {
75+
return withVersionFix;
76+
}
77+
78+
lines[mediaSeqLineIndex] = "#EXT-X-MEDIA-SEQUENCE:" + s0;
79+
return joinLines(lines);
80+
}
81+
82+
private static String joinLines(String[] lines) {
83+
StringBuilder sb = new StringBuilder();
84+
for (int i = 0; i < lines.length; i++) {
85+
if (i > 0) {
86+
sb.append('\n');
87+
}
88+
sb.append(lines[i]);
89+
}
90+
return sb.toString();
91+
}
92+
93+
private static Long extractSegmentId(String uriLine) {
94+
Matcher key2 = KEY2_PARAM.matcher(uriLine);
95+
if (key2.find()) {
96+
return Long.parseLong(key2.group(1));
97+
}
98+
int q = uriLine.indexOf('?');
99+
String pathPart = q >= 0 ? uriLine.substring(0, q) : uriLine;
100+
Matcher ts = TS_SUFFIX.matcher(pathPart);
101+
if (ts.find()) {
102+
return Long.parseLong(ts.group(1));
103+
}
104+
return null;
105+
}
106+
}

0 commit comments

Comments
 (0)