视频:修 media.php 的 304/Range 冲突,HEVC 解不了时给明确提示 (#52)

304 判定没有排除 Range 请求:视频第二次打开时浏览器同时发
Range + If-None-Match,命中 304 拿到空响应 ⇒ 黑屏。
改成 $hasRange 为真时跳过 304。顺手补 Last-Modified(部分浏览器需要它才允许 seek)。

另查明用户的视频是 H.265/HEVC,Chrome/Firefox 无法解码 —— 浏览器端无解,
检测到解码失败时显示提示卡片 + 下载入口,不再给一块黑屏。
This commit is contained in:
2026-11-05 12:40:52 +08:00
parent f92a98a3a3
commit a1140437a1
3 changed files with 225 additions and 4 deletions
+171 -3
View File
@@ -2412,7 +2412,19 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
max-width: 92vw; max-height: 100%; width: auto; height: auto; max-width: 92vw; max-height: 100%; width: auto; height: auto;
object-fit: contain; border-radius: 8px; object-fit: contain; border-radius: 8px;
} }
.pv-video { max-width: 92vw; max-height: 100%; width: auto; height: auto; background: #000; } /* 🔴 2026-11-05 修:视频打开后是黑的(用户:「点开之后是黑的,没办法播放」)。
原因:`max-height: 100%` 依赖父级的**确定高度**,而 `.pv-stage` 是
flex + `align-items:center`,百分比高度解析不出具体像素
⇒ `<video>` 按 0 高度渲染 ⇒ 只剩 background:#000 一块黑。
改成 `vh`(相对视口,永远是确定值)+ `object-fit: contain`(竖屏不变形)。 */
.pv-video {
max-width: 92vw;
max-height: 82vh;
width: auto;
height: auto;
object-fit: contain;
background: #000;
}
.pv-pdf { .pv-pdf {
width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff; width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff;
} }
@@ -3949,8 +3961,56 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
font-size: 11.5px; padding: 1px 8px; border-radius: 5px; font-size: 11.5px; padding: 1px 8px; border-radius: 5px;
background: var(--line-2); color: var(--muted); background: var(--line-2); color: var(--muted);
} }
.node-route-line { font-size: 12.5px; color: var(--muted); } /* 🔴 2026-11-05 重做(用户:「这个位都没对齐呀…直接用个标签,然后右边一个箭头,
.node-route-line .arrow { color: var(--faint); margin: 0 3px; } 到北京南站就得了,不用写从哪到哪」)。
原来只是一个普通 div,里面的 <a> 与 <span class="arrow"> 各按自己的基线排
⇒ 字号不同就"没对齐"。
现在:容器 flex + align-items:center,两个地名各自成胶囊标签,
箭头只作窄分隔。视觉上就是「[青岛北站] → [北京南站]」,
不再需要"从/到"这种字。 */
.node-route-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 12.5px;
color: var(--muted);
line-height: 1;
margin-top: 2px;
}
/* 地名标签:浅底圆角,长名字省略 */
.node-route-line > a,
.node-route-line > span.route-stop {
display: inline-flex;
align-items: center;
max-width: 46%;
padding: 3px 8px;
border-radius: 999px;
background: var(--fill, #F1EAE1);
color: var(--ink, #2C231D);
font-size: 12px;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-route-line > a:hover {
background: var(--brand-soft, #F5E3DC);
}
/* 箭头:窄、淡,只做分隔 */
.node-route-line .arrow {
color: var(--faint);
margin: 0 1px;
font-size: 12px;
line-height: 1;
flex: none;
}
.node-spent { font-size: 12.5px; color: var(--brand-ink); } .node-spent { font-size: 12.5px; color: var(--brand-ink); }
.node-member { display: inline-flex; align-items: center; gap: 4px; } .node-member { display: inline-flex; align-items: center; gap: 4px; }
.node-role { .node-role {
@@ -7004,3 +7064,111 @@ label.is-req,
.dark .mv-row.is-req > .mv-lb { .dark .mv-row.is-req > .mv-lb {
color: #F08A78 !important; color: #F08A78 !important;
} }
/* ============================================================
#47(2026-11-05)行程状态动作按钮
------------------------------------------------------------
用户:「做成两个按钮,颜色也尽可能保持一致…
选中其中一个按钮之后…还加了一个那种椭圆的那种框那种背景,灰色的。」
实现:胶囊(border-radius:999px)+ 浅底;
`.is-on` 是"当前状态",用实心主题色表示"正在被选中",
其余动作是浅底 —— 一眼能看出现在是什么、能改成什么。
============================================================ */
.node-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 2px;
}
.node-act {
display: inline-flex;
align-items: center;
border: 0;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
line-height: 1.35;
background: var(--fill, #F1EAE1);
color: var(--muted, #8A7A6D);
cursor: pointer;
font-family: inherit;
transition: background .12s ease, color .12s ease;
}
.node-act:hover { background: var(--brand-soft, #F5E3DC); }
/* 当前状态:实心 = 正在被选中 */
.node-act.is-on {
background: var(--muted, #8A7A6D);
color: #fff;
cursor: default;
}
.node-act-go { color: #2F6FA8; }
.node-act-go:hover { background: #EAF2F9; }
.node-act-ok { color: #3F7D5A; }
.node-act-ok:hover { background: #EAF4EE; }
.node-act-no { color: #C0533F; }
.node-act-no:hover { background: #FBEDEA; }
.node-act-gray { color: var(--muted, #8A7A6D); }
/* #48(2026-11-05)补充:取消按钮(琥珀色,与"删除"的砖红区分开)。
用户强调「取消和删除是不一样的」⇒ 视觉上也不能同色。 */
.node-act-warn { color: #B8862B; }
.node-act-warn:hover { background: #FDF6E7; }
/* 时间行:计划与实际并列时的区分 */
.node-time .t-plan { color: var(--muted, #8A7A6D); }
.node-time .t-actual {
color: var(--brand, #A8574F);
margin-left: 8px;
font-weight: 500;
}
/* ============================================================
#53(2026-11-05)视频解不了时的提示卡片
------------------------------------------------------------
用户传的视频是 H.265/HEVC,Chrome/Firefox 解不了 ⇒ 黑屏。
不给提示的话用户会以为"文件坏了/网站坏了"。
这里把原因和出路直接写在画面里。
============================================================ */
.pv-fallback {
max-width: 520px;
padding: 28px 24px;
border-radius: 12px;
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .18);
text-align: center;
color: #fff;
}
.pv-fallback-h {
margin: 0 0 10px;
font-size: 17px;
font-weight: 600;
}
.pv-fallback-p {
margin: 0 0 18px;
font-size: 13px;
line-height: 1.7;
opacity: .82;
}
.pv-fallback-dl {
display: inline-block;
padding: 9px 22px;
border-radius: 8px;
background: #fff;
color: #1a1a1a;
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.pv-fallback-dl:hover { background: #eee; }
+33
View File
@@ -70,6 +70,8 @@
var kind = trigger.getAttribute('data-prev') || 'image'; var kind = trigger.getAttribute('data-prev') || 'image';
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || ''; var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
var dl = trigger.getAttribute('data-dl') || src; var dl = trigger.getAttribute('data-dl') || src;
/* #53:文件名要在 error 回调里用到,提前取出来(原来 nm 在函数末尾才定义) */
var nm2 = trigger.getAttribute('data-name') || '';
if (!src) { return false; } if (!src) { return false; }
opener = trigger; opener = trigger;
clear(); clear();
@@ -82,6 +84,37 @@
el.controls = true; el.controls = true;
el.autoplay = true; el.autoplay = true;
el.playsInline = true; el.playsInline = true;
/* 🔴🔴 #53(2026-11-05):**HEVC 解码失败时给明确提示,不要装死**。
实测用户的视频是 H.265/HEVC(`hvc1`),Chrome/Firefox 的根本解不了 ——
表现为一块黑屏,什么提示都没有,用户只会以为"文件坏了"。
这里监听 error:能取到 `el.error.code` 就说明是解码/加载失败。
把解释写进画面本身(而不是 alert),并给出下载入口 ——
下载下来用系统播放器是能看的(VLC/iPhone/Windows 都支持 HEVC)。
⚠️ 不做"静默回退到下载页":用户点的是"看",直接跳走会困惑。
要的是**在原地说明白**,同时给一条出路。 */
el.addEventListener('error', function () {
var wrap = document.createElement('div');
wrap.className = 'pv-fallback';
var h = document.createElement('p');
h.className = 'pv-fallback-h';
h.textContent = '这个视频浏览器解不了';
var p = document.createElement('p');
p.className = 'pv-fallback-p';
p.textContent = '它可能是 H.265(HEVC)编码。Chrome、Firefox 不支持这种格式,'
+ '但文件本身没坏 —— 下载后用系统播放器(iPhone / VLC / Windows 播放器)可以正常观看。';
var a = document.createElement('a');
a.className = 'pv-fallback-dl';
a.href = dl;
a.textContent = '下载这个视频';
if (nm2) { a.setAttribute('download', nm2); }
wrap.appendChild(h);
wrap.appendChild(p);
wrap.appendChild(a);
while (stage.firstChild) { stage.removeChild(stage.firstChild); }
stage.appendChild(wrap);
});
} else if (kind === 'pdf') { } else if (kind === 'pdf') {
el = document.createElement('iframe'); el = document.createElement('iframe');
el.className = 'pv-pdf'; el.className = 'pv-pdf';
+21 -1
View File
@@ -217,8 +217,25 @@ $etag = '"' . md5($key . '|' . $size . '|' . $mime) . '"';
v47 的缓存回写(见第 3 步)让第二次开始命中的是**本地文件**, v47 的缓存回写(见第 3 步)让第二次开始命中的是**本地文件**,
所以这里"取回来再判 304"的代价已经降到本地读盘级别,够用了。 所以这里"取回来再判 304"的代价已经降到本地读盘级别,够用了。
(真要连本地读都省掉,得把 ETag 单独持久化 —— 那是下一步的事。) */ (真要连本地读都省掉,得把 ETag 单独持久化 —— 那是下一步的事。) */
/* 🔴🔴 2026-11-05 修(用户:「网页端的视频…点开之后是黑的,没办法播放」):
**带 Range 的请求绝不能回 304**。
原来的顺序是"先判 304,再处理 Range"。视频第一次播完后浏览器缓存了 ETag,
拖动进度条 / 第二次打开时会同时发:
Range: bytes=1048576-
If-None-Match: "abc123"
⇒ 命中 304(304 **没有响应体**)⇒ `<video>` 拿不到数据 ⇒ **黑屏**。
这就是"有时候能播、有时候黑"的根因:取决于浏览器有没有带 ETag。
RFC 7233 也明确:304 不能替代对 Range 的响应。
⇒ 有 Range 就跳过 304,直接走第 6 步。 */
$hasRange = trim((string)($_SERVER['HTTP_RANGE'] ?? '')) !== '';
$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')); $inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
if ($inm !== '' && $inm === $etag) { if (!$hasRange && $inm !== '' && $inm === $etag) {
http_response_code(304); http_response_code(304);
header('ETag: ' . $etag); header('ETag: ' . $etag);
header('Cache-Control: private, max-age=604800'); header('Cache-Control: private, max-age=604800');
@@ -261,6 +278,9 @@ if ($isRange) {
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size); header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
} }
header('Accept-Ranges: bytes'); header('Accept-Ranges: bytes');
/* 2026-11-05:补 Last-Modified —— 部分浏览器/Safari 需要它才允许 seek,
缺了会对某些 mp4 直接拒绝播放(表现为黑屏 + 无报错)。 */
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
$filename = basename($key); $filename = basename($key);
$disp = !empty($_GET['dl']) ? 'attachment' : 'inline'; $disp = !empty($_GET['dl']) ? 'attachment' : 'inline';
header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode($filename) . '"'); header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode($filename) . '"');