Skip to content

Commit f5b785f

Browse files
committed
feat: 播放器与设置优化、Web/EPG、资源与日志
- PlayerManager: 换源前记录原因;多源失败与超时处理 - 设置: 播放页视频加载速度开关,右上角实时带宽估算;移除倍速与设置页码率卡片 - 播放页: 信号信息双栏、媒体元数据展示;加载速度浮层 - PreferenceManager: 上次播放线路索引;加载速度开关 - MainActivity: 启动记录上次频道与线路 - MediaInfoFormatter: 码率/帧率未提供文案 - WebServer/Web 管理页、EPG、图标与 mipmap 等资源 - CHANGELOG、测试与构建配置更新 Made-with: Cursor
1 parent db608c5 commit f5b785f

36 files changed

Lines changed: 1414 additions & 71 deletions

CHANGELOG.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Changelog
2+
3+
本项目所有重要变更均记录在此文件中。
4+
5+
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
6+
7+
## [Unreleased]
8+
9+
### Added
10+
11+
- 应用 Logo:添加 witv 水墨风格图标(mipmap 多密度)及 Android TV banner
12+
13+
### Fixed
14+
15+
- 播放器切换频道后陈旧回调干扰新频道播放的问题
16+
- 所有播放源失败后未停止播放器,残留错误状态影响后续频道切换
17+
- 切换频道时旧超时计时器未取消,可能误触发源切换
18+
- 空源列表未清理旧播放状态
19+
20+
### Tests
21+
22+
- 新增 7 个 PlayerManager 单元测试,覆盖频道切换状态隔离、陈旧回调防护、全部失败后恢复等场景
23+
24+
## [1.0.0] - 2026-03-20
25+
26+
### Added
27+
28+
- 项目初始化:M3U 播放源解析与频道列表展示
29+
- ExoPlayer 视频播放,支持 HLS / DASH / TS 流媒体
30+
- 多播放源自动切换(超时 15 秒自动尝试下一个源)
31+
- 手动切换播放源
32+
- 频道上下键切换、数字键直接跳转
33+
- 频道收藏功能(添加 / 取消收藏)
34+
- 启动时自动播放上次观看的频道
35+
- 首次进入频道列表自动播放第一个频道
36+
- EPG 节目信息展示(当前播出 / 即将播出)
37+
- 内置 Web 服务器,支持浏览器管理 M3U 播放源
38+
- Android TV Leanback 支持
39+
- GitHub Actions CI/CD 自动构建与签名发布
40+
41+
### Fixed
42+
43+
- 收藏状态切换后未读取数据库实际状态,导致显示不一致
44+
- 自动播放在 Activity 重建时重复触发
45+
- 刷新播放源时频道 ID 重建导致收藏记录级联删除
46+
47+
[Unreleased]: https://github.com/whyun-android/witv/compare/v1.0.0...HEAD
48+
[1.0.0]: https://github.com/whyun-android/witv/releases/tag/v1.0.0

app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ android {
1010
applicationId "com.whyun.witv"
1111
minSdk 21
1212
targetSdk 34
13-
versionCode 1
14-
versionName "1.0"
13+
versionCode 2
14+
versionName "1.0.1"
1515
}
1616

