refactor(vtt-thumbnail): [PKG-VTT-THUMB-04] type runtime data and resource boundaries

This commit is contained in:
Harvey Zhao committed 2026-09-13 09:03:20 +08:00
1 parent 80588a3046
commit 7f2906ae52
22 files changed
+2940 -75

No files matched your search

@@ -3,27 +3,27 @@
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.
and the public declaration/consumer migration. The seven internal TS files are strict. 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
- `src/index.ts` 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
- `src/lifetime.ts` 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.
- `src/request.ts` 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` parses thumbnail metadata and exports the pure `findThumbnail` lookup.
- `src/parseVtt.ts` parses thumbnail metadata and exports the pure `findThumbnail` lookup.
It preserves timestamp flooring, first inclusive matches, decimal xywh strings and lexical
URL joining. It handles cue IDs, NOTE/STYLE/REGION blocks, BOM/line endings and timing
settings; invalid input raises TypeError with the original line before any UI is installed.
- `src/preview.js` owns the mobile 500ms timer and creates the setBar callback. Timer
- `src/preview.ts` 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.
- `src/getVttArray.ts` 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.
@@ -32,6 +32,26 @@ removing it, cleanup compares the live `art.controls['vtt-thumbnail']` alias wit
element. It must not remove a later replacement. Partial listener/control installation is
rolled back if initialization throws, preserving the original rejection.
## Runtime types
`src/types.ts` defines options, result, rectangle/cue data, owned events, cleanup and
preview inputs. Request cancellation is typed as a void branch; actual registration is
Promise<Result>. The lifetime accepts only destroy/setBar and uses the core event tuples.
The preview receives only its required DOM/style/duration inputs.
Assertions are limited to the real Artplayer constructor boundary, validated regex groups
and loop indexes, the completed four-key rectangle, and native responses after the closed
check. The latter is valid because native fetch returns Response and cancellation only
resolves void after closing; malformed custom responses still throw at runtime. Numeric
rectangle strings are explicitly converted for arithmetic while the zero style write stays
numeric. No any, unchecked JS or skipLibCheck exception is introduced.
The package tsconfig also checks test/types/vtt-thumbnail-runtime.ts. It tests the async
result, required options, CSS/URL types, event tuples, cancellation, readonly state and raw
rectangle strings, including expected errors. It separately proves the old public result
is synchronous in declarations; those declarations are not silently corrected in this
runtime checkpoint. New/old declaration consumers and export aliases remain task 04.
## Compatibility and intentional fixes
Option/style remain live at their historical read points. The return stays a Promise;
@@ -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,e){throw new TypeError(`Invalid VTT thumbnail at line ${t}: ${e}`)}function e(e,n){/^\d{2,}(?::\d{2}){0,2}(?:\.\d{3})?$/.test(e)||t(n,"invalid timestamp");const[i,o="0"]=e.split("."),r=i.split(":").reduce((t,e)=>60*t+Number(e),0)+Number(o)/1e3;return Number.isSafeInteger(Math.floor(r))||t(n,"timestamp exceeds the supported numeric range"),r}function n(e,n,i){const o=e.match(/^(.+)#([xywh]{4})=(.*)$/);o&&4===new Set(o[2]).size||t(i,"expected an image URL and four distinct xywh keys");const r=o[3].split(",").map(t=>t.trim());4!==r.length&&t(i,"expected four rectangle coordinates");const s={};for(let c=0;c<4;c++){const e=o[2][c],n=r[c];/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(n)&&Number.isFinite(Number(n))||t(i,"rectangle coordinates must be finite non-negative decimals"),"w"!==e&&"h"!==e||0!==Number(n)||t(i,"rectangle width and height must be positive"),s[e]=n}let l=o[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(l)){const t=n.split("/");t.pop(),t.push(l),l=t.join("/")}return{url:l,...s}}function i({lifetime:t,thumbnails:e,progress:n,duration:i,setStyle:o,isMobile:r}){let s=null,l=0;function c(e,n,i){t.closed||o(e,n,i)}return t.own(()=>{if(l++,null!==s){const t=s;s=null,clearTimeout(t)}}),o=>async(u,a,d)=>{if(t.closed)return;const f="played"===u&&d&&r;if("hover"!==u&&!f)return;const m=n.clientWidth*a,h=a*i();if(c(o,"display","flex"),t.closed)return;const p=function(t,e){return t.find(t=>e>=t.start&&e<=t.end)}(e,h);if(!p)return c(o,"display","none");if(m>0&&m<n.clientWidth?function(t,e,i){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`),i<=e.w/2?c(t,"left",0):i>n.clientWidth-e.w/2?c(t,"left",n.clientWidth-e.w+"px"):c(t,"left",i-e.w/2+"px")}(o,p,m):r||c(o,"display","none"),f&&!t.closed){const e=++l;if(null!==s&&clearTimeout(s),t.closed)return;const n=setTimeout(()=>{e!==l||t.closed||(s=null,c(o,"display","none"))},500);t.closed||e!==l?clearTimeout(n):s=n}}}return function(o){return async r=>{const{constructor:{utils:{setStyle:s,isMobile:l,addClass:c}},template:{$progress:u}}=r,a=function(t){let e=Boolean(t.isDestroy);const n=new Set;let i;const o=new Promise(t=>{i=t}),r={get closed(){return e},own:t=>(e?s(t):n.add(t),()=>n.delete(t)),listen(n,i){e||(r.own(()=>t.off(n,i)),t.on(n,i))},wait:t=>Promise.race([t,o]),dispose(){if(e)return;e=!0,i();const t=[...n].reverse();n.clear();for(const e of t)s(e)}};function s(t){try{t()}catch(e){console.warn("Failed to clean up VTT thumbnails:",e)}}try{r.listen("destroy",r.dispose)}catch(l){throw r.dispose(),l}return r}(r),d={name:"artplayerPluginVttThumbnail"};try{if(a.closed)return d;const f=o.vtt,m=await async function(t="",e){if(e.closed)return;const n="function"===typeof AbortController?new AbortController:null,i=e.own(()=>n?.abort());try{if(e.closed)return;const i=await e.wait(n?fetch(t,{signal:n.signal}):fetch(t));if(e.closed)return;if(!1===i.ok)throw new Error(`Failed to fetch VTT thumbnails: HTTP ${i.status}`);if(e.closed)return;return await e.wait(i.text())}finally{i()}}(f,a);if(a.closed)return d;const h=function(i,o=""){"string"!==typeof i&&t(1,"expected text");const r=i.replace(/^\uFEFF/,"").split(/\r\n|\r|\n/);let s=0;function l(){for(;s<r.length&&!r[s].trim();)s++}if(l(),s===r.length)return[];/^WEBVTT(?:[\t ].*)?$/.test(r[s].trim())&&!r[s].includes("--\x3e")||t(s+1,"expected WEBVTT header"),s++;const c=[];for(;s<r.length&&(l(),s!==r.length);){const i=r[s].trim();if(/^NOTE(?:[\t ]|$)/.test(i)||"STYLE"===i||"REGION"===i){for(;s<r.length&&r[s].trim();)s++;continue}const u=s+1;i.includes("--\x3e")||(s++,s!==r.length&&r[s].trim()||t(u,"cue identifier must be followed by timing"));const a=s+1,d=r[s].trim().match(/^([\d:.]+)[\t ]*-->[\t ]*([\d:.]+)(?:[\t ].*)?$/);d||t(a,"invalid cue timing");const f=e(d[1],a),m=e(d[2],a);m<f&&t(a,"cue end precedes its start"),s++,l(),s===r.length&&t(a,"missing sprite image");const h=n(r[s].trim(),o,s+1);c.push({start:Math.floor(f),end:Math.floor(m),...h}),s++}return c}(m,f),p=i({lifetime:a,thumbnails:h,progress:u,duration:()=>r.duration,setStyle:s,isMobile:l}),g=o.style||{};return a.closed||r.controls.add({name:"vtt-thumbnail",position:"top",index:20,style:g,mounted(t){a.own(()=>{r.controls["vtt-thumbnail"]===t&&r.controls.remove("vtt-thumbnail")}),a.closed||(c(t,"art-control-thumbnails"),a.listen("setBar",p(t)))}}),d}catch(f){throw a.dispose(),f}}}});
!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,e){throw new TypeError(`Invalid VTT thumbnail at line ${t}: ${e}`)}function e(e,n){/^\d{2,}(?::\d{2}){0,2}(?:\.\d{3})?$/.test(e)||t(n,"invalid timestamp");const[i,o="0"]=e.split("."),r=i.split(":").reduce((t,e)=>60*t+Number(e),0)+Number(o)/1e3;return Number.isSafeInteger(Math.floor(r))||t(n,"timestamp exceeds the supported numeric range"),r}function n(e,n,i){const o=e.match(/^(.+)#([xywh]{4})=(.*)$/);o&&4===new Set(o[2]).size||t(i,"expected an image URL and four distinct xywh keys");const r=o[3].split(",").map(t=>t.trim());4!==r.length&&t(i,"expected four rectangle coordinates");const s={};for(let c=0;c<4;c++){const e=o[2][c],n=r[c];/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(n)&&Number.isFinite(Number(n))||t(i,"rectangle coordinates must be finite non-negative decimals"),"w"!==e&&"h"!==e||0!==Number(n)||t(i,"rectangle width and height must be positive"),s[e]=n}let l=o[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(l)){const t=n.split("/");t.pop(),t.push(l),l=t.join("/")}return{url:l,...s}}function i({lifetime:t,thumbnails:e,progress:n,duration:i,setStyle:o,isMobile:r}){let s=null,l=0;function c(e,n,i){t.closed||o(e,n,i)}return t.own(()=>{if(l++,null!==s){const t=s;s=null,clearTimeout(t)}}),o=>async(u,a,d)=>{if(t.closed)return;const f="played"===u&&d&&r;if("hover"!==u&&!f)return;const m=n.clientWidth*a,h=a*i();if(c(o,"display","flex"),t.closed)return;const p=function(t,e){return t.find(t=>e>=t.start&&e<=t.end)}(e,h);if(!p)return c(o,"display","none");if(m>0&&m<n.clientWidth?function(t,e,i){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`),i<=Number(e.w)/2?c(t,"left",0):i>n.clientWidth-Number(e.w)/2?c(t,"left",n.clientWidth-Number(e.w)+"px"):c(t,"left",i-Number(e.w)/2+"px")}(o,p,m):r||c(o,"display","none"),f&&!t.closed){const e=++l;if(null!==s&&clearTimeout(s),t.closed)return;const n=setTimeout(()=>{e!==l||t.closed||(s=null,c(o,"display","none"))},500);t.closed||e!==l?clearTimeout(n):s=n}}}return function(o){return async r=>{const{setStyle:s,isMobile:l,addClass:c}=r.constructor.utils,{$progress:u}=r.template,a=function(t){let e=Boolean(t.isDestroy);const n=new Set;let i;const o=new Promise(t=>{i=t}),r={get closed(){return e},own:t=>(e?s(t):n.add(t),()=>n.delete(t)),listen(n,i){e||(r.own(()=>t.off(n,i)),t.on(n,i))},wait:t=>Promise.race([t,o]),dispose(){if(e)return;e=!0,i();const t=[...n].reverse();n.clear();for(const e of t)s(e)}};function s(t){try{t()}catch(e){console.warn("Failed to clean up VTT thumbnails:",e)}}try{r.listen("destroy",r.dispose)}catch(l){throw r.dispose(),l}return r}(r),d={name:"artplayerPluginVttThumbnail"};try{if(a.closed)return d;const f=o.vtt,m=await async function(t="",e){if(e.closed)return;const n="function"===typeof AbortController?new AbortController:null,i=e.own(()=>n?.abort());try{if(e.closed)return;const i=await e.wait(n?fetch(t,{signal:n.signal}):fetch(t));if(e.closed)return;if(!1===i.ok)throw new Error(`Failed to fetch VTT thumbnails: HTTP ${i.status}`);if(e.closed)return;return await e.wait(i.text())}finally{i()}}(f,a);if(a.closed)return d;const h=function(i,o=""){"string"!==typeof i&&t(1,"expected text");const r=i.replace(/^\uFEFF/,"").split(/\r\n|\r|\n/);let s=0;function l(){for(;s<r.length&&!r[s].trim();)s++}if(l(),s===r.length)return[];/^WEBVTT(?:[\t ].*)?$/.test(r[s].trim())&&!r[s].includes("--\x3e")||t(s+1,"expected WEBVTT header"),s++;const c=[];for(;s<r.length&&(l(),s!==r.length);){const i=r[s].trim();if(/^NOTE(?:[\t ]|$)/.test(i)||"STYLE"===i||"REGION"===i){for(;s<r.length&&r[s].trim();)s++;continue}const u=s+1;i.includes("--\x3e")||(s++,s!==r.length&&r[s].trim()||t(u,"cue identifier must be followed by timing"));const a=s+1,d=r[s].trim().match(/^([\d:.]+)[\t ]*-->[\t ]*([\d:.]+)(?:[\t ].*)?$/);d||t(a,"invalid cue timing");const f=e(d[1],a),m=e(d[2],a);m<f&&t(a,"cue end precedes its start"),s++,l(),s===r.length&&t(a,"missing sprite image");const h=n(r[s].trim(),o,s+1);c.push({start:Math.floor(f),end:Math.floor(m),...h}),s++}return c}(m,f),p=i({lifetime:a,thumbnails:h,progress:u,duration:()=>r.duration,setStyle:s,isMobile:l}),b=o.style||{};return a.closed||r.controls.add({name:"vtt-thumbnail",position:"top",index:20,style:b,mounted(t){a.own(()=>{r.controls["vtt-thumbnail"]===t&&r.controls.remove("vtt-thumbnail")}),a.closed||(c(t,"art-control-thumbnails"),a.listen("setBar",p(t)))}}),d}catch(f){throw a.dispose(),f}}}});
@@ -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=Object.defineProperty,e=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,i=(e,n,o)=>n in e?t(e,n,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[n]=o,r=(t,r)=>{for(var l in r||(r={}))n.call(r,l)&&i(t,l,r[l]);if(e)for(var l of e(r))o.call(r,l)&&i(t,l,r[l]);return t},l=(t,e,n)=>new Promise((o,i)=>{var r=t=>{try{s(n.next(t))}catch(e){i(e)}},l=t=>{try{s(n.throw(t))}catch(e){i(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(r,l);s((n=n.apply(t,e)).next())});function s(t,e){throw new TypeError(`Invalid VTT thumbnail at line ${t}: ${e}`)}function c(t,e){/^\d{2,}(?::\d{2}){0,2}(?:\.\d{3})?$/.test(t)||s(e,"invalid timestamp");const[n,o="0"]=t.split("."),i=n.split(":").reduce((t,e)=>60*t+Number(e),0)+Number(o)/1e3;return Number.isSafeInteger(Math.floor(i))||s(e,"timestamp exceeds the supported numeric range"),i}function u(t,e,n){const o=t.match(/^(.+)#([xywh]{4})=(.*)$/);o&&4===new Set(o[2]).size||s(n,"expected an image URL and four distinct xywh keys");const i=o[3].split(",").map(t=>t.trim());4!==i.length&&s(n,"expected four rectangle coordinates");const l={};for(let r=0;r<4;r++){const t=o[2][r],e=i[r];/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(e)&&Number.isFinite(Number(e))||s(n,"rectangle coordinates must be finite non-negative decimals"),"w"!==t&&"h"!==t||0!==Number(e)||s(n,"rectangle width and height must be positive"),l[t]=e}let c=o[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(c)){const t=e.split("/");t.pop(),t.push(c),c=t.join("/")}return r({url:c},l)}function a({lifetime:t,thumbnails:e,progress:n,duration:o,setStyle:i,isMobile:r}){let s=null,c=0;function u(e,n,o){t.closed||i(e,n,o)}return t.own(()=>{if(c++,null!==s){const t=s;s=null,clearTimeout(t)}}),i=>(a,d,f)=>l(null,null,function*(){if(t.closed)return;const l="played"===a&&f&&r;if("hover"!==a&&!l)return;const p=n.clientWidth*d,m=d*o();if(u(i,"display","flex"),t.closed)return;const h=function(t,e){return t.find(t=>e>=t.start&&e<=t.end)}(e,m);if(!h)return u(i,"display","none");if(p>0&&p<n.clientWidth?function(t,e,o){u(t,"backgroundImage",`url(${e.url})`),u(t,"height",`${e.h}px`),u(t,"width",`${e.w}px`),u(t,"backgroundPosition",`-${e.x}px -${e.y}px`),o<=e.w/2?u(t,"left",0):o>n.clientWidth-e.w/2?u(t,"left",n.clientWidth-e.w+"px"):u(t,"left",o-e.w/2+"px")}(i,h,p):r||u(i,"display","none"),l&&!t.closed){const e=++c;if(null!==s&&clearTimeout(s),t.closed)return;const n=setTimeout(()=>{e!==c||t.closed||(s=null,u(i,"display","none"))},500);t.closed||e!==c?clearTimeout(n):s=n}})}return function(t){return e=>l(null,null,function*(){const{constructor:{utils:{setStyle:n,isMobile:o,addClass:i}},template:{$progress:d}}=e,f=function(t){let e=Boolean(t.isDestroy);const n=new Set;let o;const i=new Promise(t=>{o=t}),r={get closed(){return e},own:t=>(e?l(t):n.add(t),()=>n.delete(t)),listen(n,o){e||(r.own(()=>t.off(n,o)),t.on(n,o))},wait:t=>Promise.race([t,i]),dispose(){if(e)return;e=!0,o();const t=[...n].reverse();n.clear();for(const e of t)l(e)}};function l(t){try{t()}catch(e){console.warn("Failed to clean up VTT thumbnails:",e)}}try{r.listen("destroy",r.dispose)}catch(s){throw r.dispose(),s}return r}(e),p={name:"artplayerPluginVttThumbnail"};try{if(f.closed)return p;const m=t.vtt,h=yield function(t="",e){return l(this,null,function*(){if(e.closed)return;const n="function"===typeof AbortController?new AbortController:null,o=e.own(()=>null==n?void 0:n.abort());try{if(e.closed)return;const o=yield 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 yield e.wait(o.text())}finally{o()}})}(m,f);if(f.closed)return p;const y=function(t,e=""){"string"!==typeof t&&s(1,"expected text");const n=t.replace(/^\uFEFF/,"").split(/\r\n|\r|\n/);let o=0;function i(){for(;o<n.length&&!n[o].trim();)o++}if(i(),o===n.length)return[];/^WEBVTT(?:[\t ].*)?$/.test(n[o].trim())&&!n[o].includes("--\x3e")||s(o+1,"expected WEBVTT header"),o++;const l=[];for(;o<n.length&&(i(),o!==n.length);){const t=n[o].trim();if(/^NOTE(?:[\t ]|$)/.test(t)||"STYLE"===t||"REGION"===t){for(;o<n.length&&n[o].trim();)o++;continue}const a=o+1;t.includes("--\x3e")||(o++,o!==n.length&&n[o].trim()||s(a,"cue identifier must be followed by timing"));const d=o+1,f=n[o].trim().match(/^([\d:.]+)[\t ]*-->[\t ]*([\d:.]+)(?:[\t ].*)?$/);f||s(d,"invalid cue timing");const p=c(f[1],d),m=c(f[2],d);m<p&&s(d,"cue end precedes its start"),o++,i(),o===n.length&&s(d,"missing sprite image");const h=u(n[o].trim(),e,o+1);l.push(r({start:Math.floor(p),end:Math.floor(m)},h)),o++}return l}(h,m),b=a({lifetime:f,thumbnails:y,progress:d,duraLine truncated
!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=Object.defineProperty,e=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,r=(e,n,o)=>n in e?t(e,n,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[n]=o,i=(t,i)=>{for(var l in i||(i={}))n.call(i,l)&&r(t,l,i[l]);if(e)for(var l of e(i))o.call(i,l)&&r(t,l,i[l]);return t},l=(t,e,n)=>new Promise((o,r)=>{var i=t=>{try{s(n.next(t))}catch(e){r(e)}},l=t=>{try{s(n.throw(t))}catch(e){r(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(i,l);s((n=n.apply(t,e)).next())});function s(t,e){throw new TypeError(`Invalid VTT thumbnail at line ${t}: ${e}`)}function c(t,e){/^\d{2,}(?::\d{2}){0,2}(?:\.\d{3})?$/.test(t)||s(e,"invalid timestamp");const[n,o="0"]=t.split("."),r=n.split(":").reduce((t,e)=>60*t+Number(e),0)+Number(o)/1e3;return Number.isSafeInteger(Math.floor(r))||s(e,"timestamp exceeds the supported numeric range"),r}function u(t,e,n){const o=t.match(/^(.+)#([xywh]{4})=(.*)$/);o&&4===new Set(o[2]).size||s(n,"expected an image URL and four distinct xywh keys");const r=o[3].split(",").map(t=>t.trim());4!==r.length&&s(n,"expected four rectangle coordinates");const l={};for(let i=0;i<4;i++){const t=o[2][i],e=r[i];/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(e)&&Number.isFinite(Number(e))||s(n,"rectangle coordinates must be finite non-negative decimals"),"w"!==t&&"h"!==t||0!==Number(e)||s(n,"rectangle width and height must be positive"),l[t]=e}let c=o[1];if(!/^\/|(?:https?|ftp|file):\/\//i.test(c)){const t=e.split("/");t.pop(),t.push(c),c=t.join("/")}return i({url:c},l)}function a({lifetime:t,thumbnails:e,progress:n,duration:o,setStyle:r,isMobile:i}){let s=null,c=0;function u(e,n,o){t.closed||r(e,n,o)}return t.own(()=>{if(c++,null!==s){const t=s;s=null,clearTimeout(t)}}),r=>(a,d,f)=>l(null,null,function*(){if(t.closed)return;const l="played"===a&&f&&i;if("hover"!==a&&!l)return;const m=n.clientWidth*d,p=d*o();if(u(r,"display","flex"),t.closed)return;const h=function(t,e){return t.find(t=>e>=t.start&&e<=t.end)}(e,p);if(!h)return u(r,"display","none");if(m>0&&m<n.clientWidth?function(t,e,o){u(t,"backgroundImage",`url(${e.url})`),u(t,"height",`${e.h}px`),u(t,"width",`${e.w}px`),u(t,"backgroundPosition",`-${e.x}px -${e.y}px`),o<=Number(e.w)/2?u(t,"left",0):o>n.clientWidth-Number(e.w)/2?u(t,"left",n.clientWidth-Number(e.w)+"px"):u(t,"left",o-Number(e.w)/2+"px")}(r,h,m):i||u(r,"display","none"),l&&!t.closed){const e=++c;if(null!==s&&clearTimeout(s),t.closed)return;const n=setTimeout(()=>{e!==c||t.closed||(s=null,u(r,"display","none"))},500);t.closed||e!==c?clearTimeout(n):s=n}})}return function(t){return e=>l(null,null,function*(){const{setStyle:n,isMobile:o,addClass:r}=e.constructor.utils,{$progress:d}=e.template,f=function(t){let e=Boolean(t.isDestroy);const n=new Set;let o;const r=new Promise(t=>{o=t}),i={get closed(){return e},own:t=>(e?l(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,r]),dispose(){if(e)return;e=!0,o();const t=[...n].reverse();n.clear();for(const e of t)l(e)}};function l(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}(e),m={name:"artplayerPluginVttThumbnail"};try{if(f.closed)return m;const p=t.vtt,h=yield function(t="",e){return l(this,null,function*(){if(e.closed)return;const n="function"===typeof AbortController?new AbortController:null,o=e.own(()=>null==n?void 0:n.abort());try{if(e.closed)return;const o=yield 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 yield e.wait(o.text())}finally{o()}})}(p,f);if(f.closed)return m;const b=function(t,e=""){"string"!==typeof t&&s(1,"expected text");const n=t.replace(/^\uFEFF/,"").split(/\r\n|\r|\n/);let o=0;function r(){for(;o<n.length&&!n[o].trim();)o++}if(r(),o===n.length)return[];/^WEBVTT(?:[\t ].*)?$/.test(n[o].trim())&&!n[o].includes("--\x3e")||s(o+1,"expected WEBVTT header"),o++;const l=[];for(;o<n.length&&(r(),o!==n.length);){const t=n[o].trim();if(/^NOTE(?:[\t ]|$)/.test(t)||"STYLE"===t||"REGION"===t){for(;o<n.length&&n[o].trim();)o++;continue}const a=o+1;t.includes("--\x3e")||(o++,o!==n.length&&n[o].trim()||s(a,"cue identifier must be followed by timing"));const d=o+1,f=n[o].trim().match(/^([\d:.]+)[\t ]*-->[\t ]*([\d:.]+)(?:[\t ].*)?$/);f||s(d,"invalid cue timing");const m=c(f[1],d),p=c(f[2],d);p<m&&s(d,"cue end precedes its start"),o++,r(),o===n.length&&s(d,"missing sprite image");const h=u(n[o].trim(),e,o+1);l.push(i({start:Math.floor(m),end:Math.floor(p)},h)),o++}return l}(h,p),y=a({lifetime:Line truncated
@@ -168,12 +168,12 @@ function createPreview({ lifetime, thumbnails, progress, duration, setStyle, isM
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)
if (width <= Number(cue.w) / 2)
style(control, "left", 0);
else if (width > progress.clientWidth - cue.w / 2)
style(control, "left", `${progress.clientWidth - cue.w}px`);
else if (width > progress.clientWidth - Number(cue.w) / 2)
style(control, "left", `${progress.clientWidth - Number(cue.w)}px`);
else
style(control, "left", `${width - cue.w / 2}px`);
style(control, "left", `${width - Number(cue.w) / 2}px`);
}
return (control) => async (type, percentage, event) => {
if (lifetime.closed)
@@ -234,7 +234,8 @@ async function requestVtt(url = "", lifetime) {
}
function artplayerPluginVttThumbnail(option) {
return async (art) => {
const { constructor: { utils: { setStyle, isMobile, addClass } }, template: { $progress } } = art;
const { setStyle, isMobile, addClass } = art.constructor.utils;
const { $progress } = art.template;
const lifetime = createLifetime(art);
const result = { name: "artplayerPluginVttThumbnail" };
try {
@@ -1,6 +1,7 @@
import type { Thumbnail } from './types'
import parseVtt from './parseVtt'
// Preserve the historical source helper while registration owns its requests.
export default async function getVttArray(vttUrl = '') {
export default async function getVttArray(vttUrl = ''): Promise<Thumbnail[]> {
return parseVtt(await (await fetch(vttUrl)).text(), vttUrl)
}
@@ -1,13 +1,16 @@
import type Artplayer from 'artplayer'
import type { Option, Result } from './types'
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
export default function artplayerPluginVttThumbnail(option: Option) {
return async (art: Artplayer): Promise<Result> => {
const { setStyle, isMobile, addClass } = (art.constructor as typeof Artplayer).utils
const { $progress } = art.template
const lifetime = createLifetime(art)
const result = { name: 'artplayerPluginVttThumbnail' }
const result: Result = { name: 'artplayerPluginVttThumbnail' }
try {
if (lifetime.closed)
return result
@@ -1,11 +1,14 @@
export default function createLifetime(art) {
import type { Events } from 'artplayer'
import type { Cleanup, Lifetime, LifetimeHost, OwnedEvent } from './types'
export default function createLifetime(art: LifetimeHost): Lifetime {
let closed = Boolean(art.isDestroy)
const cleanups = new Set()
let cancel
const cancelled = new Promise((resolve) => {
const cleanups = new Set<Cleanup>()
let cancel!: Cleanup
const cancelled = new Promise<void>((resolve) => {
cancel = resolve
})
const lifetime = {
const lifetime: Lifetime = {
get closed() { return closed },
own(cleanup) {
if (closed)
@@ -14,13 +17,13 @@ export default function createLifetime(art) {
cleanups.add(cleanup)
return () => cleanups.delete(cleanup)
},
listen(name, callback) {
listen<Name extends OwnedEvent>(name: Name, callback: (...args: Events[Name]) => unknown) {
if (closed)
return
lifetime.own(() => art.off(name, callback))
art.on(name, callback)
},
wait(value) { return Promise.race([value, cancelled]) },
wait<Value>(value: Value | PromiseLike<Value>) { return Promise.race([value, cancelled]) },
dispose() {
if (closed)
return
@@ -32,7 +35,7 @@ export default function createLifetime(art) {
run(cleanup)
},
}
function run(cleanup) {
function run(cleanup: Cleanup) {
try {
cleanup()
}
@@ -1,35 +1,37 @@
function invalid(line, message) {
import type { Rectangle, Thumbnail } from './types'
function invalid(line: number, message: string): never {
throw new TypeError(`Invalid VTT thumbnail at line ${line}: ${message}`)
}
function timestamp(value, line) {
function timestamp(value: string, line: number): number {
if (!/^\d{2,}(?::\d{2}){0,2}(?:\.\d{3})?$/.test(value))
invalid(line, 'invalid timestamp')
const [whole, fraction = '0'] = value.split('.')
const seconds = whole.split(':').reduce((total, part) => total * 60 + Number(part), 0) + Number(fraction) / 1000
const seconds = whole!.split(':').reduce((total, part) => total * 60 + Number(part), 0) + Number(fraction) / 1000
if (!Number.isSafeInteger(Math.floor(seconds)))
invalid(line, 'timestamp exceeds the supported numeric range')
return seconds
}
function rectangle(text, vttUrl, line) {
function rectangle(text: string, vttUrl: string, line: number): Rectangle & { url: string } {
const match = text.match(/^(.+)#([xywh]{4})=(.*)$/)
if (!match || new Set(match[2]).size !== 4)
if (!match || new Set(match[2]!).size !== 4)
invalid(line, 'expected an image URL and four distinct xywh keys')
const values = match[3].split(',').map(value => value.trim())
const values = match[3]!.split(',').map(value => value.trim())
if (values.length !== 4)
invalid(line, 'expected four rectangle coordinates')
const rect = {}
const rect = {} as Rectangle
for (let index = 0; index < 4; index++) {
const key = match[2][index]
const value = values[index]
const key = match[2]![index] as keyof Rectangle
const value = values[index]!
if (!/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value) || !Number.isFinite(Number(value)))
invalid(line, 'rectangle coordinates must be finite non-negative decimals')
if ((key === 'w' || key === 'h') && Number(value) === 0)
invalid(line, 'rectangle width and height must be positive')
rect[key] = value
}
let url = match[1]
let url = match[1]!
if (!/^\/|(?:https?|ftp|file):\/\//i.test(url)) {
const segments = vttUrl.split('/')
segments.pop()
@@ -39,55 +41,55 @@ function rectangle(text, vttUrl, line) {
return { url, ...rect }
}
export function findThumbnail(thumbnails, second) {
export function findThumbnail(thumbnails: readonly Thumbnail[], second: number): Thumbnail | undefined {
return thumbnails.find(item => second >= item.start && second <= item.end)
}
export default function parseVtt(text, vttUrl = '') {
export default function parseVtt(text: unknown, vttUrl = ''): Thumbnail[] {
if (typeof text !== 'string')
invalid(1, 'expected text')
const lines = text.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/)
let index = 0
function skipEmpty() {
while (index < lines.length && !lines[index].trim())
while (index < lines.length && !lines[index]!.trim())
index++
}
skipEmpty()
if (index === lines.length)
return []
if (!/^WEBVTT(?:[\t ].*)?$/.test(lines[index].trim()) || lines[index].includes('-->'))
if (!/^WEBVTT(?:[\t ].*)?$/.test(lines[index]!.trim()) || lines[index]!.includes('-->'))
invalid(index + 1, 'expected WEBVTT header')
index++
const thumbnails = []
const thumbnails: Thumbnail[] = []
while (index < lines.length) {
skipEmpty()
if (index === lines.length)
break
const first = lines[index].trim()
const first = lines[index]!.trim()
if (/^NOTE(?:[\t ]|$)/.test(first) || first === 'STYLE' || first === 'REGION') {
while (index < lines.length && lines[index].trim())
while (index < lines.length && lines[index]!.trim())
index++
continue
}
const identifierLine = index + 1
if (!first.includes('-->')) {
index++
if (index === lines.length || !lines[index].trim())
if (index === lines.length || !lines[index]!.trim())
invalid(identifierLine, 'cue identifier must be followed by timing')
}
const timingLine = index + 1
const timing = lines[index].trim().match(/^([\d:.]+)[\t ]*-->[\t ]*([\d:.]+)(?:[\t ].*)?$/)
const timing = lines[index]!.trim().match(/^([\d:.]+)[\t ]*-->[\t ]*([\d:.]+)(?:[\t ].*)?$/)
if (!timing)
invalid(timingLine, 'invalid cue timing')
const start = timestamp(timing[1], timingLine)
const end = timestamp(timing[2], timingLine)
const start = timestamp(timing[1]!, timingLine)
const end = timestamp(timing[2]!, timingLine)
if (end < start)
invalid(timingLine, 'cue end precedes its start')
index++
skipEmpty()
if (index === lines.length)
invalid(timingLine, 'missing sprite image')
const image = rectangle(lines[index].trim(), vttUrl, index + 1)
const image = rectangle(lines[index]!.trim(), vttUrl, index + 1)
thumbnails.push({ start: Math.floor(start), end: Math.floor(end), ...image })
index++
}
@@ -1,7 +1,9 @@
import type { Events } from 'artplayer'
import type { PreviewOptions, StyleKey, Thumbnail } from './types'
import { findThumbnail } from './parseVtt'
export default function createPreview({ lifetime, thumbnails, progress, duration, setStyle, isMobile }) {
let timer = null
export default function createPreview({ lifetime, thumbnails, progress, duration, setStyle, isMobile }: PreviewOptions) {
let timer: ReturnType<typeof setTimeout> | null = null
let generation = 0
lifetime.own(() => {
generation++
@@ -11,23 +13,23 @@ export default function createPreview({ lifetime, thumbnails, progress, duration
clearTimeout(previous)
}
})
function style(control, key, value) {
function style(control: HTMLElement, key: StyleKey, value: string | number) {
if (!lifetime.closed)
setStyle(control, key, value)
setStyle<keyof CSSStyleDeclaration>(control, key, value)
}
function show(control, cue, width) {
function show(control: HTMLElement, cue: Thumbnail, width: number) {
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)
if (width <= Number(cue.w) / 2)
style(control, 'left', 0)
else if (width > progress.clientWidth - cue.w / 2)
style(control, 'left', `${progress.clientWidth - cue.w}px`)
else if (width > progress.clientWidth - Number(cue.w) / 2)
style(control, 'left', `${progress.clientWidth - Number(cue.w)}px`)
else
style(control, 'left', `${width - cue.w / 2}px`)
style(control, 'left', `${width - Number(cue.w) / 2}px`)
}
return control => async (type, percentage, event) => {
return (control: HTMLElement) => async (type: Events['setBar'][0], percentage: number, event?: Event) => {
if (lifetime.closed)
return
const dragging = type === 'played' && event && isMobile
@@ -1,4 +1,6 @@
export default async function requestVtt(url = '', lifetime) {
import type { Lifetime } from './types'
export default async function requestVtt(url = '', lifetime: Lifetime): Promise<string | void> {
if (lifetime.closed)
return
const controller = typeof AbortController === 'function' ? new AbortController() : null
@@ -9,11 +11,11 @@ export default async function requestVtt(url = '', lifetime) {
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 (response!.ok === false)
throw new Error(`Failed to fetch VTT thumbnails: HTTP ${response!.status}`)
if (lifetime.closed)
return
return await lifetime.wait(response.text())
return await lifetime.wait(response!.text())
}
finally {
release()
@@ -0,0 +1,45 @@
import type Artplayer from 'artplayer'
import type { Events, Utils } from 'artplayer'
export interface Option {
vtt?: string
style?: Partial<CSSStyleDeclaration>
}
export interface Result {
name: 'artplayerPluginVttThumbnail'
}
export interface Rectangle {
x: string
y: string
w: string
h: string
}
export interface Thumbnail extends Rectangle {
start: number
end: number
url: string
}
export type Cleanup = () => void
export type OwnedEvent = 'destroy' | 'setBar'
export interface Lifetime {
readonly closed: boolean
own: (cleanup: Cleanup) => () => boolean
listen: <Name extends OwnedEvent>(name: Name, callback: (...args: Events[Name]) => unknown) => void
wait: <Value>(value: Value | PromiseLike<Value>) => Promise<Value | void>
dispose: Cleanup
}
export type LifetimeHost = Pick<Artplayer, 'on' | 'off' | 'isDestroy'>
export type StyleKey = 'backgroundImage' | 'height' | 'width' | 'backgroundPosition' | 'left' | 'display'
export interface PreviewOptions {
lifetime: Lifetime
thumbnails: readonly Thumbnail[]
progress: Pick<HTMLElement, 'clientWidth'>
duration: () => number
setStyle: Utils['setStyle']
isMobile: boolean
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "types/**/*.d.ts", "../../types/assets.d.ts", "../../test/types/vtt-thumbnail-runtime.ts"]
}