refactor(vtt-thumbnail): [PKG-VTT-THUMB-03] own pending requests and preview resources

This commit is contained in:
Harvey Zhao committed 2026-09-13 08:37:48 +08:00
1 parent 05244d1104
commit 524ddf784f
23 files changed
+3045 -262

No files matched your search

@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginVttThumbnail=e(),define(function(){return t.artplayerPluginVttThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginVttThumbnail=e()}(this,function(){"use strict";function t(t){const e=t.split("."),n=e[0].split(":")||[],i=(l=e[1]||"0",o=3,s="0",l.length>o?String(l):((o-=l.length)>s.length&&(s+=s.repeat(o/s.length)),String(l)+s.slice(0,o)));var l,o,s;const r=Number(i)/1e3;return 3600*Number(n[n.length-3]||0)+60*Number(n[n.length-2]||0)+Number(n[n.length-1]||0)+r}return function(e){return async n=>{const{constructor:{utils:{setStyle:i,isMobile:l,addClass:o}},template:{$progress:s}}=n;let r=null;const a=await async function(e=""){const n=(await(await fetch(e)).text()).split(/[\n\r]/g).filter(t=>t.trim()),i=[];for(let l=1;l<n.length;l+=2){const o=n[l],s=n[l+1];if(!s.trim())continue;const r=/((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?) ?--> ?((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?)/,a=o.match(r),u=/(.*)#(\w{4})=(.*)/,c=s.match(u),d=Math.floor(t(a[1])),p=Math.floor(t(a[2]));let f=c[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(f)){const t=e.split("/");t.pop(),t.push(f),f=t.join("/")}const h={start:d,end:p,url:f},m=c[2].split(""),g=c[3].split(",");for(let t=0;t<m.length;t++)h[m[t]]=g[t];i.push(h)}return i}(e.vtt);return n.controls.add({name:"vtt-thumbnail",position:"top",index:20,style:e.style||{},mounted(t){o(t,"art-control-thumbnails"),n.on("setBar",async(e,o,u)=>{const c="played"===e&&u&&l;if("hover"===e||c){const e=s.clientWidth*o,u=o*n.duration;i(t,"display","flex");const d=a.find(t=>u>=t.start&&u<=t.end);if(!d)return i(t,"display","none");e>0&&e<s.clientWidth?function(t,e,n){i(t,"backgroundImage",`url(${e.url})`),i(t,"height",`${e.h}px`),i(t,"width",`${e.w}px`),i(t,"backgroundPosition",`-${e.x}px -${e.y}px`),n<=e.w/2?i(t,"left",0):n>s.clientWidth-e.w/2?i(t,"left",s.clientWidth-e.w+"px"):i(t,"left",n-e.w/2+"px")}(t,d,e):l||i(t,"display","none"),c&&(clearTimeout(r),r=setTimeout(()=>{i(t,"display","none")},500))}})}}),{name:"artplayerPluginVttThumbnail"}}}});
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginVttThumbnail=e(),define(function(){return t.artplayerPluginVttThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginVttThumbnail=e()}(this,function(){"use strict";function t(t){const e=t.split("."),n=e[0].split(":")||[],o=(l=e[1]||"0",i=3,r="0",l.length>i?String(l):((i-=l.length)>r.length&&(r+=r.repeat(i/r.length)),String(l)+r.slice(0,i)));var l,i,r;const s=Number(o)/1e3;return 3600*Number(n[n.length-3]||0)+60*Number(n[n.length-2]||0)+Number(n[n.length-1]||0)+s}function e({lifetime:t,thumbnails:e,progress:n,duration:o,setStyle:l,isMobile:i}){let r=null,s=0;function c(e,n,o){t.closed||l(e,n,o)}return t.own(()=>{if(s++,null!==r){const t=r;r=null,clearTimeout(t)}}),l=>async(u,a,d)=>{if(t.closed)return;const f="played"===u&&d&&i;if("hover"!==u&&!f)return;const h=n.clientWidth*a,p=a*o();if(c(l,"display","flex"),t.closed)return;const m=e.find(t=>p>=t.start&&p<=t.end);if(!m)return c(l,"display","none");if(h>0&&h<n.clientWidth?function(t,e,o){c(t,"backgroundImage",`url(${e.url})`),c(t,"height",`${e.h}px`),c(t,"width",`${e.w}px`),c(t,"backgroundPosition",`-${e.x}px -${e.y}px`),o<=e.w/2?c(t,"left",0):o>n.clientWidth-e.w/2?c(t,"left",n.clientWidth-e.w+"px"):c(t,"left",o-e.w/2+"px")}(l,m,h):i||c(l,"display","none"),f&&!t.closed){const e=++s;if(null!==r&&clearTimeout(r),t.closed)return;const n=setTimeout(()=>{e!==s||t.closed||(r=null,c(l,"display","none"))},500);t.closed||e!==s?clearTimeout(n):r=n}}}return function(n){return async o=>{const{constructor:{utils:{setStyle:l,isMobile:i,addClass:r}},template:{$progress:s}}=o,c=function(t){let e=Boolean(t.isDestroy);const n=new Set;let o;const l=new Promise(t=>{o=t}),i={get closed(){return e},own:t=>(e?r(t):n.add(t),()=>n.delete(t)),listen(n,o){e||(i.own(()=>t.off(n,o)),t.on(n,o))},wait:t=>Promise.race([t,l]),dispose(){if(e)return;e=!0,o();const t=[...n].reverse();n.clear();for(const e of t)r(e)}};function r(t){try{t()}catch(e){console.warn("Failed to clean up VTT thumbnails:",e)}}try{i.listen("destroy",i.dispose)}catch(s){throw i.dispose(),s}return i}(o),u={name:"artplayerPluginVttThumbnail"};try{if(c.closed)return u;const a=n.vtt,d=await async function(t="",e){if(e.closed)return;const n="function"===typeof AbortController?new AbortController:null,o=e.own(()=>n?.abort());try{if(e.closed)return;const o=await e.wait(n?fetch(t,{signal:n.signal}):fetch(t));if(e.closed)return;if(!1===o.ok)throw new Error(`Failed to fetch VTT thumbnails: HTTP ${o.status}`);if(e.closed)return;return await e.wait(o.text())}finally{o()}}(a,c);if(c.closed)return u;const f=function(e,n=""){const o=e.split(/[\n\r]/g).filter(t=>t.trim()),l=[];for(let i=1;i<o.length;i+=2){const e=o[i],r=o[i+1];if(!r.trim())continue;const s=/((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?) ?--> ?((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?)/,c=e.match(s),u=/(.*)#(\w{4})=(.*)/,a=r.match(u),d=Math.floor(t(c[1])),f=Math.floor(t(c[2]));let h=a[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(h)){const t=n.split("/");t.pop(),t.push(h),h=t.join("/")}const p={start:d,end:f,url:h},m=a[2].split(""),y=a[3].split(",");for(let t=0;t<m.length;t++)p[m[t]]=y[t];l.push(p)}return l}(d,a),h=e({lifetime:c,thumbnails:f,progress:s,duration:()=>o.duration,setStyle:l,isMobile:i}),p=n.style||{};return c.closed||o.controls.add({name:"vtt-thumbnail",position:"top",index:20,style:p,mounted(t){c.own(()=>{o.controls["vtt-thumbnail"]===t&&o.controls.remove("vtt-thumbnail")}),c.closed||(r(t,"art-control-thumbnails"),c.listen("setBar",h(t)))}}),u}catch(a){throw c.dispose(),a}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginVttThumbnail=e(),define(function(){return t.artplayerPluginVttThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginVttThumbnail=e()}(this,function(){"use strict";var t=(t,e,n)=>new Promise((l,i)=>{var o=t=>{try{s(n.next(t))}catch(e){i(e)}},r=t=>{try{s(n.throw(t))}catch(e){i(e)}},s=t=>t.done?l(t.value):Promise.resolve(t.value).then(o,r);s((n=n.apply(t,e)).next())});function e(t){const e=t.split("."),n=e[0].split(":")||[],l=(i=e[1]||"0",o=3,r="0",i.length>o?String(i):((o-=i.length)>r.length&&(r+=r.repeat(o/r.length)),String(i)+r.slice(0,o)));var i,o,r;const s=Number(l)/1e3;return 3600*Number(n[n.length-3]||0)+60*Number(n[n.length-2]||0)+Number(n[n.length-1]||0)+s}return function(n){return l=>t(null,null,function*(){const{constructor:{utils:{setStyle:i,isMobile:o,addClass:r}},template:{$progress:s}}=l;let u=null;const a=yield function(n=""){return t(this,null,function*(){const t=(yield(yield fetch(n)).text()).split(/[\n\r]/g).filter(t=>t.trim()),l=[];for(let i=1;i<t.length;i+=2){const o=t[i],r=t[i+1];if(!r.trim())continue;const s=/((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?) ?--> ?((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?)/,u=o.match(s),a=/(.*)#(\w{4})=(.*)/,c=r.match(a),d=Math.floor(e(u[1])),h=Math.floor(e(u[2]));let p=c[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(p)){const t=n.split("/");t.pop(),t.push(p),p=t.join("/")}const f={start:d,end:h,url:p},m=c[2].split(""),g=c[3].split(",");for(let t=0;t<m.length;t++)f[m[t]]=g[t];l.push(f)}return l})}(n.vtt);return l.controls.add({name:"vtt-thumbnail",position:"top",index:20,style:n.style||{},mounted(e){r(e,"art-control-thumbnails"),l.on("setBar",(n,r,c)=>t(null,null,function*(){const t="played"===n&&c&&o;if("hover"===n||t){const n=s.clientWidth*r,c=r*l.duration;i(e,"display","flex");const d=a.find(t=>c>=t.start&&c<=t.end);if(!d)return i(e,"display","none");n>0&&n<s.clientWidth?function(t,e,n){i(t,"backgroundImage",`url(${e.url})`),i(t,"height",`${e.h}px`),i(t,"width",`${e.w}px`),i(t,"backgroundPosition",`-${e.x}px -${e.y}px`),n<=e.w/2?i(t,"left",0):n>s.clientWidth-e.w/2?i(t,"left",s.clientWidth-e.w+"px"):i(t,"left",n-e.w/2+"px")}(e,d,n):o||i(e,"display","none"),t&&(clearTimeout(u),u=setTimeout(()=>{i(e,"display","none")},500))}}))}}),{name:"artplayerPluginVttThumbnail"}})}});
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginVttThumbnail=e(),define(function(){return t.artplayerPluginVttThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginVttThumbnail=e()}(this,function(){"use strict";var t=(t,e,n)=>new Promise((o,l)=>{var i=t=>{try{s(n.next(t))}catch(e){l(e)}},r=t=>{try{s(n.throw(t))}catch(e){l(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(i,r);s((n=n.apply(t,e)).next())});function e(t){const e=t.split("."),n=e[0].split(":")||[],o=(l=e[1]||"0",i=3,r="0",l.length>i?String(l):((i-=l.length)>r.length&&(r+=r.repeat(i/r.length)),String(l)+r.slice(0,i)));var l,i,r;const s=Number(o)/1e3;return 3600*Number(n[n.length-3]||0)+60*Number(n[n.length-2]||0)+Number(n[n.length-1]||0)+s}function n({lifetime:e,thumbnails:n,progress:o,duration:l,setStyle:i,isMobile:r}){let s=null,u=0;function c(t,n,o){e.closed||i(t,n,o)}return e.own(()=>{if(u++,null!==s){const t=s;s=null,clearTimeout(t)}}),i=>(a,d,f)=>t(null,null,function*(){if(e.closed)return;const t="played"===a&&f&&r;if("hover"!==a&&!t)return;const h=o.clientWidth*d,p=d*l();if(c(i,"display","flex"),e.closed)return;const m=n.find(t=>p>=t.start&&p<=t.end);if(!m)return c(i,"display","none");if(h>0&&h<o.clientWidth?function(t,e,n){c(t,"backgroundImage",`url(${e.url})`),c(t,"height",`${e.h}px`),c(t,"width",`${e.w}px`),c(t,"backgroundPosition",`-${e.x}px -${e.y}px`),n<=e.w/2?c(t,"left",0):n>o.clientWidth-e.w/2?c(t,"left",o.clientWidth-e.w+"px"):c(t,"left",n-e.w/2+"px")}(i,m,h):r||c(i,"display","none"),t&&!e.closed){const t=++u;if(null!==s&&clearTimeout(s),e.closed)return;const n=setTimeout(()=>{t!==u||e.closed||(s=null,c(i,"display","none"))},500);e.closed||t!==u?clearTimeout(n):s=n}})}return function(o){return l=>t(null,null,function*(){const{constructor:{utils:{setStyle:i,isMobile:r,addClass:s}},template:{$progress:u}}=l,c=function(t){let e=Boolean(t.isDestroy);const n=new Set;let o;const l=new Promise(t=>{o=t}),i={get closed(){return e},own:t=>(e?r(t):n.add(t),()=>n.delete(t)),listen(n,o){e||(i.own(()=>t.off(n,o)),t.on(n,o))},wait:t=>Promise.race([t,l]),dispose(){if(e)return;e=!0,o();const t=[...n].reverse();n.clear();for(const e of t)r(e)}};function r(t){try{t()}catch(e){console.warn("Failed to clean up VTT thumbnails:",e)}}try{i.listen("destroy",i.dispose)}catch(s){throw i.dispose(),s}return i}(l),a={name:"artplayerPluginVttThumbnail"};try{if(c.closed)return a;const d=o.vtt,f=yield function(e="",n){return t(this,null,function*(){if(n.closed)return;const t="function"===typeof AbortController?new AbortController:null,o=n.own(()=>null==t?void 0:t.abort());try{if(n.closed)return;const o=yield n.wait(t?fetch(e,{signal:t.signal}):fetch(e));if(n.closed)return;if(!1===o.ok)throw new Error(`Failed to fetch VTT thumbnails: HTTP ${o.status}`);if(n.closed)return;return yield n.wait(o.text())}finally{o()}})}(d,c);if(c.closed)return a;const h=function(t,n=""){const o=t.split(/[\n\r]/g).filter(t=>t.trim()),l=[];for(let i=1;i<o.length;i+=2){const t=o[i],r=o[i+1];if(!r.trim())continue;const s=/((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?) ?--> ?((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?)/,u=t.match(s),c=/(.*)#(\w{4})=(.*)/,a=r.match(c),d=Math.floor(e(u[1])),f=Math.floor(e(u[2]));let h=a[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(h)){const t=n.split("/");t.pop(),t.push(h),h=t.join("/")}const p={start:d,end:f,url:h},m=a[2].split(""),y=a[3].split(",");for(let e=0;e<m.length;e++)p[m[e]]=y[e];l.push(p)}return l}(f,d),p=n({lifetime:c,thumbnails:h,progress:u,duration:()=>l.duration,setStyle:i,isMobile:r}),m=o.style||{};return c.closed||l.controls.add({name:"vtt-thumbnail",position:"top",index:20,style:m,mounted(t){c.own(()=>{l.controls["vtt-thumbnail"]===t&&l.controls.remove("vtt-thumbnail")}),c.closed||(s(t,"art-control-thumbnails"),c.listen("setBar",p(t)))}}),a}catch(d){throw c.dispose(),d}})}});
+173 -58
View File
@@ -4,6 +4,59 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
function createLifetime(art) {
let closed = Boolean(art.isDestroy);
const cleanups = /* @__PURE__ */ new Set();
let cancel;
const cancelled = new Promise((resolve) => {
cancel = resolve;
});
const lifetime = {
get closed() {
return closed;
},
own(cleanup) {
if (closed)
run(cleanup);
else
cleanups.add(cleanup);
return () => cleanups.delete(cleanup);
},
listen(name, callback) {
if (closed)
return;
lifetime.own(() => art.off(name, callback));
art.on(name, callback);
},
wait(value) {
return Promise.race([value, cancelled]);
},
dispose() {
if (closed)
return;
closed = true;
cancel();
const pending = [...cleanups].reverse();
cleanups.clear();
for (const cleanup of pending)
run(cleanup);
}
};
function run(cleanup) {
try {
cleanup();
} catch (error) {
console.warn("Failed to clean up VTT thumbnails:", error);
}
}
try {
lifetime.listen("destroy", lifetime.dispose);
} catch (error) {
lifetime.dispose();
throw error;
}
return lifetime;
}
function padEnd(str, targetLength, padString) {
if (str.length > targetLength) {
return String(str);
@@ -25,8 +78,7 @@ function t2d(time) {
const s = Number(left[left.length - 1] || 0);
return h + m + s + ms;
}
async function getVttArray(vttUrl = "") {
const vttString = await (await fetch(vttUrl)).text();
function parseVtt(vttString, vttUrl = "") {
const lines = vttString.split(/[\n\r]/g).filter((item) => item.trim());
const vttArray = [];
for (let i = 1; i < lines.length; i += 2) {
@@ -58,65 +110,128 @@ async function getVttArray(vttUrl = "") {
}
return vttArray;
}
function createPreview({ lifetime, thumbnails, progress, duration, setStyle, isMobile }) {
let timer = null;
let generation = 0;
lifetime.own(() => {
generation++;
if (timer !== null) {
const previous = timer;
timer = null;
clearTimeout(previous);
}
});
function style(control, key, value) {
if (!lifetime.closed)
setStyle(control, key, value);
}
function show(control, cue, width) {
style(control, "backgroundImage", `url(${cue.url})`);
style(control, "height", `${cue.h}px`);
style(control, "width", `${cue.w}px`);
style(control, "backgroundPosition", `-${cue.x}px -${cue.y}px`);
if (width <= cue.w / 2)
style(control, "left", 0);
else if (width > progress.clientWidth - cue.w / 2)
style(control, "left", `${progress.clientWidth - cue.w}px`);
else
style(control, "left", `${width - cue.w / 2}px`);
}
return (control) => async (type, percentage, event) => {
if (lifetime.closed)
return;
const dragging = type === "played" && event && isMobile;
if (type !== "hover" && !dragging)
return;
const width = progress.clientWidth * percentage;
const second = percentage * duration();
style(control, "display", "flex");
if (lifetime.closed)
return;
const cue = thumbnails.find((item) => second >= item.start && second <= item.end);
if (!cue)
return style(control, "display", "none");
if (width > 0 && width < progress.clientWidth)
show(control, cue, width);
else if (!isMobile)
style(control, "display", "none");
if (dragging && !lifetime.closed) {
const current = ++generation;
if (timer !== null)
clearTimeout(timer);
if (lifetime.closed)
return;
const id = setTimeout(() => {
if (current !== generation || lifetime.closed)
return;
timer = null;
style(control, "display", "none");
}, 500);
if (lifetime.closed || current !== generation)
clearTimeout(id);
else
timer = id;
}
};
}
async function requestVtt(url = "", lifetime) {
if (lifetime.closed)
return;
const controller = typeof AbortController === "function" ? new AbortController() : null;
const release = lifetime.own(() => controller?.abort());
try {
if (lifetime.closed)
return;
const response = await lifetime.wait(controller ? fetch(url, { signal: controller.signal }) : fetch(url));
if (lifetime.closed)
return;
if (response.ok === false)
throw new Error(`Failed to fetch VTT thumbnails: HTTP ${response.status}`);
if (lifetime.closed)
return;
return await lifetime.wait(response.text());
} finally {
release();
}
}
function artplayerPluginVttThumbnail(option) {
return async (art) => {
const {
constructor: {
utils: { setStyle, isMobile, addClass }
},
template: { $progress }
} = art;
let timer = null;
const thumbnails = await getVttArray(option.vtt);
function showThumbnails($control, find, width) {
setStyle($control, "backgroundImage", `url(${find.url})`);
setStyle($control, "height", `${find.h}px`);
setStyle($control, "width", `${find.w}px`);
setStyle($control, "backgroundPosition", `-${find.x}px -${find.y}px`);
if (width <= find.w / 2) {
setStyle($control, "left", 0);
} else if (width > $progress.clientWidth - find.w / 2) {
setStyle($control, "left", `${$progress.clientWidth - find.w}px`);
} else {
setStyle($control, "left", `${width - find.w / 2}px`);
}
const { constructor: { utils: { setStyle, isMobile, addClass } }, template: { $progress } } = art;
const lifetime = createLifetime(art);
const result = { name: "artplayerPluginVttThumbnail" };
try {
if (lifetime.closed)
return result;
const url = option.vtt;
const text = await requestVtt(url, lifetime);
if (lifetime.closed)
return result;
const thumbnails = parseVtt(text, url);
const preview = createPreview({ lifetime, thumbnails, progress: $progress, duration: () => art.duration, setStyle, isMobile });
const style = option.style || {};
if (lifetime.closed)
return result;
art.controls.add({
name: "vtt-thumbnail",
position: "top",
index: 20,
style,
mounted(control) {
lifetime.own(() => {
if (art.controls["vtt-thumbnail"] === control)
art.controls.remove("vtt-thumbnail");
});
if (lifetime.closed)
return;
addClass(control, "art-control-thumbnails");
lifetime.listen("setBar", preview(control));
}
});
return result;
} catch (error) {
lifetime.dispose();
throw error;
}
art.controls.add({
name: "vtt-thumbnail",
position: "top",
index: 20,
style: option.style || {},
mounted($control) {
addClass($control, "art-control-thumbnails");
art.on("setBar", async (type, percentage, event) => {
const isMobileDroging = type === "played" && event && isMobile;
if (type === "hover" || isMobileDroging) {
const width = $progress.clientWidth * percentage;
const second = percentage * art.duration;
setStyle($control, "display", "flex");
const find = thumbnails.find((item) => second >= item.start && second <= item.end);
if (!find)
return setStyle($control, "display", "none");
if (width > 0 && width < $progress.clientWidth) {
showThumbnails($control, find, width);
} else {
if (!isMobile) {
setStyle($control, "display", "none");
}
}
if (isMobileDroging) {
clearTimeout(timer);
timer = setTimeout(() => {
setStyle($control, "display", "none");
}, 500);
}
}
});
}
});
return {
name: "artplayerPluginVttThumbnail"
};
};
}
export {
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:unit": "node --test test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -86,7 +86,7 @@
"test:thumbnail": "node --test test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js refactor/scripts/thumbnail-contract.test.mjs refactor/scripts/thumbnail-types.test.mjs",
"test:thumbnail-types-package": "node refactor/scripts/thumbnail-package-types.mjs",
"test:auto-thumbnail": "node --test test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs",
"test:vtt-thumbnail": "node --test test/vtt-thumbnail.test.js refactor/scripts/vtt-thumbnail-contract.test.mjs"
"test:vtt-thumbnail": "node --test test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js refactor/scripts/vtt-thumbnail-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -0,0 +1,69 @@
# VTT thumbnail maintenance
The factory remains `artplayerPluginVttThumbnail(option)`. Its registration function is
asynchronous and resolves to `{ name: 'artplayerPluginVttThumbnail' }`. Existing public
declarations still describe a synchronous result; task PKG-VTT-THUMB-04 owns that conflict
and the strict TypeScript migration. Do not infer the return shape from those declarations.
## Modules and ownership
- `src/index.js` composes registration, captures the VTT URL before awaiting, reads style
afterwards, and installs the `vtt-thumbnail` control with its existing CSS class/index.
- `src/lifetime.js` owns one registration's destroy listener, event listeners and cleanup
callbacks. Close is idempotent. Cleanup continues after a callback fails. An independent
cancellation Promise settles pending work even if fetch does not honor AbortSignal.
- `src/request.js` owns a native AbortController when available and reads the response body.
Destroy cancels both phases; late rejections are observed. Failed HTTP responses reject
before parsing. The controller cleanup is released when the request ends.
- `src/parseVtt.js` is pure parsing with the historical timestamp floor, first inclusive
match data, raw xywh strings and lexical URL joining. Its old malformed-input limitations
remain under PKG-VTT-THUMB-03; extracting this function is not a parser rewrite.
- `src/preview.js` owns the mobile 500ms timer and creates the setBar callback. Timer
generations reject old callbacks. Every style write checks lifetime state, including
callbacks captured before destroy. Geometry, mobile/desktop edges and cue selection
retain the latest published behavior.
- `src/getVttArray.js` retains the old source helper signature as a fetch/parse wrapper.
The player registration uses the owned request module instead. Distribution/deep-import
review belongs to PKG-VTT-THUMB-06.
The registration owns only the control element passed to its mounted callback. Before
removing it, cleanup compares the live `art.controls['vtt-thumbnail']` alias with that exact
element. It must not remove a later replacement. Partial listener/control installation is
rolled back if initialization throws, preserving the original rejection.
## Compatibility and intentional fixes
Option/style remain live at their historical read points. The return stays a Promise;
destroy during loading now settles it with the normal name result, without installing UI.
Existing core plugin managers can complete their own destroyed-instance handling. This
does not introduce a public destroy method or a new registration result field.
Registration still downloads VTT once. A video restart does not reload it or reinterpret
later option.vtt changes. Empty cues remain valid and hide the preview. HTTP failures now
reject explicitly; before this fix, a 404 with a parseable body incorrectly displayed cues.
The historical failure tests retain the former outcome rather than claiming it was valid API.
The old 1.0.x export objects, earlier `thumbnails` control name and incorrect declarations
are separately frozen and remain compatibility work for tasks 04-06. Current runtime work
does not claim those consumer migrations or the full package refactor are finished.
## Verification and future changes
Use Yarn 1.22.22 and the repository's pinned Node version:
```sh
yarn test:vtt-thumbnail
yarn test:browser test/browser/vtt-thumbnail-lifecycle.spec.js
yarn build artplayer-plugin-vtt-thumbnail
```
The unit helper can load actual main/legacy files with ARTPLAYER_VTT_THUMBNAIL_ARTIFACT or
the frozen former implementation with ARTPLAYER_VTT_THUMBNAIL_BASELINE=1. Historical tests
are distinct from candidate expectations. Browser tests use real published/candidate cores,
native fetch/abort, mouse hover, sprite decoding and cleanup. Their scope is not full old
core/device coverage, installed tarballs, mobile Safari or the online editor.
Modify parsing in parseVtt, requests in request, layout/timers in preview, and ownership in
lifetime. Preserve the normal/failure lifecycle tests alongside any parser improvements.
See ../../refactor/baselines/vtt-thumbnail-contract.md and the task 03 change record for
historical evidence and outstanding acceptance work.
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginVttThumbnail=e(),define(function(){return t.artplayerPluginVttThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginVttThumbnail=e()}(this,function(){"use strict";function t(t){const e=t.split("."),n=e[0].split(":")||[],i=(l=e[1]||"0",o=3,s="0",l.length>o?String(l):((o-=l.length)>s.length&&(s+=s.repeat(o/s.length)),String(l)+s.slice(0,o)));var l,o,s;const r=Number(i)/1e3;return 3600*Number(n[n.length-3]||0)+60*Number(n[n.length-2]||0)+Number(n[n.length-1]||0)+r}return function(e){return async n=>{const{constructor:{utils:{setStyle:i,isMobile:l,addClass:o}},template:{$progress:s}}=n;let r=null;const a=await async function(e=""){const n=(await(await fetch(e)).text()).split(/[\n\r]/g).filter(t=>t.trim()),i=[];for(let l=1;l<n.length;l+=2){const o=n[l],s=n[l+1];if(!s.trim())continue;const r=/((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?) ?--> ?((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?)/,a=o.match(r),u=/(.*)#(\w{4})=(.*)/,c=s.match(u),d=Math.floor(t(a[1])),p=Math.floor(t(a[2]));let f=c[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(f)){const t=e.split("/");t.pop(),t.push(f),f=t.join("/")}const h={start:d,end:p,url:f},m=c[2].split(""),g=c[3].split(",");for(let t=0;t<m.length;t++)h[m[t]]=g[t];i.push(h)}return i}(e.vtt);return n.controls.add({name:"vtt-thumbnail",position:"top",index:20,style:e.style||{},mounted(t){o(t,"art-control-thumbnails"),n.on("setBar",async(e,o,u)=>{const c="played"===e&&u&&l;if("hover"===e||c){const e=s.clientWidth*o,u=o*n.duration;i(t,"display","flex");const d=a.find(t=>u>=t.start&&u<=t.end);if(!d)return i(t,"display","none");e>0&&e<s.clientWidth?function(t,e,n){i(t,"backgroundImage",`url(${e.url})`),i(t,"height",`${e.h}px`),i(t,"width",`${e.w}px`),i(t,"backgroundPosition",`-${e.x}px -${e.y}px`),n<=e.w/2?i(t,"left",0):n>s.clientWidth-e.w/2?i(t,"left",s.clientWidth-e.w+"px"):i(t,"left",n-e.w/2+"px")}(t,d,e):l||i(t,"display","none"),c&&(clearTimeout(r),r=setTimeout(()=>{i(t,"display","none")},500))}})}}),{name:"artplayerPluginVttThumbnail"}}}});
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginVttThumbnail=e(),define(function(){return t.artplayerPluginVttThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginVttThumbnail=e()}(this,function(){"use strict";function t(t){const e=t.split("."),n=e[0].split(":")||[],o=(l=e[1]||"0",i=3,r="0",l.length>i?String(l):((i-=l.length)>r.length&&(r+=r.repeat(i/r.length)),String(l)+r.slice(0,i)));var l,i,r;const s=Number(o)/1e3;return 3600*Number(n[n.length-3]||0)+60*Number(n[n.length-2]||0)+Number(n[n.length-1]||0)+s}function e({lifetime:t,thumbnails:e,progress:n,duration:o,setStyle:l,isMobile:i}){let r=null,s=0;function c(e,n,o){t.closed||l(e,n,o)}return t.own(()=>{if(s++,null!==r){const t=r;r=null,clearTimeout(t)}}),l=>async(u,a,d)=>{if(t.closed)return;const f="played"===u&&d&&i;if("hover"!==u&&!f)return;const h=n.clientWidth*a,p=a*o();if(c(l,"display","flex"),t.closed)return;const m=e.find(t=>p>=t.start&&p<=t.end);if(!m)return c(l,"display","none");if(h>0&&h<n.clientWidth?function(t,e,o){c(t,"backgroundImage",`url(${e.url})`),c(t,"height",`${e.h}px`),c(t,"width",`${e.w}px`),c(t,"backgroundPosition",`-${e.x}px -${e.y}px`),o<=e.w/2?c(t,"left",0):o>n.clientWidth-e.w/2?c(t,"left",n.clientWidth-e.w+"px"):c(t,"left",o-e.w/2+"px")}(l,m,h):i||c(l,"display","none"),f&&!t.closed){const e=++s;if(null!==r&&clearTimeout(r),t.closed)return;const n=setTimeout(()=>{e!==s||t.closed||(r=null,c(l,"display","none"))},500);t.closed||e!==s?clearTimeout(n):r=n}}}return function(n){return async o=>{const{constructor:{utils:{setStyle:l,isMobile:i,addClass:r}},template:{$progress:s}}=o,c=function(t){let e=Boolean(t.isDestroy);const n=new Set;let o;const l=new Promise(t=>{o=t}),i={get closed(){return e},own:t=>(e?r(t):n.add(t),()=>n.delete(t)),listen(n,o){e||(i.own(()=>t.off(n,o)),t.on(n,o))},wait:t=>Promise.race([t,l]),dispose(){if(e)return;e=!0,o();const t=[...n].reverse();n.clear();for(const e of t)r(e)}};function r(t){try{t()}catch(e){console.warn("Failed to clean up VTT thumbnails:",e)}}try{i.listen("destroy",i.dispose)}catch(s){throw i.dispose(),s}return i}(o),u={name:"artplayerPluginVttThumbnail"};try{if(c.closed)return u;const a=n.vtt,d=await async function(t="",e){if(e.closed)return;const n="function"===typeof AbortController?new AbortController:null,o=e.own(()=>n?.abort());try{if(e.closed)return;const o=await e.wait(n?fetch(t,{signal:n.signal}):fetch(t));if(e.closed)return;if(!1===o.ok)throw new Error(`Failed to fetch VTT thumbnails: HTTP ${o.status}`);if(e.closed)return;return await e.wait(o.text())}finally{o()}}(a,c);if(c.closed)return u;const f=function(e,n=""){const o=e.split(/[\n\r]/g).filter(t=>t.trim()),l=[];for(let i=1;i<o.length;i+=2){const e=o[i],r=o[i+1];if(!r.trim())continue;const s=/((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?) ?--> ?((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?)/,c=e.match(s),u=/(.*)#(\w{4})=(.*)/,a=r.match(u),d=Math.floor(t(c[1])),f=Math.floor(t(c[2]));let h=a[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(h)){const t=n.split("/");t.pop(),t.push(h),h=t.join("/")}const p={start:d,end:f,url:h},m=a[2].split(""),y=a[3].split(",");for(let t=0;t<m.length;t++)p[m[t]]=y[t];l.push(p)}return l}(d,a),h=e({lifetime:c,thumbnails:f,progress:s,duration:()=>o.duration,setStyle:l,isMobile:i}),p=n.style||{};return c.closed||o.controls.add({name:"vtt-thumbnail",position:"top",index:20,style:p,mounted(t){c.own(()=>{o.controls["vtt-thumbnail"]===t&&o.controls.remove("vtt-thumbnail")}),c.closed||(r(t,"art-control-thumbnails"),c.listen("setBar",h(t)))}}),u}catch(a){throw c.dispose(),a}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginVttThumbnail=e(),define(function(){return t.artplayerPluginVttThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginVttThumbnail=e()}(this,function(){"use strict";var t=(t,e,n)=>new Promise((l,i)=>{var o=t=>{try{s(n.next(t))}catch(e){i(e)}},r=t=>{try{s(n.throw(t))}catch(e){i(e)}},s=t=>t.done?l(t.value):Promise.resolve(t.value).then(o,r);s((n=n.apply(t,e)).next())});function e(t){const e=t.split("."),n=e[0].split(":")||[],l=(i=e[1]||"0",o=3,r="0",i.length>o?String(i):((o-=i.length)>r.length&&(r+=r.repeat(o/r.length)),String(i)+r.slice(0,o)));var i,o,r;const s=Number(l)/1e3;return 3600*Number(n[n.length-3]||0)+60*Number(n[n.length-2]||0)+Number(n[n.length-1]||0)+s}return function(n){return l=>t(null,null,function*(){const{constructor:{utils:{setStyle:i,isMobile:o,addClass:r}},template:{$progress:s}}=l;let u=null;const a=yield function(n=""){return t(this,null,function*(){const t=(yield(yield fetch(n)).text()).split(/[\n\r]/g).filter(t=>t.trim()),l=[];for(let i=1;i<t.length;i+=2){const o=t[i],r=t[i+1];if(!r.trim())continue;const s=/((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?) ?--> ?((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?)/,u=o.match(s),a=/(.*)#(\w{4})=(.*)/,c=r.match(a),d=Math.floor(e(u[1])),h=Math.floor(e(u[2]));let p=c[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(p)){const t=n.split("/");t.pop(),t.push(p),p=t.join("/")}const f={start:d,end:h,url:p},m=c[2].split(""),g=c[3].split(",");for(let t=0;t<m.length;t++)f[m[t]]=g[t];l.push(f)}return l})}(n.vtt);return l.controls.add({name:"vtt-thumbnail",position:"top",index:20,style:n.style||{},mounted(e){r(e,"art-control-thumbnails"),l.on("setBar",(n,r,c)=>t(null,null,function*(){const t="played"===n&&c&&o;if("hover"===n||t){const n=s.clientWidth*r,c=r*l.duration;i(e,"display","flex");const d=a.find(t=>c>=t.start&&c<=t.end);if(!d)return i(e,"display","none");n>0&&n<s.clientWidth?function(t,e,n){i(t,"backgroundImage",`url(${e.url})`),i(t,"height",`${e.h}px`),i(t,"width",`${e.w}px`),i(t,"backgroundPosition",`-${e.x}px -${e.y}px`),n<=e.w/2?i(t,"left",0):n>s.clientWidth-e.w/2?i(t,"left",s.clientWidth-e.w+"px"):i(t,"left",n-e.w/2+"px")}(e,d,n):o||i(e,"display","none"),t&&(clearTimeout(u),u=setTimeout(()=>{i(e,"display","none")},500))}}))}}),{name:"artplayerPluginVttThumbnail"}})}});
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginVttThumbnail=e(),define(function(){return t.artplayerPluginVttThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginVttThumbnail=e()}(this,function(){"use strict";var t=(t,e,n)=>new Promise((o,l)=>{var i=t=>{try{s(n.next(t))}catch(e){l(e)}},r=t=>{try{s(n.throw(t))}catch(e){l(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(i,r);s((n=n.apply(t,e)).next())});function e(t){const e=t.split("."),n=e[0].split(":")||[],o=(l=e[1]||"0",i=3,r="0",l.length>i?String(l):((i-=l.length)>r.length&&(r+=r.repeat(i/r.length)),String(l)+r.slice(0,i)));var l,i,r;const s=Number(o)/1e3;return 3600*Number(n[n.length-3]||0)+60*Number(n[n.length-2]||0)+Number(n[n.length-1]||0)+s}function n({lifetime:e,thumbnails:n,progress:o,duration:l,setStyle:i,isMobile:r}){let s=null,u=0;function c(t,n,o){e.closed||i(t,n,o)}return e.own(()=>{if(u++,null!==s){const t=s;s=null,clearTimeout(t)}}),i=>(a,d,f)=>t(null,null,function*(){if(e.closed)return;const t="played"===a&&f&&r;if("hover"!==a&&!t)return;const h=o.clientWidth*d,p=d*l();if(c(i,"display","flex"),e.closed)return;const m=n.find(t=>p>=t.start&&p<=t.end);if(!m)return c(i,"display","none");if(h>0&&h<o.clientWidth?function(t,e,n){c(t,"backgroundImage",`url(${e.url})`),c(t,"height",`${e.h}px`),c(t,"width",`${e.w}px`),c(t,"backgroundPosition",`-${e.x}px -${e.y}px`),n<=e.w/2?c(t,"left",0):n>o.clientWidth-e.w/2?c(t,"left",o.clientWidth-e.w+"px"):c(t,"left",n-e.w/2+"px")}(i,m,h):r||c(i,"display","none"),t&&!e.closed){const t=++u;if(null!==s&&clearTimeout(s),e.closed)return;const n=setTimeout(()=>{t!==u||e.closed||(s=null,c(i,"display","none"))},500);e.closed||t!==u?clearTimeout(n):s=n}})}return function(o){return l=>t(null,null,function*(){const{constructor:{utils:{setStyle:i,isMobile:r,addClass:s}},template:{$progress:u}}=l,c=function(t){let e=Boolean(t.isDestroy);const n=new Set;let o;const l=new Promise(t=>{o=t}),i={get closed(){return e},own:t=>(e?r(t):n.add(t),()=>n.delete(t)),listen(n,o){e||(i.own(()=>t.off(n,o)),t.on(n,o))},wait:t=>Promise.race([t,l]),dispose(){if(e)return;e=!0,o();const t=[...n].reverse();n.clear();for(const e of t)r(e)}};function r(t){try{t()}catch(e){console.warn("Failed to clean up VTT thumbnails:",e)}}try{i.listen("destroy",i.dispose)}catch(s){throw i.dispose(),s}return i}(l),a={name:"artplayerPluginVttThumbnail"};try{if(c.closed)return a;const d=o.vtt,f=yield function(e="",n){return t(this,null,function*(){if(n.closed)return;const t="function"===typeof AbortController?new AbortController:null,o=n.own(()=>null==t?void 0:t.abort());try{if(n.closed)return;const o=yield n.wait(t?fetch(e,{signal:t.signal}):fetch(e));if(n.closed)return;if(!1===o.ok)throw new Error(`Failed to fetch VTT thumbnails: HTTP ${o.status}`);if(n.closed)return;return yield n.wait(o.text())}finally{o()}})}(d,c);if(c.closed)return a;const h=function(t,n=""){const o=t.split(/[\n\r]/g).filter(t=>t.trim()),l=[];for(let i=1;i<o.length;i+=2){const t=o[i],r=o[i+1];if(!r.trim())continue;const s=/((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?) ?--> ?((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?)/,u=t.match(s),c=/(.*)#(\w{4})=(.*)/,a=r.match(c),d=Math.floor(e(u[1])),f=Math.floor(e(u[2]));let h=a[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(h)){const t=n.split("/");t.pop(),t.push(h),h=t.join("/")}const p={start:d,end:f,url:h},m=a[2].split(""),y=a[3].split(",");for(let e=0;e<m.length;e++)p[m[e]]=y[e];l.push(p)}return l}(f,d),p=n({lifetime:c,thumbnails:h,progress:u,duration:()=>l.duration,setStyle:i,isMobile:r}),m=o.style||{};return c.closed||l.controls.add({name:"vtt-thumbnail",position:"top",index:20,style:m,mounted(t){c.own(()=>{l.controls["vtt-thumbnail"]===t&&l.controls.remove("vtt-thumbnail")}),c.closed||(s(t,"art-control-thumbnails"),c.listen("setBar",p(t)))}}),a}catch(d){throw c.dispose(),d}})}});
@@ -4,6 +4,59 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
function createLifetime(art) {
let closed = Boolean(art.isDestroy);
const cleanups = /* @__PURE__ */ new Set();
let cancel;
const cancelled = new Promise((resolve) => {
cancel = resolve;
});
const lifetime = {
get closed() {
return closed;
},
own(cleanup) {
if (closed)
run(cleanup);
else
cleanups.add(cleanup);
return () => cleanups.delete(cleanup);
},
listen(name, callback) {
if (closed)
return;
lifetime.own(() => art.off(name, callback));
art.on(name, callback);
},
wait(value) {
return Promise.race([value, cancelled]);
},
dispose() {
if (closed)
return;
closed = true;
cancel();
const pending = [...cleanups].reverse();
cleanups.clear();
for (const cleanup of pending)
run(cleanup);
}
};
function run(cleanup) {
try {
cleanup();
} catch (error) {
console.warn("Failed to clean up VTT thumbnails:", error);
}
}
try {
lifetime.listen("destroy", lifetime.dispose);
} catch (error) {
lifetime.dispose();
throw error;
}
return lifetime;
}
function padEnd(str, targetLength, padString) {
if (str.length > targetLength) {
return String(str);
@@ -25,8 +78,7 @@ function t2d(time) {
const s = Number(left[left.length - 1] || 0);
return h + m + s + ms;
}
async function getVttArray(vttUrl = "") {
const vttString = await (await fetch(vttUrl)).text();
function parseVtt(vttString, vttUrl = "") {
const lines = vttString.split(/[\n\r]/g).filter((item) => item.trim());
const vttArray = [];
for (let i = 1; i < lines.length; i += 2) {
@@ -58,65 +110,128 @@ async function getVttArray(vttUrl = "") {
}
return vttArray;
}
function createPreview({ lifetime, thumbnails, progress, duration, setStyle, isMobile }) {
let timer = null;
let generation = 0;
lifetime.own(() => {
generation++;
if (timer !== null) {
const previous = timer;
timer = null;
clearTimeout(previous);
}
});
function style(control, key, value) {
if (!lifetime.closed)
setStyle(control, key, value);
}
function show(control, cue, width) {
style(control, "backgroundImage", `url(${cue.url})`);
style(control, "height", `${cue.h}px`);
style(control, "width", `${cue.w}px`);
style(control, "backgroundPosition", `-${cue.x}px -${cue.y}px`);
if (width <= cue.w / 2)
style(control, "left", 0);
else if (width > progress.clientWidth - cue.w / 2)
style(control, "left", `${progress.clientWidth - cue.w}px`);
else
style(control, "left", `${width - cue.w / 2}px`);
}
return (control) => async (type, percentage, event) => {
if (lifetime.closed)
return;
const dragging = type === "played" && event && isMobile;
if (type !== "hover" && !dragging)
return;
const width = progress.clientWidth * percentage;
const second = percentage * duration();
style(control, "display", "flex");
if (lifetime.closed)
return;
const cue = thumbnails.find((item) => second >= item.start && second <= item.end);
if (!cue)
return style(control, "display", "none");
if (width > 0 && width < progress.clientWidth)
show(control, cue, width);
else if (!isMobile)
style(control, "display", "none");
if (dragging && !lifetime.closed) {
const current = ++generation;
if (timer !== null)
clearTimeout(timer);
if (lifetime.closed)
return;
const id = setTimeout(() => {
if (current !== generation || lifetime.closed)
return;
timer = null;
style(control, "display", "none");
}, 500);
if (lifetime.closed || current !== generation)
clearTimeout(id);
else
timer = id;
}
};
}
async function requestVtt(url = "", lifetime) {
if (lifetime.closed)
return;
const controller = typeof AbortController === "function" ? new AbortController() : null;
const release = lifetime.own(() => controller?.abort());
try {
if (lifetime.closed)
return;
const response = await lifetime.wait(controller ? fetch(url, { signal: controller.signal }) : fetch(url));
if (lifetime.closed)
return;
if (response.ok === false)
throw new Error(`Failed to fetch VTT thumbnails: HTTP ${response.status}`);
if (lifetime.closed)
return;
return await lifetime.wait(response.text());
} finally {
release();
}
}
function artplayerPluginVttThumbnail(option) {
return async (art) => {
const {
constructor: {
utils: { setStyle, isMobile, addClass }
},
template: { $progress }
} = art;
let timer = null;
const thumbnails = await getVttArray(option.vtt);
function showThumbnails($control, find, width) {
setStyle($control, "backgroundImage", `url(${find.url})`);
setStyle($control, "height", `${find.h}px`);
setStyle($control, "width", `${find.w}px`);
setStyle($control, "backgroundPosition", `-${find.x}px -${find.y}px`);
if (width <= find.w / 2) {
setStyle($control, "left", 0);
} else if (width > $progress.clientWidth - find.w / 2) {
setStyle($control, "left", `${$progress.clientWidth - find.w}px`);
} else {
setStyle($control, "left", `${width - find.w / 2}px`);
}
const { constructor: { utils: { setStyle, isMobile, addClass } }, template: { $progress } } = art;
const lifetime = createLifetime(art);
const result = { name: "artplayerPluginVttThumbnail" };
try {
if (lifetime.closed)
return result;
const url = option.vtt;
const text = await requestVtt(url, lifetime);
if (lifetime.closed)
return result;
const thumbnails = parseVtt(text, url);
const preview = createPreview({ lifetime, thumbnails, progress: $progress, duration: () => art.duration, setStyle, isMobile });
const style = option.style || {};
if (lifetime.closed)
return result;
art.controls.add({
name: "vtt-thumbnail",
position: "top",
index: 20,
style,
mounted(control) {
lifetime.own(() => {
if (art.controls["vtt-thumbnail"] === control)
art.controls.remove("vtt-thumbnail");
});
if (lifetime.closed)
return;
addClass(control, "art-control-thumbnails");
lifetime.listen("setBar", preview(control));
}
});
return result;
} catch (error) {
lifetime.dispose();
throw error;
}
art.controls.add({
name: "vtt-thumbnail",
position: "top",
index: 20,
style: option.style || {},
mounted($control) {
addClass($control, "art-control-thumbnails");
art.on("setBar", async (type, percentage, event) => {
const isMobileDroging = type === "played" && event && isMobile;
if (type === "hover" || isMobileDroging) {
const width = $progress.clientWidth * percentage;
const second = percentage * art.duration;
setStyle($control, "display", "flex");
const find = thumbnails.find((item) => second >= item.start && second <= item.end);
if (!find)
return setStyle($control, "display", "none");
if (width > 0 && width < $progress.clientWidth) {
showThumbnails($control, find, width);
} else {
if (!isMobile) {
setStyle($control, "display", "none");
}
}
if (isMobileDroging) {
clearTimeout(timer);
timer = setTimeout(() => {
setStyle($control, "display", "none");
}, 500);
}
}
});
}
});
return {
name: "artplayerPluginVttThumbnail"
};
};
}
export {
@@ -1,70 +1,6 @@
function padEnd(str, targetLength, padString) {
if (str.length > targetLength) {
return String(str)
}
else {
targetLength = targetLength - str.length
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length)
}
return String(str) + padString.slice(0, targetLength)
}
}
function t2d(time) {
const arr = time.split('.')
const left = arr[0].split(':') || []
const right = padEnd(arr[1] || '0', 3, '0')
const ms = Number(right) / 1000
const h = Number(left[left.length - 3] || 0) * 3600
const m = Number(left[left.length - 2] || 0) * 60
const s = Number(left[left.length - 1] || 0)
return h + m + s + ms
}
import parseVtt from './parseVtt'
// Preserve the historical source helper while registration owns its requests.
export default async function getVttArray(vttUrl = '') {
const vttString = await (await fetch(vttUrl)).text()
const lines = vttString.split(/[\n\r]/g).filter(item => item.trim())
const vttArray = []
for (let i = 1; i < lines.length; i += 2) {
const time = lines[i]
const text = lines[i + 1]
if (!text.trim())
continue
const timeReg
= /((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?) ?--> ?((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?)/
const timeMatch = time.match(timeReg)
const textReg = /(.*)#(\w{4})=(.*)/
const textMatch = text.match(textReg)
const start = Math.floor(t2d(timeMatch[1]))
const end = Math.floor(t2d(timeMatch[2]))
let url = textMatch[1]
const isAbsoluteUrl = /^\/|(?:https?|ftp|file):\/\//i.test(url)
if (!isAbsoluteUrl) {
const urlArr = vttUrl.split('/')
urlArr.pop()
urlArr.push(url)
url = urlArr.join('/')
}
const result = { start, end, url }
const keys = textMatch[2].split('')
const values = textMatch[3].split(',')
for (let j = 0; j < keys.length; j++) {
result[keys[j]] = values[j]
}
vttArray.push(result)
}
return vttArray
return parseVtt(await (await fetch(vttUrl)).text(), vttUrl)
}
@@ -1,74 +1,46 @@
import getVttArray from './getVttArray'
import createLifetime from './lifetime'
import parseVtt from './parseVtt'
import createPreview from './preview'
import requestVtt from './request'
export default function artplayerPluginVttThumbnail(option) {
return async (art) => {
const {
constructor: {
utils: { setStyle, isMobile, addClass },
},
template: { $progress },
} = art
let timer = null
const thumbnails = await getVttArray(option.vtt)
function showThumbnails($control, find, width) {
setStyle($control, 'backgroundImage', `url(${find.url})`)
setStyle($control, 'height', `${find.h}px`)
setStyle($control, 'width', `${find.w}px`)
setStyle($control, 'backgroundPosition', `-${find.x}px -${find.y}px`)
if (width <= find.w / 2) {
setStyle($control, 'left', 0)
}
else if (width > $progress.clientWidth - find.w / 2) {
setStyle($control, 'left', `${$progress.clientWidth - find.w}px`)
}
else {
setStyle($control, 'left', `${width - find.w / 2}px`)
}
const { constructor: { utils: { setStyle, isMobile, addClass } }, template: { $progress } } = art
const lifetime = createLifetime(art)
const result = { name: 'artplayerPluginVttThumbnail' }
try {
if (lifetime.closed)
return result
const url = option.vtt
const text = await requestVtt(url, lifetime)
if (lifetime.closed)
return result
const thumbnails = parseVtt(text, url)
const preview = createPreview({ lifetime, thumbnails, progress: $progress, duration: () => art.duration, setStyle, isMobile })
const style = option.style || {}
if (lifetime.closed)
return result
art.controls.add({
name: 'vtt-thumbnail',
position: 'top',
index: 20,
style,
mounted(control) {
lifetime.own(() => {
if (art.controls['vtt-thumbnail'] === control)
art.controls.remove('vtt-thumbnail')
})
if (lifetime.closed)
return
addClass(control, 'art-control-thumbnails')
lifetime.listen('setBar', preview(control))
},
})
return result
}
art.controls.add({
name: 'vtt-thumbnail',
position: 'top',
index: 20,
style: option.style || {},
mounted($control) {
addClass($control, 'art-control-thumbnails')
art.on('setBar', async (type, percentage, event) => {
const isMobileDroging = type === 'played' && event && isMobile
if (type === 'hover' || isMobileDroging) {
const width = $progress.clientWidth * percentage
const second = percentage * art.duration
setStyle($control, 'display', 'flex')
const find = thumbnails.find(item => second >= item.start && second <= item.end)
if (!find)
return setStyle($control, 'display', 'none')
if (width > 0 && width < $progress.clientWidth) {
showThumbnails($control, find, width)
}
else {
if (!isMobile) {
setStyle($control, 'display', 'none')
}
}
if (isMobileDroging) {
clearTimeout(timer)
timer = setTimeout(() => {
setStyle($control, 'display', 'none')
}, 500)
}
}
})
},
})
return {
name: 'artplayerPluginVttThumbnail',
catch (error) {
lifetime.dispose()
throw error
}
}
}
@@ -0,0 +1,51 @@
export default function createLifetime(art) {
let closed = Boolean(art.isDestroy)
const cleanups = new Set()
let cancel
const cancelled = new Promise((resolve) => {
cancel = resolve
})
const lifetime = {
get closed() { return closed },
own(cleanup) {
if (closed)
run(cleanup)
else
cleanups.add(cleanup)
return () => cleanups.delete(cleanup)
},
listen(name, callback) {
if (closed)
return
lifetime.own(() => art.off(name, callback))
art.on(name, callback)
},
wait(value) { return Promise.race([value, cancelled]) },
dispose() {
if (closed)
return
closed = true
cancel()
const pending = [...cleanups].reverse()
cleanups.clear()
for (const cleanup of pending)
run(cleanup)
},
}
function run(cleanup) {
try {
cleanup()
}
catch (error) {
console.warn('Failed to clean up VTT thumbnails:', error)
}
}
try {
lifetime.listen('destroy', lifetime.dispose)
}
catch (error) {
lifetime.dispose()
throw error
}
return lifetime
}
@@ -0,0 +1,69 @@
function padEnd(str, targetLength, padString) {
if (str.length > targetLength) {
return String(str)
}
else {
targetLength = targetLength - str.length
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length)
}
return String(str) + padString.slice(0, targetLength)
}
}
function t2d(time) {
const arr = time.split('.')
const left = arr[0].split(':') || []
const right = padEnd(arr[1] || '0', 3, '0')
const ms = Number(right) / 1000
const h = Number(left[left.length - 3] || 0) * 3600
const m = Number(left[left.length - 2] || 0) * 60
const s = Number(left[left.length - 1] || 0)
return h + m + s + ms
}
export default function parseVtt(vttString, vttUrl = '') {
const lines = vttString.split(/[\n\r]/g).filter(item => item.trim())
const vttArray = []
for (let i = 1; i < lines.length; i += 2) {
const time = lines[i]
const text = lines[i + 1]
if (!text.trim())
continue
const timeReg
= /((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?) ?--> ?((?:\d{2}:)?(?:\d{2}:)?\d{2}(?:.\d{3})?)/
const timeMatch = time.match(timeReg)
const textReg = /(.*)#(\w{4})=(.*)/
const textMatch = text.match(textReg)
const start = Math.floor(t2d(timeMatch[1]))
const end = Math.floor(t2d(timeMatch[2]))
let url = textMatch[1]
const isAbsoluteUrl = /^\/|(?:https?|ftp|file):\/\//i.test(url)
if (!isAbsoluteUrl) {
const urlArr = vttUrl.split('/')
urlArr.pop()
urlArr.push(url)
url = urlArr.join('/')
}
const result = { start, end, url }
const keys = textMatch[2].split('')
const values = textMatch[3].split(',')
for (let j = 0; j < keys.length; j++) {
result[keys[j]] = values[j]
}
vttArray.push(result)
}
return vttArray
}
@@ -0,0 +1,64 @@
export default function createPreview({ lifetime, thumbnails, progress, duration, setStyle, isMobile }) {
let timer = null
let generation = 0
lifetime.own(() => {
generation++
if (timer !== null) {
const previous = timer
timer = null
clearTimeout(previous)
}
})
function style(control, key, value) {
if (!lifetime.closed)
setStyle(control, key, value)
}
function show(control, cue, width) {
style(control, 'backgroundImage', `url(${cue.url})`)
style(control, 'height', `${cue.h}px`)
style(control, 'width', `${cue.w}px`)
style(control, 'backgroundPosition', `-${cue.x}px -${cue.y}px`)
if (width <= cue.w / 2)
style(control, 'left', 0)
else if (width > progress.clientWidth - cue.w / 2)
style(control, 'left', `${progress.clientWidth - cue.w}px`)
else
style(control, 'left', `${width - cue.w / 2}px`)
}
return control => async (type, percentage, event) => {
if (lifetime.closed)
return
const dragging = type === 'played' && event && isMobile
if (type !== 'hover' && !dragging)
return
const width = progress.clientWidth * percentage
const second = percentage * duration()
style(control, 'display', 'flex')
if (lifetime.closed)
return
const cue = thumbnails.find(item => second >= item.start && second <= item.end)
if (!cue)
return style(control, 'display', 'none')
if (width > 0 && width < progress.clientWidth)
show(control, cue, width)
else if (!isMobile)
style(control, 'display', 'none')
if (dragging && !lifetime.closed) {
const current = ++generation
if (timer !== null)
clearTimeout(timer)
if (lifetime.closed)
return
const id = setTimeout(() => {
if (current !== generation || lifetime.closed)
return
timer = null
style(control, 'display', 'none')
}, 500)
if (lifetime.closed || current !== generation)
clearTimeout(id)
else
timer = id
}
}
}
@@ -0,0 +1,21 @@
export default async function requestVtt(url = '', lifetime) {
if (lifetime.closed)
return
const controller = typeof AbortController === 'function' ? new AbortController() : null
const release = lifetime.own(() => controller?.abort())
try {
if (lifetime.closed)
return
const response = await lifetime.wait(controller ? fetch(url, { signal: controller.signal }) : fetch(url))
if (lifetime.closed)
return
if (response.ok === false)
throw new Error(`Failed to fetch VTT thumbnails: HTTP ${response.status}`)
if (lifetime.closed)
return
return await lifetime.wait(response.text())
}
finally {
release()
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,32 @@
# PKG-VTT-THUMB-03 资源所有权检查点
将注册、请求、纯解析、预览/计时器和生命周期拆开;保留旧 getVttArray 源路径的包装函数。
这是 03 的源码检查点,**任务继续 doing**:解析错误/输入规则及更多资源边界尚需完成,
严格 TS/公开声明归 04,完整核心设备/安装/demo/编辑器验收归 05/06。
## 已实现修复
- 请求前订阅 destroy;原生 AbortController 可用时取消 fetch/body。即使 fetch 忽略 signal
或环境没有 AbortController,销毁也结算注册 Promise,迟到返回不安装控件,迟到拒绝被观察。
- setBar、移动隐藏计时器和控件均有独立清理。计时器 ID 0 有效,旧定时回调有代次检查,
在 setTimeout 返回之前重入销毁时仍清掉迟到的 handle。
- 每次样式写入检查关闭状态;销毁中重入不会再排计时器或后续写入。
- 安装失败保留原错误并回滚已安装监听器/控件。清理失败只记录警告,继续其它清理。
控件只在实时 alias 仍为本次 mounted 元素时移除,不误删别人替换的控件。
- HTTP 失败现在明确拒绝,不再解析错误响应体。这是单独复现的缺陷修正。
## 保持的边界
工厂/结果名、异步注册、URL/style 读取顺序、style 原对象、控件名/CSS/index、向下取整
时间区间及首个命中、xywh/URL 几何、500ms 移动隐藏均保留。restart 不触发新增下载。
源码抽出的 parseVtt 保持原算法;非法 cue/坐标、NOTE/ID 等仍不能称已修复。
旧版 CJS `.default` 与同步声明冲突仍待 04,不能以当前直接函数入口代替所有旧消费形式。
## 验证与交接
21 项候选回归在冻结旧版 4 通过/17 失败,新源码通过;保留 01/02 历史测试。
真实浏览器检查覆盖 published/candidate 核心、鼠标悬停、实际 sprite 像素和原生请求取消。
准确构建、源码/产物/浏览器与 CI 结果见 [验证记录](../baselines/vtt-thumbnail-resources.json)。
包内 ARCHITECTURE.md 说明实际文件地图和资源数据流。无依赖、版本或公开声明变更。
按仓库命令生成 dist/docs 编译产物,不手改。回退本检查点提交可回到 02 后生产实现;
历史对照仍可重跑。独立本地提交,不推送、不发布。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
状态:todo 94 / doing 12 / blocked 0 / done 116 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 93 / doing 13 / blocked 0 / done 116 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -184,7 +184,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| PKG-VTT-THUMB-01 | artplayer-plugin-vtt-thumbnail<br>核对包契约与历史用法 | BASE-05 | vtt/style、异步返回、预览控件名与样式契约 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | M | done |
| PKG-VTT-THUMB-02 | artplayer-plugin-vtt-thumbnail<br>建立特有行为与错误测试 | PKG-VTT-THUMB-01, ENG-03, ENG-05 | VTT 解析、xywh、相对 URL、时间边界、请求失败和定位 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | M | done |
| PKG-VTT-THUMB-03 | artplayer-plugin-vtt-thumbnail<br>整理内部职责与资源 | PKG-VTT-THUMB-02, CORE-09, CORE-19 | 纯解析/区间查找与 DOM、请求/定时器生命周期分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | M | todo |
| PKG-VTT-THUMB-03 | artplayer-plugin-vtt-thumbnail<br>整理内部职责与资源 | PKG-VTT-THUMB-02, CORE-09, CORE-19 | 纯解析/区间查找与 DOM、请求/定时器生命周期分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | M | doing |
| PKG-VTT-THUMB-04 | artplayer-plugin-vtt-thumbnail<br>迁移自有源码和公开类型 | PKG-VTT-THUMB-03, ENG-04, ENG-06, CORE-07 | cue/rect/result 和参数可选性保持兼容 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | M | todo |
| PKG-VTT-THUMB-05 | artplayer-plugin-vtt-thumbnail<br>验证新旧核心和组合 | PKG-VTT-THUMB-04, CORE-22 | 鼠标和移动进度、全屏、切源、chapter 组合 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | M | todo |
| PKG-VTT-THUMB-06 | artplayer-plugin-vtt-thumbnail<br>验证分发并同步文档 | PKG-VTT-THUMB-05, ENG-07 | vtt.thumbnail.js、VTT/图片资源解析和产物验证 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | M | todo |
@@ -499,6 +499,7 @@
- PKG-AUTO-THUMB-03: [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md) [记录](baselines/auto-thumbnail-lifecycle-checkpoint.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-hidden-renderer.md) [记录](baselines/auto-thumbnail-hidden-renderer.json) [记录](baselines/auto-thumbnail-timeline-media.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-frame-presentation.md) [记录](baselines/auto-thumbnail-frame-presentation.json)
- PKG-VTT-THUMB-01: [记录](baselines/vtt-thumbnail-release.json) [记录](baselines/vtt-thumbnail-contract.md) [记录](changes/2026-09-13-PKG-VTT-THUMB-01-contract.md)
- PKG-VTT-THUMB-02: [记录](baselines/vtt-thumbnail-failures.json) [记录](changes/2026-09-13-PKG-VTT-THUMB-02-failures.md)
- PKG-VTT-THUMB-03: [记录](changes/2026-09-13-PKG-VTT-THUMB-03-resources.md) [记录](baselines/vtt-thumbnail-resources.json)
- PKG-HLS-01: [记录](baselines/hls-control-contract.md) [记录](baselines/hls-control-release.json) [记录](changes/2026-09-11-PKG-HLS-01-contract.md)
- PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md)
- PKG-HLS-03: [记录](changes/2026-09-12-PKG-HLS-03-modules.md) [记录](baselines/hls-modules-validation.json) [记录](hls-validation.md)
+12
View File
@@ -1,5 +1,17 @@
# 进度与证据
## PKG-VTT-THUMB-03 请求与预览资源检查点(doing)
生产入口拆为注册、生命周期、请求、预览和纯解析,保留旧源码 helper。已修复销毁后
安装/样式写入、挂起请求取消、移动计时器和部分安装回滚;HTTP 错误明确拒绝。
21 项候选测试在旧版 4 通过/17 失败,源码/main/legacy 各 21 通过;原生旧版 6 项均复现失败,
候选三种入口各 12 项通过(两种核心 × 三浏览器 × 两场景),实际 ESM 也通过。
完整 CI 2038 项通过。构建产物与包内架构同步,未改变声明/版本;解析健壮性、严格 TS、
完整设备/核心组合与安装/demo 验收仍待。见[变更](changes/2026-09-13-PKG-VTT-THUMB-03-resources.md)
和[验证](baselines/vtt-thumbnail-resources.json)。222 项:116 done、13 doing、93 todo。
独立本地检查点提交,不推送、不发布。
## PKG-VTT-THUMB-02 特有失败与生命周期复现完成
108 项新历史测试复现销毁后安装/计时器写入、请求拒绝、HTTP 状态忽略、非法 VTT/坐标、
+8 -2
View File
@@ -4151,7 +4151,10 @@
"refactor/baselines/vtt-thumbnail-contract.md",
"refactor/scripts/vtt-thumbnail-contract.test.mjs",
"test/vtt-thumbnail.test.js",
"refactor/baselines/vtt-thumbnail-failures.json"
"refactor/baselines/vtt-thumbnail-failures.json",
"test/vtt-thumbnail-lifecycle.test.js",
"test/browser/vtt-thumbnail-lifecycle.spec.js",
"refactor/baselines/vtt-thumbnail-resources.json"
],
"compatibleResolution": "Preserve valid cue selection and geometry; test malformed data and standard VTT separately from old compiler defects.",
"closureCriteria": "Parser fixtures include malformed, empty, real demo VTT, fractional and shared bounds, relative URLs and historical accepted formats."
@@ -4171,7 +4174,10 @@
"refactor/baselines/vtt-thumbnail-contract.md",
"refactor/scripts/vtt-thumbnail-contract.test.mjs",
"test/vtt-thumbnail.test.js",
"refactor/baselines/vtt-thumbnail-failures.json"
"refactor/baselines/vtt-thumbnail-failures.json",
"test/vtt-thumbnail-lifecycle.test.js",
"test/browser/vtt-thumbnail-lifecycle.spec.js",
"refactor/baselines/vtt-thumbnail-resources.json"
],
"compatibleResolution": "Own requests, listeners, timers and controls per registration without stale writes or cross-instance cleanup.",
"closureCriteria": "Deferred request, destroy, failure, timer, repeated registration and native core scenarios demonstrate cleanup."
+5 -2
View File
@@ -1897,11 +1897,14 @@
"CORE-09",
"CORE-19"
],
"status": "todo",
"status": "doing",
"risk": "M",
"deliverable": "纯解析/区间查找与 DOM、请求/定时器生命周期分离",
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
"evidence": []
"evidence": [
"changes/2026-09-13-PKG-VTT-THUMB-03-resources.md",
"baselines/vtt-thumbnail-resources.json"
]
},
{
"id": "PKG-VTT-THUMB-04",
@@ -0,0 +1,91 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { vttThumbnailCandidate } from '../helpers/vtt-thumbnail.js'
import { expect, test } from './fixtures.js'
const implementation = await vttThumbnailCandidate()
const cues = 'WEBVTT\n\n00:00.000 --> 00:04.000\nvtt-sprite.svg#xywh=0,0,80,45\n\n00:04.000 --> 00:08.000\nvtt-sprite.svg#xywh=80,0,80,45\n'
const sprite = '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="45"><path fill="red" d="M0 0h80v45H0z"/><path fill="blue" d="M80 0h80v45H80z"/></svg>'
for (const core of ['published', 'candidate']) {
test(`VTT native ${core} core: real progress hover selects sprite regions and destroy removes owned UI`, async ({ page }, testInfo) => {
await page.route('**/test/vtt-cues.vtt', route => route.fulfill({ contentType: 'text/vtt', body: cues }))
await page.route('**/test/vtt-sprite.svg', route => route.fulfill({ contentType: 'image/svg+xml', body: sprite }))
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: implementation.code })
await page.evaluate(async () => {
window.createPlayer('/test/pattern.mp4')
await window.art.plugins.add(window.artplayerPluginVttThumbnail({ vtt: '/test/vtt-cues.vtt' }))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const progress = page.locator('.art-control-progress')
const preview = page.locator('.art-control-vtt-thumbnail')
const bounds = await progress.boundingBox()
await page.mouse.move(bounds.x + bounds.width * 0.25, bounds.y + bounds.height / 2)
await expect(preview).toHaveCSS('display', 'flex')
await expect(preview).toHaveCSS('width', '80px')
await expect(preview).toHaveCSS('height', '45px')
await expect(preview).toHaveCSS('background-position', '0px 0px')
await page.mouse.move(bounds.x + bounds.width * 0.75, bounds.y + bounds.height / 2)
await expect(preview).toHaveCSS('background-position', '-80px 0px')
const pixels = await page.evaluate(async () => {
const image = new Image()
image.src = '/test/vtt-sprite.svg'
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = 160
canvas.height = 45
const context = canvas.getContext('2d')
context.drawImage(image, 0, 0)
return [Array.from(context.getImageData(40, 20, 1, 1).data), Array.from(context.getImageData(120, 20, 1, 1).data)]
})
expect(pixels).toEqual([[255, 0, 0, 255], [0, 0, 255, 255]])
await testInfo.attach('selected-sprite', { body: await preview.screenshot(), contentType: 'image/png' })
await page.evaluate(() => window.art.destroy(false))
await expect(preview).toHaveCount(0)
await testInfo.attach('vtt-candidate', { body: JSON.stringify({ core, implementation: hash(implementation.code), pixels }), contentType: 'application/json' })
})
test(`VTT native ${core} core: destroy aborts a pending native fetch and settles registration`, async ({ page }, testInfo) => {
let release
let requested
const started = new Promise((resolve) => {
requested = resolve
})
const pending = new Promise((resolve) => {
release = resolve
})
await page.route('**/test/vtt-held.vtt', async (route) => {
requested()
await pending
await route.fulfill({ contentType: 'text/vtt', body: cues }).catch(() => {})
})
try {
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: implementation.code })
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
const fetch = window.fetch.bind(window)
window.fetch = (url, options) => {
if (url === '/test/vtt-held.vtt')
window.vttSignal = options?.signal
return fetch(url, options)
}
window.vttSettled = false
window.vttPending = window.artplayerPluginVttThumbnail({ vtt: '/test/vtt-held.vtt' })(window.art).then((result) => {
window.vttSettled = true
window.vttResult = result
})
})
await started
await page.evaluate(() => window.art.destroy(false))
await expect.poll(() => page.evaluate(() => window.vttSettled)).toBe(true)
expect(await page.evaluate(() => window.vttSignal.aborted)).toBe(true)
release()
await page.evaluate(() => window.vttPending)
await expect(page.locator('.art-control-vtt-thumbnail')).toHaveCount(0)
expect(await page.evaluate(() => window.vttResult)).toEqual({ name: 'artplayerPluginVttThumbnail' })
await testInfo.attach('vtt-candidate', { body: JSON.stringify({ core, implementation: hash(implementation.code), nativeAbort: true }), contentType: 'application/json' })
}
finally { release() }
})
}
+20
View File
@@ -1,7 +1,16 @@
import fs from 'node:fs'
import process from 'node:process'
import vm from 'node:vm'
import { transform } from 'esbuild'
import { readMember } from '../../refactor/scripts/releases.mjs'
import { verifyVttThumbnailContract } from '../../refactor/scripts/vtt-thumbnail-contract.mjs'
import { compilePackage } from './load.js'
export async function vttThumbnailCandidate() {
if (process.env.ARTPLAYER_VTT_THUMBNAIL_BASELINE === '1')
return (await vttThumbnailHistorical()).find(item => item.name === 'frozen-workspace-js')
return { name: 'candidate', profile: 'current', code: process.env.ARTPLAYER_VTT_THUMBNAIL_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_VTT_THUMBNAIL_ARTIFACT, 'utf8') : await compilePackage('artplayer-plugin-vtt-thumbnail', 'umd') }
}
export async function vttThumbnailHistorical() {
const { baseline, archives, sources } = await verifyVttThumbnailContract()
@@ -41,6 +50,8 @@ export function vttThumbnailEnvironment(implementation, { script = false, mobile
const timers = new Map()
const classes = new Set()
const styles = {}
const removed = []
const warnings = []
let nextTimer = 0
let resolveFetch
let rejectFetch
@@ -69,7 +80,12 @@ export function vttThumbnailEnvironment(implementation, { script = false, mobile
} },
controls: { add(option) {
controls.push(option)
this[option.name] = control
option.mounted(control)
return control
}, remove(name) {
removed.push(name)
delete this[name]
} },
on(name, callback) {
if (!listeners.has(name))
@@ -81,6 +97,8 @@ export function vttThumbnailEnvironment(implementation, { script = false, mobile
}
const box = {
window: {},
AbortController,
console: { warn(...args) { warnings.push(args) } },
fetch(...args) {
requests.push(args)
return pending || Promise.resolve(response(text))
@@ -111,6 +129,8 @@ export function vttThumbnailEnvironment(implementation, { script = false, mobile
timers,
requests,
progress,
removed,
warnings,
resolve(source = text) { resolveFetch(response(source)) },
reject(error) { rejectFetch(error) },
async emit(name, ...args) { return Promise.all((listeners.get(name) || []).map(callback => callback(...args))) },
+268
View File
@@ -0,0 +1,268 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate resource regressions use the Node runner.
import test from 'node:test'
import { vttText, vttThumbnailCandidate, vttThumbnailEnvironment } from './helpers/vtt-thumbnail.js'
const implementation = await vttThumbnailCandidate()
const create = options => vttThumbnailEnvironment(implementation, options)
async function flush() {
for (let index = 0; index < 8; index++)
await Promise.resolve()
}
const count = env => [...env.listeners.values()].reduce((sum, items) => sum + items.length, 0)
test('VTT candidate retains asynchronous registration, live style, floored bounds and literal image paths', async () => {
const env = create({ deferred: true })
const option = { vtt: '/folder/cues.vtt', style: {} }
const pending = env.factory(option)(env.art)
assert.equal(env.requests[0][0], option.vtt)
assert.equal(env.controls.length, 0)
option.style = { opacity: '0.8' }
env.resolve()
assert.equal((await pending).name, 'artplayerPluginVttThumbnail')
assert.equal(env.controls[0].style, option.style)
await env.hover(0.5)
assert.equal(env.styles.backgroundImage, 'url(/folder/sheet.jpg)')
assert.equal(env.styles.backgroundPosition, '-10px -20px')
assert.equal(env.styles.left, '60px')
await env.hover(0.51)
assert.equal(env.styles.backgroundImage, 'url(/folder/second.jpg)')
})
for (const abort of [true, false]) {
test(`VTT candidate settles destroyed pending registration without late mounting; AbortController=${abort}`, async () => {
const env = create({ deferred: true })
if (!abort)
env.box.AbortController = undefined
let settled = false
const pending = env.factory({ vtt: '/cues.vtt' })(env.art).then((result) => {
settled = true
return result
})
await env.emit('destroy')
await flush()
assert.equal(settled, true, 'Destroy settles even a fetch that ignores cancellation')
if (abort)
assert.equal(env.requests[0][1].signal.aborted, true)
env.resolve()
await pending
await flush()
assert.equal(env.controls.length, 0)
assert.equal(count(env), 0)
})
}
test('VTT candidate observes late rejection after destroy without exposing an unhandled Promise', async () => {
const env = create({ deferred: true })
const pending = env.factory({})(env.art)
await env.emit('destroy')
env.reject(new Error('late network error'))
await pending
await flush()
assert.equal(count(env), 0)
assert.equal(env.controls.length, 0)
})
test('VTT candidate cancels while response body is pending', async () => {
const env = create()
let finish
env.box.fetch = async () => ({ ok: true, text: () => new Promise((resolve) => {
finish = resolve
}) })
const pending = env.factory({})(env.art)
await flush()
assert.equal(typeof finish, 'function')
await env.emit('destroy')
finish(vttText)
await pending
assert.equal(env.controls.length, 0)
assert.equal(count(env), 0)
})
test('VTT candidate clears timer zero, removes its control and ignores captured stale callbacks', async () => {
const env = create({ mobile: true })
await env.factory({})(env.art)
const listener = env.listeners.get('setBar')[0]
await env.hover(0.3, 'played', {})
assert.equal(env.timers.has(0), true)
const timer = env.timers.get(0)
await env.emit('destroy')
assert.equal(env.timers.size, 0)
assert.equal(count(env), 0)
assert.deepEqual(env.removed, ['vtt-thumbnail'])
env.styles.display = 'sentinel'
timer.callback()
await listener('hover', 0.7, {})
assert.equal(env.styles.display, 'sentinel')
await env.emit('destroy')
assert.equal(env.removed.length, 1)
})
test('VTT candidate never removes a replacement control owned by another registration', async () => {
const env = create()
await env.factory({})(env.art)
const replacement = {}
env.art.controls['vtt-thumbnail'] = replacement
await env.emit('destroy')
assert.equal(env.art.controls['vtt-thumbnail'], replacement)
assert.deepEqual(env.removed, [])
assert.equal(count(env), 0)
})
for (const phase of ['fetch', 'text', 'parse', 'mount']) {
test(`VTT candidate releases registration resources after ${phase} failure`, async () => {
const env = create({ text: phase === 'parse' ? 'WEBVTT\ninvalid\npayload' : vttText })
const failure = new Error(`${phase} failure`)
if (phase === 'fetch')
env.box.fetch = () => { throw failure }
if (phase === 'text')
env.box.fetch = async () => ({ text: async () => { throw failure } })
if (phase === 'mount')
env.art.constructor.utils.addClass = () => { throw failure }
await assert.rejects(env.factory({})(env.art), error => phase === 'parse' ? error.name === 'TypeError' : error === failure)
assert.equal(count(env), 0)
assert.equal(env.timers.size, 0)
if (phase === 'mount')
assert.deepEqual(env.removed, ['vtt-thumbnail'])
})
}
test('VTT candidate rejects HTTP failure before parsing its body', async () => {
const env = create()
let read = false
env.box.fetch = async () => ({ ok: false, status: 404, text: async () => {
read = true
return vttText
} })
await assert.rejects(env.factory({})(env.art), /404/)
assert.equal(read, false)
assert.equal(count(env), 0)
})
test('VTT candidate cleans up a listener installed by an on implementation that throws', async () => {
const env = create()
const original = env.art.on
const failure = new Error('partial listener registration')
env.art.on = function (name, callback) {
original.call(this, name, callback)
if (name === 'setBar')
throw failure
return this
}
await assert.rejects(env.factory({})(env.art), error => error === failure)
assert.equal(count(env), 0)
assert.deepEqual(env.removed, ['vtt-thumbnail'])
})
test('VTT candidate does not install when style access destroys the player', async () => {
const env = create()
await env.factory({ get style() {
env.emit('destroy')
return {}
} })(env.art)
assert.equal(env.controls.length, 0)
assert.equal(count(env), 0)
})
test('VTT candidate stops subsequent style writes and scheduling after reentrant destroy', async () => {
const env = create({ mobile: true })
await env.factory({})(env.art)
const writes = []
env.art.constructor.utils.setStyle = () => {
throw new Error('Captured utility should remain unchanged')
}
Object.defineProperty(env.styles, 'display', {
get() { return writes.at(-1) },
set(value) {
writes.push(value)
env.emit('destroy')
},
configurable: true,
})
await env.hover(0.3, 'played', {})
assert.deepEqual(Object.keys(env.styles), [])
assert.deepEqual(writes, ['flex'])
assert.equal(env.timers.size, 0)
assert.equal(count(env), 0)
})
test('VTT candidate continues other cleanup when a timer cleanup throws', async () => {
const env = create({ mobile: true })
await env.factory({})(env.art)
await env.hover(0.3, 'played', {})
env.box.clearTimeout = () => {
throw new Error('timer cleanup failed')
}
await env.emit('destroy')
assert.equal(count(env), 0)
assert.deepEqual(env.removed, ['vtt-thumbnail'])
assert.equal(env.warnings.length, 1)
})
test('VTT candidate starts no work on an already destroyed player', async () => {
const env = create()
env.art.isDestroy = true
assert.equal((await env.factory({})(env.art)).name, 'artplayerPluginVttThumbnail')
assert.equal(env.requests.length, 0)
assert.equal(count(env), 0)
})
test('VTT candidate removes a partially installed destroy listener before rejecting', async () => {
const env = create()
const original = env.art.on
const error = new Error('destroy registration failed')
env.art.on = function (name, callback) {
original.call(this, name, callback)
throw error
}
await assert.rejects(env.factory({})(env.art), value => value === error)
assert.equal(count(env), 0)
assert.equal(env.requests.length, 0)
})
test('VTT candidate invalidates replaced mobile callbacks and preserves the 500ms delay', async () => {
const env = create({ mobile: true })
await env.factory({})(env.art)
await env.hover(0.3, 'played', {})
const old = env.timers.get(0)
await env.hover(0.7, 'played', {})
assert.equal(env.timers.size, 1)
old.callback()
assert.equal(env.styles.display, 'flex')
const latest = [...env.timers.values()][0]
assert.equal(latest.delay, 500)
latest.callback()
assert.equal(env.styles.display, 'none')
})
test('VTT candidate cleans up timer handles returned after reentrant destruction', async () => {
const env = create({ mobile: true })
await env.factory({})(env.art)
const schedule = env.box.setTimeout
env.box.setTimeout = (...args) => {
env.emit('destroy')
return schedule(...args)
}
await env.hover(0.3, 'played', {})
assert.equal(env.timers.size, 0)
assert.equal(count(env), 0)
})
test('VTT candidate keeps registrations isolated and does not reload VTT on restart', async () => {
const left = create({ mobile: true })
const right = create({ mobile: true })
await left.factory({ vtt: '/left/cues.vtt' })(left.art)
const option = { vtt: '/right/cues.vtt' }
await right.factory(option)(right.art)
await right.hover(0.3, 'played', {})
await left.emit('destroy')
option.vtt = '/replacement.vtt'
await right.emit('restart')
await right.hover(0.7)
assert.equal(right.requests.length, 1)
assert.equal(right.styles.backgroundImage, 'url(/right/second.jpg)')
assert.equal(right.timers.size, 1)
assert.equal(right.removed.length, 0)
await right.emit('destroy')
assert.equal(right.timers.size, 0)
})