1717
buildTypes {

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
android:name=".WiTVApp"
1717
android:allowBackup="true"
1818
android:banner="@drawable/app_banner"
19-
android:icon="@drawable/app_banner"
19+
android:icon="@mipmap/ic_launcher"
20+
android:roundIcon="@mipmap/ic_launcher_round"
2021
android:label="@string/app_name"
2122
android:networkSecurityConfig="@xml/network_security_config"
2223
android:supportsRtl="true"

app/src/main/assets/web/app.js

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,18 @@ async function reloadSource(id) {
9595
}
9696

9797
async function viewChannels(sourceId) {
98+
const section = document.getElementById('channelSection');
99+
const container = document.getElementById('channelGroups');
100+
const countBadge = document.getElementById('channelCount');
101+
section.style.display = 'block';
102+
countBadge.textContent = '';
103+
container.innerHTML = '<div class="loading-state"><span class="spinner"></span>正在加载频道列表…</div>';
98104
try {
99105
const res = await fetch(`${API}/api/sources/${sourceId}/channels`);
100106
const data = await res.json();
101107
renderChannels(data);
102108
} catch (err) {
109+
container.innerHTML = '<p class="empty-hint">频道加载失败</p>';
103110
console.error('Failed to load channels:', err);
104111
}
105112
}
@@ -129,11 +136,11 @@ function renderChannels(data) {
129136
</div>
130137
<div class="group-channels">
131138
${channels.map(ch => `
132-
<div class="channel-item">
139+
<div class="channel-item" onclick="showEpg(${ch.id}, '${escapeHtml(ch.displayName).replace(/'/g, "\\'")}')">
133140
${ch.logoUrl ? `<img src="${escapeHtml(ch.logoUrl)}" alt="" onerror="this.style.display='none'">` : ''}
134141
<span>${escapeHtml(ch.displayName)}</span>
135142
<button class="btn-fav ${favoriteIds.has(ch.id) ? 'is-fav' : ''}"
136-
onclick="toggleFavorite(${ch.id}, this)" title="${favoriteIds.has(ch.id) ? '取消收藏' : '收藏'}">
143+
onclick="event.stopPropagation();toggleFavorite(${ch.id}, this)" title="${favoriteIds.has(ch.id) ? '取消收藏' : '收藏'}">
137144
${favoriteIds.has(ch.id) ? '★' : '☆'}
138145
</button>
139146
</div>
@@ -254,6 +261,82 @@ function showToast(msg, type) {
254261
setTimeout(() => { toast.className = 'toast'; }, 3000);
255262
}
256263

264+
async function loadVersion() {
265+
try {
266+
const res = await fetch(`${API}/api/version`);
267+
const data = await res.json();
268+
document.getElementById('appVersion').textContent = 'v' + (data.version || '');
269+
} catch (err) {
270+
console.error('Failed to load version:', err);
271+
}
272+
}
273+
274+
const EPG_LIST_LIMIT = 48;
275+
276+
async function showEpg(channelId, channelName) {
277+
const modal = document.getElementById('epgModal');
278+
const title = document.getElementById('epgModalTitle');
279+
const body = document.getElementById('epgModalBody');
280+
281+
title.textContent = channelName;
282+
body.innerHTML = '<div class="loading-state"><span class="spinner"></span>正在加载节目信息…</div>';
283+
modal.classList.add('show');
284+
285+
try {
286+
const res = await fetch(`${API}/api/epg/channel/${channelId}?limit=${EPG_LIST_LIMIT}`);
287+
const data = await res.json();
288+
const programs = data.programs || [];
289+
if (programs.length === 0) {
290+
body.innerHTML = '<p class="epg-empty">暂无节目信息</p>';
291+
return;
292+
}
293+
const fmt = ts => {
294+
const d = new Date(ts);
295+
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
296+
};
297+
const now = Date.now();
298+
const curIdx = programs.findIndex(p => p.startTime <= now && p.endTime > now);
299+
const nextIdx = curIdx === -1 ? 0 : curIdx + 1;
300+
301+
function epgLabel(i) {
302+
if (i === curIdx) return '当前播出';
303+
if (i === nextIdx) return '即将播出';
304+
return '后续';
305+
}
306+
307+
const rows = programs.map((p, i) => {
308+
const isCurrent = i === curIdx;
309+
const desc = p.description && String(p.description).trim();
310+
return `
311+
<div class="epg-item ${isCurrent ? 'epg-current' : ''}">
312+
<span class="epg-label">${epgLabel(i)}</span>
313+
<span class="epg-title">${escapeHtml(p.title)}</span>
314+
<span class="epg-time">${fmt(p.startTime)} - ${fmt(p.endTime)}</span>
315+
${desc ? `<p class="epg-desc">${escapeHtml(desc)}</p>` : ''}
316+
</div>`;
317+
}).join('');
318+
319+
const cap = data.limit != null ? data.limit : EPG_LIST_LIMIT;
320+
let footerText;
321+
if (programs.length >= cap) {
322+
footerText = `已显示 ${cap} 条未结束节目(已达本页上限,数据库中可能还有更多)`;
323+
} else {
324+
footerText = `共 ${programs.length} 条未结束节目`;
325+
}
326+
const footer = `<p class="epg-footer">${footerText}</p>`;
327+
328+
body.innerHTML = rows + footer;
329+
} catch (err) {
330+
body.innerHTML = '<p class="epg-empty">节目信息加载失败</p>';
331+
}
332+
}
333+
334+
function closeEpgModal(event) {
335+
if (event && event.target !== event.currentTarget) return;
336+
document.getElementById('epgModal').classList.remove('show');
337+
}
338+
257339
// Initialize
340+
loadVersion();
258341
loadFavorites().then(() => loadSources());
259342
loadSettings();
766 Bytes
Binary file not shown.

app/src/main/assets/web/index.html

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<title>WiTV 管理</title>
7+
<link rel="icon" href="/favicon.ico" type="image/x-icon">
78
<link rel="stylesheet" href="style.css">
89
</head>
910
<body>
1011
<div class="container">
1112
<header>
1213
<h1>📺 WiTV 管理</h1>
13-
<p class="subtitle">Android TV M3U 播放器</p>
14+
<p class="subtitle">Android TV M3U 播放器 <span id="appVersion"></span></p>
1415
</header>
1516

1617
<!-- Add Source -->
@@ -65,6 +66,16 @@ <h2>设置</h2>
6566

6667
<div id="toast" class="toast"></div>
6768

69+
<div id="epgModal" class="modal-overlay" onclick="closeEpgModal(event)">
70+
<div class="modal-content">
71+
<div class="modal-header">
72+
<h3 id="epgModalTitle"></h3>
73+
<button class="modal-close" onclick="closeEpgModal()">&times;</button>
74+
</div>
75+
<div id="epgModalBody" class="modal-body"></div>
76+
</div>
77+
</div>
78+
6879
<script src="app.js"></script>
6980
</body>
7081
</html>

app/src/main/assets/web/style.css

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,12 @@ input:focus {
241241
display: flex;
242242
align-items: center;
243243
gap: 8px;
244+
cursor: pointer;
245+
transition: background 0.2s;
246+
}
247+
248+
.channel-item:hover {
249+
background: #162a3a;
244250
}
245251

246252
.channel-item img {
@@ -313,6 +319,141 @@ input:focus {
313319
to { transform: rotate(360deg); }
314320
}
315321

322+
.loading-state {
323+
text-align: center;
324+
padding: 32px;
325+
color: #78909c;
326+
font-size: 14px;
327+
}
328+
329+
/* EPG Modal */
330+
.modal-overlay {
331+
display: none;
332+
position: fixed;
333+
inset: 0;
334+
background: rgba(0, 0, 0, 0.6);
335+
z-index: 200;
336+
align-items: center;
337+
justify-content: center;
338+
}
339+
340+
.modal-overlay.show {
341+
display: flex;
342+
}
343+
344+
.modal-content {
345+
background: #1a2733;
346+
border: 1px solid #37474f;
347+
border-radius: 12px;
348+
width: 90%;
349+
max-width: 420px;
350+
overflow: hidden;
351+
animation: modalIn 0.2s ease-out;
352+
}
353+
354+
@keyframes modalIn {
355+
from { opacity: 0; transform: scale(0.95); }
356+
to { opacity: 1; transform: scale(1); }
357+
}
358+
359+
.modal-header {
360+
display: flex;
361+
align-items: center;
362+
justify-content: space-between;
363+
padding: 16px 20px;
364+
border-bottom: 1px solid #263238;
365+
}
366+
367+
.modal-header h3 {
368+
font-size: 16px;
369+
color: #eceff1;
370+
margin: 0;
371+
}
372+
373+
.modal-close {
374+
background: none;
375+
border: none;
376+
color: #78909c;
377+
font-size: 22px;
378+
cursor: pointer;
379+
padding: 0 4px;
380+
line-height: 1;
381+
}
382+
383+
.modal-close:hover {
384+
color: #eceff1;
385+
}
386+
387+
.modal-body {
388+
padding: 16px 20px;
389+
max-height: min(70vh, 520px);
390+
overflow-y: auto;
391+
}
392+
393+
.epg-item {
394+
padding: 12px;
395+
border-radius: 8px;
396+
margin-bottom: 8px;
397+
background: #0d1b2a;
398+
display: flex;
399+
flex-direction: column;
400+
gap: 4px;
401+
}
402+
403+
.epg-item:last-child {
404+
margin-bottom: 0;
405+
}
406+
407+
.epg-current {
408+
border-left: 3px solid #4caf50;
409+
}
410+
411+
.epg-label {
412+
font-size: 11px;
413+
font-weight: 600;
414+
color: #78909c;
415+
text-transform: uppercase;
416+
letter-spacing: 0.5px;
417+
}
418+
419+
.epg-current .epg-label {
420+
color: #4caf50;
421+
}
422+
423+
.epg-title {
424+
font-size: 15px;
425+
color: #eceff1;
426+
}
427+
428+
.epg-time {
429+
font-size: 12px;
430+
color: #546e7a;
431+
}
432+
433+
.epg-desc {
434+
font-size: 12px;
435+
color: #78909c;
436+
margin-top: 6px;
437+
line-height: 1.45;
438+
max-height: 4.5em;
439+
overflow: hidden;
440+
}
441+
442+
.epg-footer {
443+
font-size: 12px;
444+
color: #546e7a;
445+
text-align: center;
446+
margin-top: 12px;
447+
padding-top: 8px;
448+
border-top: 1px solid #263238;
449+
}
450+
451+
.epg-empty {
452+
text-align: center;
453+
color: #546e7a;
454+
padding: 16px;
455+
}
456+
316457
@media (max-width: 600px) {
317458
.container {
318459
padding: 16px 12px;

0 commit comments

Comments
 (0)