Files
FamilyBook/assets/js/preview.js
T
laoyang a1140437a1 视频:修 media.php 的 304/Range 冲突,HEVC 解不了时给明确提示 (#52)
304 判定没有排除 Range 请求:视频第二次打开时浏览器同时发
Range + If-None-Match,命中 304 拿到空响应 ⇒ 黑屏。
改成 $hasRange 为真时跳过 304。顺手补 Last-Modified(部分浏览器需要它才允许 seek)。

另查明用户的视频是 H.265/HEVC,Chrome/Firefox 无法解码 —— 浏览器端无解,
检测到解码失败时显示提示卡片 + 下载入口,不再给一块黑屏。
2026-11-05 12:40:52 +08:00

217 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 全局附件预览(v24 用户第 3 条;第 19 批 第 6 条加左右翻页)
*
* 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。
* · 图片 → <img>
* · 视频 → <video controls autoplay>(media.php 支持 Range,进度条能拖)
* · PDF → <iframe>(用浏览器自带的阅读器)
* · 其它(Word / Excel / …)→ 根本不进这里:那段链接指向的是下载地址,点了就下。
*
* 右上角恒有一个「下载」按钮(用户原话:所有的预览页面,右上角都有一个下载按钮)。
*
* 触发方式是数据属性,不用给每个地方写一遍初始化:
* <a href="…" data-prev="image|video|pdf" data-src="…" data-dl="…" data-name="…">
*
* ────────────────────────────────────────────────────────────────
* v24 第 19 批 用户第 6 条:「行程中的图片和视频,预览的时候,左右有箭头,
* 可以直接查看下一张。」
*
* 做法:翻页的候选 = **同一页里所有可预览的附件**(`[data-prev]`),按文档顺序。
* 为什么不做"只在本段行程内翻页":
* · 这个预览层是**全站一个**(图片/视频/PDF 共用),而 attachments 墙是按
* 宿主分块渲染的;要"按块"就得给每块再编一次组号,收益很小、复杂度不小;
* · 用户要的行为是"看完这张接着看下一张",同页顺序翻页完全满足,
* 并且跨段也不会跳去别的页面 —— 翻到下一个旅程节点时用户能立刻看出来。
* ⚠️ 只有**一个**可预览项时箭头不显示(点了没反应的元素比没有更糟)。
* ⚠️ 键盘 ← → 也翻页;视频/PDF 里方向键有自己的含义,所以**只在图片**上接管方向键。
*/
(function () {
'use strict';
var box = document.getElementById('pv');
if (!box) { return; }
var stage = box.querySelector('.pv-stage');
var titleEl = box.querySelector('.pv-name');
var metaEl = box.querySelector('.pv-meta');
var dlEl = box.querySelector('.pv-dl');
var closeEl = box.querySelector('.pv-close');
var prevEl = box.querySelector('.pv-prev');
var nextEl = box.querySelector('.pv-next');
var opener = null;
/** 当前这一层里能看到的所有可预览项(文档顺序),以及"正在看第几个" */
var items = [];
var cur = -1;
function clear() {
// ⚠️ 换内容要**整个重建**节点,不能只改 src:
// <video> 改 src 不会自动卸载上一支(声音会叠着放),<iframe> 同理。
stage.innerHTML = '';
}
/** 收一份当前页面里所有可预览的附件(每次打开都重收,页面内容可能被改过) */
function collect(trigger) {
items = Array.prototype.slice.call(document.querySelectorAll('[data-prev]'));
cur = items.indexOf(trigger);
if (cur < 0) { items = [trigger]; cur = 0; }
}
/** 第一张 / 最后一张时箭头要禁用(循环翻页会让人不知道自己在哪儿) */
function syncArrows() {
if (!prevEl || !nextEl) { return; }
var many = items.length > 1;
prevEl.hidden = !many;
nextEl.hidden = !many;
prevEl.disabled = !many || cur <= 0;
nextEl.disabled = !many || cur >= items.length - 1;
}
function paint(trigger) {
var kind = trigger.getAttribute('data-prev') || 'image';
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
var dl = trigger.getAttribute('data-dl') || src;
/* #53:文件名要在 error 回调里用到,提前取出来(原来 nm 在函数末尾才定义) */
var nm2 = trigger.getAttribute('data-name') || '';
if (!src) { return false; }
opener = trigger;
clear();
var el;
if (kind === 'video') {
el = document.createElement('video');
el.className = 'pv-video';
el.src = src;
el.controls = true;
el.autoplay = 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') {
el = document.createElement('iframe');
el.className = 'pv-pdf';
el.src = src;
// ⚠️ 这里**绝对不能**加 sandbox 属性。加上去 PDF 会直接打不开:
// 页面只剩一行「此页面已被 Chromium 屏蔽」,阅读器根本不起。
//
// 实测(Chromium 153,五组同源对照):
// · 无 sandbox → 正常渲染(多挂一个内置阅读器扩展 frame)
// · allow-same-origin allow-scripts → 屏蔽
// · allow-scripts → 屏蔽
// · + allow-popups-to-escape-sandbox → **仍然屏蔽**(网上很多答案说这个能救,本版本不行)
// · + allow-forms allow-popups → 仍然屏蔽
// ⇒ 只要属性存在就不给 PDF 插件权限。加白名单无效,只能不加。
//
// 不加也安全:PDF 由浏览器内置阅读器(mhjfbmdgcfjbbpaeojofohoefgiehjai)
// 在**独立扩展进程**里渲染,不是普通页面上下文,拿不到父页的 window / DOM / cookie。
// PDF 是原生文档、不是 HTML,本就没有能访问父页面的脚本环境。
// 服务端另有 `X-Content-Type-Options: nosniff` + MIME 按内容嗅探(media.php),
// PDF 不会被当 HTML 执行。若将来真需要禁止 PDF 内嵌 JS,正确做法是换 pdf.js 自渲染,
// 而不是给 iframe 挂 sandbox——那会把整个 PDF 预览一起废掉。
} else {
el = document.createElement('img');
el.className = 'pv-img';
el.src = src;
el.alt = '';
}
stage.appendChild(el);
titleEl.textContent = trigger.getAttribute('data-name') || '';
var sz = trigger.getAttribute('data-size') || '';
var counter = items.length > 1 ? (' ' + (cur + 1) + '/' + items.length) : '';
metaEl.textContent = sz + counter;
dlEl.setAttribute('href', dl);
// 下载要带上真实文件名:download 属性 + 服务端 Content-Disposition 双保险
var nm = trigger.getAttribute('data-name') || '';
if (nm) { dlEl.setAttribute('download', nm); } else { dlEl.removeAttribute('download'); }
syncArrows();
return true;
}
function open(trigger) {
collect(trigger);
if (!paint(trigger)) { return; }
box.hidden = false;
box.setAttribute('aria-hidden', 'false');
closeEl.focus();
}
/**
* 翻到上一张 / 下一张。
* @param {number} dir -1 = 上一张,+1 = 下一张
*/
function step(dir) {
if (items.length <= 1) { return; }
var n = cur + dir;
if (n < 0 || n >= items.length) { return; }
cur = n;
paint(items[cur]);
}
function close() {
box.hidden = true;
box.setAttribute('aria-hidden', 'true');
clear(); // 停掉视频 / 卸掉 PDF
if (opener && opener.focus) { opener.focus(); }
opener = null;
items = [];
cur = -1;
}
/** 正在看的是不是图片(视频/PDF 里方向键另有含义,不能抢) */
function isImage() {
return items[cur] && (items[cur].getAttribute('data-prev') || '') === 'image';
}
document.addEventListener('click', function (e) {
var t = e.target && e.target.closest ? e.target.closest('[data-prev]') : null;
if (t) {
// ⚠️ 只拦"可预览"的:Word/Excel 那类链接没有 data-prev,
// 浏览器照常走 href(就是下载地址),不会被这里挡住。
e.preventDefault();
open(t);
return;
}
// 点遮罩(stage 本身或弹层空白处)关闭;点内容不关
if (!box.hidden && (e.target === box || e.target === stage)) { close(); }
});
if (prevEl) { prevEl.addEventListener('click', function () { step(-1); }); }
if (nextEl) { nextEl.addEventListener('click', function () { step(1); }); }
closeEl.addEventListener('click', close);
document.addEventListener('keydown', function (e) {
if (box.hidden) { return; }
if (e.key === 'Escape') { close(); return; }
if (e.key === 'ArrowLeft' && isImage()) { e.preventDefault(); step(-1); return; }
if (e.key === 'ArrowRight' && isImage()) { e.preventDefault(); step(1); }
});
})();