Files
fitness-coach/miniprogram/components/gif-player/gif-player.js

221 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

// gif-player微信 <image> 组件不播放 GIF 动画(只渲染首帧),
// 这里用 Canvas 2D + 纯 JS 解码器(omggif)逐帧绘制,实现真正的动图播放。
//
// 两个关键修复:
// 1) 循环:循环回到首帧时重置累积缓冲区,避免上一轮 disposal 残留导致画面冻结/错乱。
// 2) 缓存:同一 GIF 下载/解码一次后,同会话用内存缓存秒开,跨会话用本地文件缓存,
// 不再每次进入详情页都重新下载。
const { GifReader } = require('../../utils/vendor/omggif.js');
const config = require('../../config.js');
// ---------- 模块级 GIF 缓存 ----------
// 内存缓存(同会话秒开) + 本地文件缓存(跨会话持久) 双保险。
const _mem = new Map(); // url -> { buf, reader } 按插入顺序充当 LRU
const _inflight = new Map(); // url -> Promise去重并发请求防止同一 GIF 重复下载
const _MAX_MEM = 30; // 内存最多保留 30 个解码结果
const _MAX_LOCAL = 12; // 本地文件最多保留 12 个(约数十 MB避免超配额)
const _localIndex = []; // 本地文件访问顺序(用于 LRU 淘汰)
function _hash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) >>> 0;
return h.toString(36);
}
function _localPath(url) {
// 含 cb 参数天然区分媒体版本,换超分动图后旧缓存不会误命中
return `${wx.env.USER_DATA_PATH}/gifcache_${_hash(url)}.bin`;
}
function _readLocal(url) {
return new Promise((resolve) => {
wx.getFileSystemManager().readFile({
filePath: _localPath(url),
success: (r) => resolve(r.data),
fail: () => resolve(null)
});
});
}
function _writeLocal(url) {
const entry = _mem.get(url);
if (!entry) return;
try {
wx.getFileSystemManager().writeFileSync(_localPath(url), entry.buf);
const p = _localPath(url);
const i = _localIndex.indexOf(p);
if (i >= 0) _localIndex.splice(i, 1);
_localIndex.push(p);
while (_localIndex.length > _MAX_LOCAL) {
const old = _localIndex.shift();
try { wx.getFileSystemManager().unlinkSync(old); } catch (e) { /* ignore */ }
}
} catch (e) { /* 配额不足等写入失败,忽略:内存缓存仍生效 */ }
}
function _touchMem(url) {
const v = _mem.get(url);
if (v) { _mem.delete(url); _mem.set(url, v); } // 移到末尾 = 最近使用
}
function _evictMem() {
while (_mem.size > _MAX_MEM) {
const k = _mem.keys().next().value;
_mem.delete(k);
}
}
function loadGif(url) {
if (_mem.has(url)) {
_touchMem(url);
return Promise.resolve(_mem.get(url));
}
if (_inflight.has(url)) return _inflight.get(url);
const p = (async () => {
// 先试本地文件缓存(跨会话)
const local = await _readLocal(url);
if (local) {
const reader = new GifReader(new Uint8Array(local));
const entry = { buf: local, reader };
_mem.set(url, entry);
_touchMem(url);
_evictMem();
return entry;
}
// 未命中则下载
const buf = await new Promise((resolve, reject) => {
wx.request({
url,
responseType: 'arraybuffer',
success: (res) => (res.statusCode >= 200 && res.statusCode < 300
? resolve(res.data)
: reject(new Error('HTTP ' + res.statusCode))),
fail: reject
});
});
const reader = new GifReader(new Uint8Array(buf));
const entry = { buf, reader };
_mem.set(url, entry);
_touchMem(url);
_evictMem();
_writeLocal(url); // 落本地,下次跨会话可直接读
return entry;
})();
_inflight.set(url, p);
p.finally(() => _inflight.delete(url));
return p;
}
Component({
properties: {
// GIF 地址(通常是 ex.gifUrl超分后的清晰动图
// 组件可能在 attached 之后才拿到 src父页面接口异步返回用 observer 兜底重新加载。
src: {
type: String,
value: '',
observer(value) {
if (value) this._load();
}
}
},
data: {
failed: false,
loading: true
},
lifetimes: {
attached() {
this._aborted = false;
this._timer = null;
this._reader = null;
this._ctx = null;
this._rgba = null;
this._frame = 0;
this._loadedSrc = '';
if (this.data.src) this._load();
},
detached() {
// 仅 detached 时彻底停止;不要把 _aborted 留给异步回调误判
this._aborted = true;
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
}
},
methods: {
// 清除上一次可能的循环定时器(不动 _aborted 标志,否则会误杀正在进行的异步请求)
_clearTimer() {
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
},
_load() {
const raw = this.data.src;
if (!raw || this._aborted) return;
// 击穿微信 GET 缓存:服务器静态文件被覆盖新版本(如重做超分动图)后,
// 旧图仍可能被微信请求缓存导致客户端显示旧图。加固定 bust 参数强制重新下载。
const sep = raw.indexOf('?') >= 0 ? '&' : '?';
const src = raw + sep + 'cb=' + config.MEDIA_CACHE_BUST;
// 同一 src(含 bust)且已解码完成则跳过,避免 attached 与 observer 重复触发
if (this._loadedSrc === src && this._reader) return;
this._loadedSrc = src;
this._clearTimer();
this.setData({ loading: true, failed: false });
loadGif(src).then((entry) => {
// detached 或 src 已变化则放弃
if (this._aborted || this._loadedSrc !== src) return;
this._reader = entry.reader;
this._frame = 0;
this._start();
}).catch((e) => {
if (!this._aborted) {
console.error('[gif-player] load fail', e);
this.setData({ failed: true, loading: false });
}
});
},
// 等待 Canvas 2D 节点就绪(自定义组件 attached 时可能还没布局好,最多重试 15 次)
_start(attempt = 0) {
if (this._aborted || !this._reader) return;
const q = wx.createSelectorQuery().in(this);
q.select('#gifCanvas').fields({ node: true, size: true }).exec((rs) => {
if (this._aborted) return;
if (!rs || !rs[0] || !rs[0].node) {
if (attempt < 15) setTimeout(() => this._start(attempt + 1), 60);
return;
}
const canvas = rs[0].node;
const ctx = canvas.getContext('2d');
const w = this._reader.width;
const h = this._reader.height;
// 画布内部分辨率 = GIF 原始分辨率(已超分到 720CSS 负责缩放到容器
canvas.width = w;
canvas.height = h;
this._ctx = ctx;
this._rgba = new Uint8ClampedArray(w * h * 4);
this._frame = 0;
this._render();
});
},
_render() {
if (this._aborted || !this._reader || !this._ctx) return;
const r = this._reader;
const n = r.numFrames();
if (!n) { this.setData({ failed: true, loading: false }); return; }
try {
// 循环回到首帧时重置累积缓冲区,清除上一轮 disposal 残留,
// 否则画面会在播放一轮后冻结/错乱。
if (this._frame === 0) {
this._rgba = new Uint8ClampedArray(r.width * r.height * 4);
}
// decodeAndBlitFrameRGBA 按 GIF 的 disposal 规则在 rgba 上累积绘制
r.decodeAndBlitFrameRGBA(this._frame, this._rgba);
const img = this._ctx.createImageData(r.width, r.height);
img.data.set(this._rgba);
this._ctx.putImageData(img, 0, 0);
} catch (e) {
console.error('[gif-player] frame fail', e);
this.setData({ failed: true, loading: false });
return;
}
// 首帧渲染完成,关闭加载提示
if (this.data.loading) this.setData({ loading: false });
const info = r.frameInfo(this._frame);
// omggif 的 delay 单位是 1/100 秒,需 ×10 转毫秒;缺失时给 100ms
const delay = (info && info.delay) ? info.delay * 10 : 100;
this._frame = (this._frame + 1) % n;
// 单帧 GIF 只画一次;多帧无限循环(健身演示需要持续播放)。最小 40ms 避免极端快帧
if (n > 1) this._timer = setTimeout(() => this._render(), Math.max(40, delay));
}
}
});