refactor(core): CORE-15 type subtitles and own request and track lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 07:23:10 +08:00
1 parent 64d1939daa
commit 1b83081e8f
28 files changed
+11286 -405

No files matched your search

+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+259 -104
View File
@@ -361,13 +361,13 @@ class ResourceScope {
throw new ResourceCleanupError(errors);
}
}
const states$1 = /* @__PURE__ */ new WeakMap();
const states$2 = /* @__PURE__ */ new WeakMap();
const containers = /* @__PURE__ */ new WeakMap();
function beginLifecycle(owner) {
states$1.set(owner, { scope: new ResourceScope(), destroying: false });
states$2.set(owner, { scope: new ResourceScope(), destroying: false });
}
function stateOf(owner) {
const state2 = states$1.get(owner);
const state2 = states$2.get(owner);
if (!state2)
throw new Error("ArtPlayer lifecycle has not been initialized");
return state2;
@@ -1433,6 +1433,15 @@ function animationFrame(scope, callback) {
});
return release;
}
function requestController(scope) {
if (typeof AbortController === "undefined")
return void 0;
const controller = new AbortController();
scope.add(() => {
controller.abort();
});
return controller;
}
function wait(scope, delay = 0) {
if (scope.closed)
return Promise.resolve(false);
@@ -5297,17 +5306,17 @@ function beginSettingAdd(item) {
}
};
}
const states = /* @__PURE__ */ new WeakMap();
const states$1 = /* @__PURE__ */ new WeakMap();
function rememberSettingState(item, capture) {
if (capture)
states.set(item, capture);
states$1.set(item, capture);
else
states.delete(item);
states$1.delete(item);
}
function captureSettingItem(item) {
const descriptors = new Map(Reflect.ownKeys(item).map((key) => [key, Object.getOwnPropertyDescriptor(item, key)]));
const nodes = [];
const state2 = states.get(item);
const state2 = states$1.get(item);
const restoreState = state2?.();
for (const key of ["html", "icon", "tooltip"]) {
const value = descriptors.get(key)?.value;
@@ -6134,142 +6143,288 @@ class Storage {
}
}
const css = ".art-video-player {\n --art-theme: #f00;\n --art-font-color: #fff;\n --art-background-color: #000;\n --art-text-shadow-color: rgba(0, 0, 0, 0.5);\n --art-transition-duration: 0.2s;\n --art-padding: 10px;\n --art-border-radius: 3px;\n --art-progress-height: 6px;\n --art-progress-color: rgba(255, 255, 255, 0.25);\n --art-progress-top-gap: 10px;\n --art-hover-color: rgba(255, 255, 255, 0.25);\n --art-loaded-color: rgba(255, 255, 255, 0.25);\n --art-state-size: 80px;\n --art-state-opacity: 0.8;\n --art-bottom-height: 100px;\n --art-bottom-offset: 20px;\n --art-bottom-gap: 5px;\n --art-highlight-width: 8px;\n --art-highlight-color: rgba(255, 255, 255, 0.5);\n --art-control-height: 46px;\n --art-control-opacity: 0.75;\n --art-control-icon-size: 36px;\n --art-control-icon-scale: 1.1;\n --art-volume-height: 120px;\n --art-volume-handle-size: 14px;\n --art-lock-size: 36px;\n --art-indicator-scale: 0;\n --art-indicator-size: 16px;\n --art-fullscreen-web-index: 9999;\n --art-settings-icon-size: 24px;\n --art-settings-max-height: 300px;\n --art-selector-max-height: 300px;\n --art-contextmenus-min-width: 250px;\n --art-subtitle-font-size: 20px;\n --art-subtitle-gap: 5px;\n --art-subtitle-bottom: 15px;\n --art-subtitle-border: #000;\n --art-widget-background: rgba(0, 0, 0, 0.85);\n --art-tip-background: rgba(0, 0, 0, 0.7);\n --art-scrollbar-size: 4px;\n --art-scrollbar-background: rgba(255, 255, 255, 0.25);\n --art-scrollbar-background-hover: rgba(255, 255, 255, 0.5);\n --art-mini-progress-height: 2px;\n}\n.art-bg-cover {\n background-position: center center;\n background-repeat: no-repeat;\n background-size: cover;\n}\n.art-bottom-gradient {\n background-image: linear-gradient(to top, #000, rgba(0, 0, 0, 0.4), transparent);\n background-repeat: repeat-x;\n background-position: center bottom;\n}\n.art-backdrop-filter {\n -webkit-backdrop-filter: saturate(180%) blur(20px);\n backdrop-filter: saturate(180%) blur(20px);\n background-color: rgba(0, 0, 0, 0.75) !important;\n}\n.art-truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.art-video-player {\n position: relative;\n margin: 0 auto;\n width: 100%;\n height: 100%;\n outline: 0;\n zoom: 1;\n padding: 0;\n text-align: left;\n direction: ltr;\n font-size: 14px;\n line-height: 1.3;\n user-select: none;\n box-sizing: border-box;\n color: var(--art-font-color);\n background-color: var(--art-background-color);\n text-shadow: 0 0 2px var(--art-text-shadow-color);\n font-family: PingFang SC, Helvetica Neue, Microsoft YaHei, Roboto, Arial, sans-serif;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -ms-touch-action: manipulation;\n touch-action: manipulation;\n -ms-high-contrast-adjust: none;\n}\n.art-video-player *,\n.art-video-player *::before,\n.art-video-player *::after {\n box-sizing: border-box;\n}\n.art-video-player ::-webkit-scrollbar {\n width: var(--art-scrollbar-size);\n height: var(--art-scrollbar-size);\n}\n.art-video-player ::-webkit-scrollbar-thumb {\n background-color: var(--art-scrollbar-background);\n}\n.art-video-player ::-webkit-scrollbar-thumb:hover {\n background-color: var(--art-scrollbar-background-hover);\n}\n.art-video-player img {\n max-width: 100%;\n vertical-align: top;\n}\n.art-video-player svg {\n fill: var(--art-font-color);\n}\n.art-video-player a {\n color: var(--art-font-color);\n text-decoration: none;\n}\n.art-icon {\n line-height: 1;\n display: flex;\n justify-content: center;\n align-items: center;\n}\n.art-video-player.art-backdrop .art-contextmenus,\n.art-video-player.art-backdrop .art-info,\n.art-video-player.art-backdrop .art-settings,\n.art-video-player.art-backdrop .art-layer-auto-playback,\n.art-video-player.art-backdrop .art-selector-list,\n.art-video-player.art-backdrop .art-volume-inner {\n -webkit-backdrop-filter: saturate(180%) blur(20px);\n backdrop-filter: saturate(180%) blur(20px);\n background-color: rgba(0, 0, 0, 0.75) !important;\n}\n.art-video {\n position: absolute;\n inset: 0;\n z-index: 10;\n width: 100%;\n height: 100%;\n}\n.art-poster {\n position: absolute;\n inset: 0;\n z-index: 11;\n width: 100%;\n height: 100%;\n background-position: center center;\n background-repeat: no-repeat;\n background-size: cover;\n pointer-events: none;\n}\n.art-video-player .art-subtitle {\n display: none;\n justify-content: center;\n align-items: center;\n flex-direction: column;\n position: absolute;\n z-index: 20;\n width: 100%;\n padding: 0 5%;\n text-align: center;\n pointer-events: none;\n gap: var(--art-subtitle-gap);\n bottom: var(--art-subtitle-bottom);\n font-size: var(--art-subtitle-font-size);\n transition: bottom var(--art-transition-duration) ease;\n text-shadow: var(--art-subtitle-border) 1px 0 1px, var(--art-subtitle-border) 0 1px 1px, var(--art-subtitle-border) -1px 0 1px, var(--art-subtitle-border) 0 -1px 1px, var(--art-subtitle-border) 1px 1px 1px,Line truncated
class Subtitle extends Component {
function parseSubtitle(buffer, option) {
const text = new TextDecoder(option.encoding).decode(buffer);
switch (option.type || getExt(option.url)) {
case "srt":
return { vtt: option.onVttLoad(srtToVtt(text)) };
case "ass":
return { vtt: option.onVttLoad(assToVtt(text)) };
case "vtt":
return { vtt: option.onVttLoad(text) };
default:
return void 0;
}
}
class SubtitleRequest {
constructor(parent) {
this.scope = parent.child();
this.cancelled = new Promise((resolve) => {
this.scope.add(() => {
resolve(void 0);
});
});
this.signal = requestController(this.scope)?.signal;
}
get active() {
return !this.scope.closed;
}
cancel() {
this.scope.dispose();
}
async run(work) {
if (!this.active)
return void 0;
try {
return await Promise.race([work(), this.cancelled]);
} finally {
this.cancel();
}
}
}
const states = /* @__PURE__ */ new WeakMap();
function initSubtitleState(subtitle, art) {
const scope = getScope(art).child();
const state2 = { scope, revision: 0, render: 0, trackRevision: 0 };
states.set(subtitle, state2);
scope.add(() => {
releaseSubtitleURL(state2);
});
return state2;
}
function subtitleState(subtitle) {
return states.get(subtitle);
}
function beginSubtitleRequest(state2) {
const previous = state2.request;
const request = new SubtitleRequest(state2.scope);
state2.request = request;
state2.revision += 1;
previous?.cancel();
return request;
}
function releaseSubtitleURL(state2) {
const url = state2.ownedURL;
state2.ownedURL = void 0;
if (url)
URL.revokeObjectURL(url);
}
function renderSubtitle(subtitle) {
const state2 = subtitleState(subtitle);
if (state2.scope.closed)
return;
const revision = state2.revision;
const render = ++state2.render;
const active = () => !state2.scope.closed && state2.revision === revision && state2.render === render;
const { art } = subtitle;
const { $subtitle } = art.template;
$subtitle.innerHTML = "";
if (!subtitle.activeCues.length)
return;
art.emit("subtitleBeforeUpdate", subtitle.activeCues);
if (!active())
return;
const html2 = subtitle.activeCues.map((cue, index) => cue.text.split(/\r?\n/).filter((line) => line.trim()).map((line) => `<div class="art-subtitle-line" data-group="${index}">
${art.option.subtitle.escape ? escape(line) : line}
</div>`).join("")).join("");
if (!active())
return;
$subtitle.innerHTML = html2;
art.emit("subtitleAfterUpdate", subtitle.activeCues);
}
function replaceSubtitleTrack(subtitle, kind, url) {
const state2 = subtitleState(subtitle);
if (state2.scope.closed)
return;
const revision = ++state2.trackRevision;
const { art } = subtitle;
const { template } = art;
const previous = template.$track;
const parent = previous.parentNode;
const next = previous.nextSibling;
const track = document.createElement("track");
const scope = state2.scope.child();
scope.add(() => {
track.onload = null;
track.onerror = null;
});
let committed = false;
try {
track.default = true;
track.kind = kind;
track.src = url;
track.label = art.option.subtitle.name || "Artplayer";
track.track.mode = "hidden";
track.onload = () => {
if (!scope.closed && template.$track === track)
art.emit("subtitleLoad", subtitle.cues, subtitle.option);
};
track.onerror = () => {
if (!scope.closed && template.$track === track)
art.notice.show = new Error("Failed to load subtitle track");
};
if (scope.closed || state2.trackRevision !== revision) {
scope.dispose();
return;
}
previous.remove();
template.$video.appendChild(track);
template.$track = track;
const textTrack = subtitle.textTrack;
const cleanup = textTrack ? art.proxy(textTrack, "cuechange", () => {
if (!scope.closed && template.$track === track)
subtitle.update();
}) : () => {
};
scope.add(() => {
art.events.remove(cleanup);
});
if (scope.closed || state2.trackRevision !== revision) {
scope.dispose();
track.remove();
return;
}
const previousScope = state2.track;
state2.track = scope;
subtitle.destroyEvent = cleanup;
previous.onload = null;
previous.onerror = null;
committed = true;
try {
previousScope?.dispose();
} finally {
if (state2.ownedURL && state2.ownedURL !== url)
releaseSubtitleURL(state2);
}
} catch (error2) {
if (committed)
throw error2;
scope.dispose();
track.remove();
if (state2.scope.closed || state2.trackRevision !== revision)
throw error2;
template.$track = previous;
if (parent)
parent.insertBefore(previous, next?.parentNode === parent ? next : null);
throw error2;
}
}
const SubtitleBase = Component;
class Subtitle extends SubtitleBase {
constructor(art) {
super(art);
this.name = "subtitle";
this.option = null;
this.destroyEvent = () => null;
this.init(art.option.subtitle);
const state2 = initSubtitleState(this, art);
void this.init(art.option.subtitle).catch(() => {
});
let lastState = false;
art.on("video:timeupdate", () => {
if (!this.url)
const timeupdate = () => {
if (state2.scope.closed || !this.url)
return;
const state2 = this.art.template.$video.webkitDisplayingFullscreen;
if (typeof state2 !== "boolean")
return;
if (state2 !== lastState) {
lastState = state2;
this.createTrack(state2 ? "subtitles" : "metadata", this.url);
const fullscreen2 = art.template.$video.webkitDisplayingFullscreen;
if (typeof fullscreen2 === "boolean" && fullscreen2 !== lastState) {
lastState = fullscreen2;
this.createTrack(fullscreen2 ? "subtitles" : "metadata", this.url);
}
};
art.on("video:timeupdate", timeupdate);
state2.scope.add(() => {
art.off("video:timeupdate", timeupdate);
});
}
get url() {
return this.art.template.$track.src;
}
set url(url) {
this.switch(url);
void this.switch(url).catch(() => {
});
}
get textTrack() {
return this.art.template.$video?.textTracks?.[0];
}
get activeCues() {
if (!this.textTrack)
return [];
return Array.from(this.textTrack.activeCues);
return Array.from(this.textTrack?.activeCues || []);
}
get cues() {
if (!this.textTrack)
return [];
return Array.from(this.textTrack.cues);
return Array.from(this.textTrack?.cues || []);
}
style(key, value) {
const { $subtitle } = this.art.template;
if (typeof key === "object") {
return setStyles($subtitle, key);
}
return setStyle($subtitle, key, value);
return typeof key === "object" ? setStyles($subtitle, key) : setStyle($subtitle, key, value);
}
update() {
const {
option: { subtitle },
template: { $subtitle }
} = this.art;
$subtitle.innerHTML = "";
if (!this.activeCues.length)
return;
this.art.emit("subtitleBeforeUpdate", this.activeCues);
$subtitle.innerHTML = this.activeCues.map(
(cue, index) => cue.text.split(/\r?\n/).filter((line) => line.trim()).map(
(line) => `<div class="art-subtitle-line" data-group="${index}">
${subtitle.escape ? escape(line) : line}
</div>`
).join("")
).join("");
this.art.emit("subtitleAfterUpdate", this.activeCues);
renderSubtitle(this);
}
async switch(url, newOption = {}) {
const { i18n, notice, option } = this.art;
const subtitleOption = { ...option.subtitle, ...newOption, url };
const state2 = subtitleState(this);
const revision = state2.revision + 1;
const subUrl = await this.init(subtitleOption);
if (newOption.name) {
if (!state2.scope.closed && state2.revision === revision && newOption.name)
notice.show = `${i18n.get("Switch Subtitle")}: ${newOption.name}`;
}
return subUrl;
}
createTrack(kind, url) {
const { template, proxy, option } = this.art;
const { $video, $track } = template;
const $newTrack = createElement("track");
$newTrack.default = true;
$newTrack.kind = kind;
$newTrack.src = url;
$newTrack.label = option.subtitle.name || "Artplayer";
$newTrack.track.mode = "hidden";
$newTrack.onload = () => {
this.art.emit("subtitleLoad", this.cues, this.option);
};
this.art.events.remove(this.destroyEvent);
$track.onload = null;
remove($track);
append($video, $newTrack);
template.$track = $newTrack;
this.destroyEvent = proxy(this.textTrack, "cuechange", () => this.update());
replaceSubtitleTrack(this, kind, url);
}
async init(subtitleOption) {
const {
notice,
template: { $subtitle }
} = this.art;
if (!this.textTrack)
return null;
validator(subtitleOption, scheme.subtitle);
if (!subtitleOption.url)
return;
this.option = subtitleOption;
this.style(subtitleOption.style);
return fetch(subtitleOption.url).then((response) => response.arrayBuffer()).then((buffer) => {
const decoder = new TextDecoder(subtitleOption.encoding);
const text = decoder.decode(buffer);
switch (subtitleOption.type || getExt(subtitleOption.url)) {
case "srt": {
const vtt = srtToVtt(text);
const vttNew = subtitleOption.onVttLoad(vtt);
return vttToBlob(vttNew);
const state2 = subtitleState(this);
const request = beginSubtitleRequest(state2);
const { notice, template: { $subtitle } } = this.art;
return request.run(async () => {
if (!this.textTrack)
return null;
validator(subtitleOption, scheme.subtitle);
if (!subtitleOption.url)
return void 0;
this.option = subtitleOption;
this.style(subtitleOption.style);
let generated;
try {
if (!request.active)
return void 0;
const response = await fetch(subtitleOption.url, { signal: request.signal });
if (!request.active)
return void 0;
if (!response.ok)
throw new Error(`Failed to load subtitle: ${response.status} ${response.statusText}`);
const buffer = await response.arrayBuffer();
if (!request.active)
return void 0;
const vtt = parseSubtitle(buffer, subtitleOption);
if (!request.active)
return void 0;
const subUrl = vtt === void 0 ? subtitleOption.url : generated = vttToBlob(vtt.vtt);
if (!request.active)
return void 0;
$subtitle.innerHTML = "";
const trackRevision = state2.trackRevision + 1;
if (this.url !== subUrl)
this.createTrack("metadata", subUrl);
if (!request.active || state2.trackRevision > trackRevision)
return void 0;
if (generated) {
state2.ownedURL = generated;
generated = void 0;
}
case "ass": {
const vtt = assToVtt(text);
const vttNew = subtitleOption.onVttLoad(vtt);
return vttToBlob(vttNew);
}
case "vtt": {
const vttNew = subtitleOption.onVttLoad(text);
return vttToBlob(vttNew);
}
default:
return subtitleOption.url;
}
}).then((subUrl) => {
$subtitle.innerHTML = "";
if (this.url === subUrl)
return subUrl;
URL.revokeObjectURL(this.url);
this.createTrack("metadata", subUrl);
return subUrl;
}).catch((err) => {
$subtitle.innerHTML = "";
notice.show = err;
throw err;
} catch (error2) {
if (!request.active)
return void 0;
$subtitle.innerHTML = "";
notice.show = error2;
throw error2;
} finally {
if (generated) {
if (!state2.scope.closed && this.url === generated)
state2.ownedURL = generated;
else
URL.revokeObjectURL(generated);
}
}
});
}
}
+1 -1
View File
@@ -49,7 +49,7 @@
"ci:build": "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/playback.test.js test/dash-control.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:unit": "node --test test/playback.test.js test/dash-control.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": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
+32
View File
@@ -4,6 +4,38 @@ ArtPlayer keeps its existing constructor, player mixins, plugins and DOM/CSS hoo
The production entry is still `src/index.js`; this document marks actual migrated
boundaries rather than describing the entire core as TypeScript.
## Subtitles (CORE-15)
src/subtitle/index.ts keeps the Component prototype, bound update, public methods and
normal URL results. The request, parse, state, track and render modules now separate
transport cancellation, conversion, ownership, native DOM events and cue rendering.
The offset mixin remains the player property entry, with original cue bounds retained
on native cue objects. Validation evidence and remaining release gates live in refactor/.
request.ts races work against scope closure. Superseded requests settle undefined even
when the transport ignores AbortSignal; active failures reject. state.ts owns request
and track scopes and only generated object URLs. A caller-owned URL is never revoked.
Replacing a track for native fullscreen keeps the same owned subtitle resource.
parse.ts delegates SRT/ASS conversion to the public utility parsers, preserving callback
this and type/extension/encoding behavior. Unknown formats retain the caller URL.
render.ts retains the line/group markup and uses current global subtitle.escape; a
switch-specific escape override does not silently change the legacy rendering contract.
Before-update listeners may still edit cues; switching/destroying prevents obsolete DOM
writes. Disabled text tracks return empty cue arrays; native cue identity is preserved.
To change transport policy start with request.ts and index.ts; to change native track
ownership start with track.ts and state.ts. Run test/subtitle.test.js, the browser
subtitle-lifecycle and declarations specs, and strict source/consumer type checks.
Track generations prevent a reentrant replacement from losing its callbacks when an
older registration returns or throws. Failed insertion restores the previous node;
cleanup failures after commit retain the new resource and remain observable to callers.
Track load errors notify only while that node is current; all owned load/error/cuechange
callbacks are cleared on replacement or destroy. HTTP errors reject before conversion.
Constructor and URL-setter calls report through notice and consume their otherwise
unobservable rejected promises; explicit init/switch calls keep rejection semantics.
Public cue event inference remains tracked by BASE-TYPE-07 / CORE-21. Browser-engine
tests do not certify physical iOS/Safari fullscreen or every proxy implementation.
## Settings (CORE-14)
setting/index.ts remains the specialized manager: panel cache keys are option arrays,
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+259 -104
View File
@@ -361,13 +361,13 @@ class ResourceScope {
throw new ResourceCleanupError(errors);
}
}
const states$1 = /* @__PURE__ */ new WeakMap();
const states$2 = /* @__PURE__ */ new WeakMap();
const containers = /* @__PURE__ */ new WeakMap();
function beginLifecycle(owner) {
states$1.set(owner, { scope: new ResourceScope(), destroying: false });
states$2.set(owner, { scope: new ResourceScope(), destroying: false });
}
function stateOf(owner) {
const state2 = states$1.get(owner);
const state2 = states$2.get(owner);
if (!state2)
throw new Error("ArtPlayer lifecycle has not been initialized");
return state2;
@@ -1433,6 +1433,15 @@ function animationFrame(scope, callback) {
});
return release;
}
function requestController(scope) {
if (typeof AbortController === "undefined")
return void 0;
const controller = new AbortController();
scope.add(() => {
controller.abort();
});
return controller;
}
function wait(scope, delay = 0) {
if (scope.closed)
return Promise.resolve(false);
@@ -5297,17 +5306,17 @@ function beginSettingAdd(item) {
}
};
}
const states = /* @__PURE__ */ new WeakMap();
const states$1 = /* @__PURE__ */ new WeakMap();
function rememberSettingState(item, capture) {
if (capture)
states.set(item, capture);
states$1.set(item, capture);
else
states.delete(item);
states$1.delete(item);
}
function captureSettingItem(item) {
const descriptors = new Map(Reflect.ownKeys(item).map((key) => [key, Object.getOwnPropertyDescriptor(item, key)]));
const nodes = [];
const state2 = states.get(item);
const state2 = states$1.get(item);
const restoreState = state2?.();
for (const key of ["html", "icon", "tooltip"]) {
const value = descriptors.get(key)?.value;
@@ -6134,142 +6143,288 @@ class Storage {
}
}
const css = ".art-video-player {\n --art-theme: #f00;\n --art-font-color: #fff;\n --art-background-color: #000;\n --art-text-shadow-color: rgba(0, 0, 0, 0.5);\n --art-transition-duration: 0.2s;\n --art-padding: 10px;\n --art-border-radius: 3px;\n --art-progress-height: 6px;\n --art-progress-color: rgba(255, 255, 255, 0.25);\n --art-progress-top-gap: 10px;\n --art-hover-color: rgba(255, 255, 255, 0.25);\n --art-loaded-color: rgba(255, 255, 255, 0.25);\n --art-state-size: 80px;\n --art-state-opacity: 0.8;\n --art-bottom-height: 100px;\n --art-bottom-offset: 20px;\n --art-bottom-gap: 5px;\n --art-highlight-width: 8px;\n --art-highlight-color: rgba(255, 255, 255, 0.5);\n --art-control-height: 46px;\n --art-control-opacity: 0.75;\n --art-control-icon-size: 36px;\n --art-control-icon-scale: 1.1;\n --art-volume-height: 120px;\n --art-volume-handle-size: 14px;\n --art-lock-size: 36px;\n --art-indicator-scale: 0;\n --art-indicator-size: 16px;\n --art-fullscreen-web-index: 9999;\n --art-settings-icon-size: 24px;\n --art-settings-max-height: 300px;\n --art-selector-max-height: 300px;\n --art-contextmenus-min-width: 250px;\n --art-subtitle-font-size: 20px;\n --art-subtitle-gap: 5px;\n --art-subtitle-bottom: 15px;\n --art-subtitle-border: #000;\n --art-widget-background: rgba(0, 0, 0, 0.85);\n --art-tip-background: rgba(0, 0, 0, 0.7);\n --art-scrollbar-size: 4px;\n --art-scrollbar-background: rgba(255, 255, 255, 0.25);\n --art-scrollbar-background-hover: rgba(255, 255, 255, 0.5);\n --art-mini-progress-height: 2px;\n}\n.art-bg-cover {\n background-position: center center;\n background-repeat: no-repeat;\n background-size: cover;\n}\n.art-bottom-gradient {\n background-image: linear-gradient(to top, #000, rgba(0, 0, 0, 0.4), transparent);\n background-repeat: repeat-x;\n background-position: center bottom;\n}\n.art-backdrop-filter {\n -webkit-backdrop-filter: saturate(180%) blur(20px);\n backdrop-filter: saturate(180%) blur(20px);\n background-color: rgba(0, 0, 0, 0.75) !important;\n}\n.art-truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.art-video-player {\n position: relative;\n margin: 0 auto;\n width: 100%;\n height: 100%;\n outline: 0;\n zoom: 1;\n padding: 0;\n text-align: left;\n direction: ltr;\n font-size: 14px;\n line-height: 1.3;\n user-select: none;\n box-sizing: border-box;\n color: var(--art-font-color);\n background-color: var(--art-background-color);\n text-shadow: 0 0 2px var(--art-text-shadow-color);\n font-family: PingFang SC, Helvetica Neue, Microsoft YaHei, Roboto, Arial, sans-serif;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -ms-touch-action: manipulation;\n touch-action: manipulation;\n -ms-high-contrast-adjust: none;\n}\n.art-video-player *,\n.art-video-player *::before,\n.art-video-player *::after {\n box-sizing: border-box;\n}\n.art-video-player ::-webkit-scrollbar {\n width: var(--art-scrollbar-size);\n height: var(--art-scrollbar-size);\n}\n.art-video-player ::-webkit-scrollbar-thumb {\n background-color: var(--art-scrollbar-background);\n}\n.art-video-player ::-webkit-scrollbar-thumb:hover {\n background-color: var(--art-scrollbar-background-hover);\n}\n.art-video-player img {\n max-width: 100%;\n vertical-align: top;\n}\n.art-video-player svg {\n fill: var(--art-font-color);\n}\n.art-video-player a {\n color: var(--art-font-color);\n text-decoration: none;\n}\n.art-icon {\n line-height: 1;\n display: flex;\n justify-content: center;\n align-items: center;\n}\n.art-video-player.art-backdrop .art-contextmenus,\n.art-video-player.art-backdrop .art-info,\n.art-video-player.art-backdrop .art-settings,\n.art-video-player.art-backdrop .art-layer-auto-playback,\n.art-video-player.art-backdrop .art-selector-list,\n.art-video-player.art-backdrop .art-volume-inner {\n -webkit-backdrop-filter: saturate(180%) blur(20px);\n backdrop-filter: saturate(180%) blur(20px);\n background-color: rgba(0, 0, 0, 0.75) !important;\n}\n.art-video {\n position: absolute;\n inset: 0;\n z-index: 10;\n width: 100%;\n height: 100%;\n}\n.art-poster {\n position: absolute;\n inset: 0;\n z-index: 11;\n width: 100%;\n height: 100%;\n background-position: center center;\n background-repeat: no-repeat;\n background-size: cover;\n pointer-events: none;\n}\n.art-video-player .art-subtitle {\n display: none;\n justify-content: center;\n align-items: center;\n flex-direction: column;\n position: absolute;\n z-index: 20;\n width: 100%;\n padding: 0 5%;\n text-align: center;\n pointer-events: none;\n gap: var(--art-subtitle-gap);\n bottom: var(--art-subtitle-bottom);\n font-size: var(--art-subtitle-font-size);\n transition: bottom var(--art-transition-duration) ease;\n text-shadow: var(--art-subtitle-border) 1px 0 1px, var(--art-subtitle-border) 0 1px 1px, var(--art-subtitle-border) -1px 0 1px, var(--art-subtitle-border) 0 -1px 1px, var(--art-subtitle-border) 1px 1px 1px,Line truncated
class Subtitle extends Component {
function parseSubtitle(buffer, option) {
const text = new TextDecoder(option.encoding).decode(buffer);
switch (option.type || getExt(option.url)) {
case "srt":
return { vtt: option.onVttLoad(srtToVtt(text)) };
case "ass":
return { vtt: option.onVttLoad(assToVtt(text)) };
case "vtt":
return { vtt: option.onVttLoad(text) };
default:
return void 0;
}
}
class SubtitleRequest {
constructor(parent) {
this.scope = parent.child();
this.cancelled = new Promise((resolve) => {
this.scope.add(() => {
resolve(void 0);
});
});
this.signal = requestController(this.scope)?.signal;
}
get active() {
return !this.scope.closed;
}
cancel() {
this.scope.dispose();
}
async run(work) {
if (!this.active)
return void 0;
try {
return await Promise.race([work(), this.cancelled]);
} finally {
this.cancel();
}
}
}
const states = /* @__PURE__ */ new WeakMap();
function initSubtitleState(subtitle, art) {
const scope = getScope(art).child();
const state2 = { scope, revision: 0, render: 0, trackRevision: 0 };
states.set(subtitle, state2);
scope.add(() => {
releaseSubtitleURL(state2);
});
return state2;
}
function subtitleState(subtitle) {
return states.get(subtitle);
}
function beginSubtitleRequest(state2) {
const previous = state2.request;
const request = new SubtitleRequest(state2.scope);
state2.request = request;
state2.revision += 1;
previous?.cancel();
return request;
}
function releaseSubtitleURL(state2) {
const url = state2.ownedURL;
state2.ownedURL = void 0;
if (url)
URL.revokeObjectURL(url);
}
function renderSubtitle(subtitle) {
const state2 = subtitleState(subtitle);
if (state2.scope.closed)
return;
const revision = state2.revision;
const render = ++state2.render;
const active = () => !state2.scope.closed && state2.revision === revision && state2.render === render;
const { art } = subtitle;
const { $subtitle } = art.template;
$subtitle.innerHTML = "";
if (!subtitle.activeCues.length)
return;
art.emit("subtitleBeforeUpdate", subtitle.activeCues);
if (!active())
return;
const html2 = subtitle.activeCues.map((cue, index) => cue.text.split(/\r?\n/).filter((line) => line.trim()).map((line) => `<div class="art-subtitle-line" data-group="${index}">
${art.option.subtitle.escape ? escape(line) : line}
</div>`).join("")).join("");
if (!active())
return;
$subtitle.innerHTML = html2;
art.emit("subtitleAfterUpdate", subtitle.activeCues);
}
function replaceSubtitleTrack(subtitle, kind, url) {
const state2 = subtitleState(subtitle);
if (state2.scope.closed)
return;
const revision = ++state2.trackRevision;
const { art } = subtitle;
const { template } = art;
const previous = template.$track;
const parent = previous.parentNode;
const next = previous.nextSibling;
const track = document.createElement("track");
const scope = state2.scope.child();
scope.add(() => {
track.onload = null;
track.onerror = null;
});
let committed = false;
try {
track.default = true;
track.kind = kind;
track.src = url;
track.label = art.option.subtitle.name || "Artplayer";
track.track.mode = "hidden";
track.onload = () => {
if (!scope.closed && template.$track === track)
art.emit("subtitleLoad", subtitle.cues, subtitle.option);
};
track.onerror = () => {
if (!scope.closed && template.$track === track)
art.notice.show = new Error("Failed to load subtitle track");
};
if (scope.closed || state2.trackRevision !== revision) {
scope.dispose();
return;
}
previous.remove();
template.$video.appendChild(track);
template.$track = track;
const textTrack = subtitle.textTrack;
const cleanup = textTrack ? art.proxy(textTrack, "cuechange", () => {
if (!scope.closed && template.$track === track)
subtitle.update();
}) : () => {
};
scope.add(() => {
art.events.remove(cleanup);
});
if (scope.closed || state2.trackRevision !== revision) {
scope.dispose();
track.remove();
return;
}
const previousScope = state2.track;
state2.track = scope;
subtitle.destroyEvent = cleanup;
previous.onload = null;
previous.onerror = null;
committed = true;
try {
previousScope?.dispose();
} finally {
if (state2.ownedURL && state2.ownedURL !== url)
releaseSubtitleURL(state2);
}
} catch (error2) {
if (committed)
throw error2;
scope.dispose();
track.remove();
if (state2.scope.closed || state2.trackRevision !== revision)
throw error2;
template.$track = previous;
if (parent)
parent.insertBefore(previous, next?.parentNode === parent ? next : null);
throw error2;
}
}
const SubtitleBase = Component;
class Subtitle extends SubtitleBase {
constructor(art) {
super(art);
this.name = "subtitle";
this.option = null;
this.destroyEvent = () => null;
this.init(art.option.subtitle);
const state2 = initSubtitleState(this, art);
void this.init(art.option.subtitle).catch(() => {
});
let lastState = false;
art.on("video:timeupdate", () => {
if (!this.url)
const timeupdate = () => {
if (state2.scope.closed || !this.url)
return;
const state2 = this.art.template.$video.webkitDisplayingFullscreen;
if (typeof state2 !== "boolean")
return;
if (state2 !== lastState) {
lastState = state2;
this.createTrack(state2 ? "subtitles" : "metadata", this.url);
const fullscreen2 = art.template.$video.webkitDisplayingFullscreen;
if (typeof fullscreen2 === "boolean" && fullscreen2 !== lastState) {
lastState = fullscreen2;
this.createTrack(fullscreen2 ? "subtitles" : "metadata", this.url);
}
};
art.on("video:timeupdate", timeupdate);
state2.scope.add(() => {
art.off("video:timeupdate", timeupdate);
});
}
get url() {
return this.art.template.$track.src;
}
set url(url) {
this.switch(url);
void this.switch(url).catch(() => {
});
}
get textTrack() {
return this.art.template.$video?.textTracks?.[0];
}
get activeCues() {
if (!this.textTrack)
return [];
return Array.from(this.textTrack.activeCues);
return Array.from(this.textTrack?.activeCues || []);
}
get cues() {
if (!this.textTrack)
return [];
return Array.from(this.textTrack.cues);
return Array.from(this.textTrack?.cues || []);
}
style(key, value) {
const { $subtitle } = this.art.template;
if (typeof key === "object") {
return setStyles($subtitle, key);
}
return setStyle($subtitle, key, value);
return typeof key === "object" ? setStyles($subtitle, key) : setStyle($subtitle, key, value);
}
update() {
const {
option: { subtitle },
template: { $subtitle }
} = this.art;
$subtitle.innerHTML = "";
if (!this.activeCues.length)
return;
this.art.emit("subtitleBeforeUpdate", this.activeCues);
$subtitle.innerHTML = this.activeCues.map(
(cue, index) => cue.text.split(/\r?\n/).filter((line) => line.trim()).map(
(line) => `<div class="art-subtitle-line" data-group="${index}">
${subtitle.escape ? escape(line) : line}
</div>`
).join("")
).join("");
this.art.emit("subtitleAfterUpdate", this.activeCues);
renderSubtitle(this);
}
async switch(url, newOption = {}) {
const { i18n, notice, option } = this.art;
const subtitleOption = { ...option.subtitle, ...newOption, url };
const state2 = subtitleState(this);
const revision = state2.revision + 1;
const subUrl = await this.init(subtitleOption);
if (newOption.name) {
if (!state2.scope.closed && state2.revision === revision && newOption.name)
notice.show = `${i18n.get("Switch Subtitle")}: ${newOption.name}`;
}
return subUrl;
}
createTrack(kind, url) {
const { template, proxy, option } = this.art;
const { $video, $track } = template;
const $newTrack = createElement("track");
$newTrack.default = true;
$newTrack.kind = kind;
$newTrack.src = url;
$newTrack.label = option.subtitle.name || "Artplayer";
$newTrack.track.mode = "hidden";
$newTrack.onload = () => {
this.art.emit("subtitleLoad", this.cues, this.option);
};
this.art.events.remove(this.destroyEvent);
$track.onload = null;
remove($track);
append($video, $newTrack);
template.$track = $newTrack;
this.destroyEvent = proxy(this.textTrack, "cuechange", () => this.update());
replaceSubtitleTrack(this, kind, url);
}
async init(subtitleOption) {
const {
notice,
template: { $subtitle }
} = this.art;
if (!this.textTrack)
return null;
validator(subtitleOption, scheme.subtitle);
if (!subtitleOption.url)
return;
this.option = subtitleOption;
this.style(subtitleOption.style);
return fetch(subtitleOption.url).then((response) => response.arrayBuffer()).then((buffer) => {
const decoder = new TextDecoder(subtitleOption.encoding);
const text = decoder.decode(buffer);
switch (subtitleOption.type || getExt(subtitleOption.url)) {
case "srt": {
const vtt = srtToVtt(text);
const vttNew = subtitleOption.onVttLoad(vtt);
return vttToBlob(vttNew);
const state2 = subtitleState(this);
const request = beginSubtitleRequest(state2);
const { notice, template: { $subtitle } } = this.art;
return request.run(async () => {
if (!this.textTrack)
return null;
validator(subtitleOption, scheme.subtitle);
if (!subtitleOption.url)
return void 0;
this.option = subtitleOption;
this.style(subtitleOption.style);
let generated;
try {
if (!request.active)
return void 0;
const response = await fetch(subtitleOption.url, { signal: request.signal });
if (!request.active)
return void 0;
if (!response.ok)
throw new Error(`Failed to load subtitle: ${response.status} ${response.statusText}`);
const buffer = await response.arrayBuffer();
if (!request.active)
return void 0;
const vtt = parseSubtitle(buffer, subtitleOption);
if (!request.active)
return void 0;
const subUrl = vtt === void 0 ? subtitleOption.url : generated = vttToBlob(vtt.vtt);
if (!request.active)
return void 0;
$subtitle.innerHTML = "";
const trackRevision = state2.trackRevision + 1;
if (this.url !== subUrl)
this.createTrack("metadata", subUrl);
if (!request.active || state2.trackRevision > trackRevision)
return void 0;
if (generated) {
state2.ownedURL = generated;
generated = void 0;
}
case "ass": {
const vtt = assToVtt(text);
const vttNew = subtitleOption.onVttLoad(vtt);
return vttToBlob(vttNew);
}
case "vtt": {
const vttNew = subtitleOption.onVttLoad(text);
return vttToBlob(vttNew);
}
default:
return subtitleOption.url;
}
}).then((subUrl) => {
$subtitle.innerHTML = "";
if (this.url === subUrl)
return subUrl;
URL.revokeObjectURL(this.url);
this.createTrack("metadata", subUrl);
return subUrl;
}).catch((err) => {
$subtitle.innerHTML = "";
notice.show = err;
throw err;
} catch (error2) {
if (!request.active)
return void 0;
$subtitle.innerHTML = "";
notice.show = error2;
throw error2;
} finally {
if (generated) {
if (!state2.scope.closed && this.url === generated)
state2.ownedURL = generated;
else
URL.revokeObjectURL(generated);
}
}
});
}
}
@@ -1,20 +1,21 @@
import type { SubtitleOffsetHost } from '../subtitle/types'
import { clamp, def } from '../utils'
export default function subtitleOffsetMix(art) {
export default function subtitleOffsetMix(art: SubtitleOffsetHost): void {
const { notice, i18n, template } = art
def(art, 'subtitleOffset', {
get() {
return template.$track?.offset || 0
},
set(value) {
set(value: number) {
const { cues } = art.subtitle
if (!template.$track || cues.length === 0)
return
const offset = clamp(value, -10, 10)
template.$track.offset = offset
for (let index = 0; index < cues.length; index++) {
const cue = cues[index]
const cue = cues[index]!
cue.originalStartTime = cue.originalStartTime ?? cue.startTime
cue.originalEndTime = cue.originalEndTime ?? cue.endTime
cue.startTime = clamp(cue.originalStartTime + offset, 0, art.duration)
-183
View File
@@ -1,183 +0,0 @@
import validator from 'option-validator'
import scheme from './scheme'
import {
append,
assToVtt,
createElement,
escape,
getExt,
remove,
setStyle,
setStyles,
srtToVtt,
vttToBlob,
} from './utils'
import Component from './utils/component'
export default class Subtitle extends Component {
constructor(art) {
super(art)
this.name = 'subtitle'
this.option = null
this.destroyEvent = () => null
this.init(art.option.subtitle)
let lastState = false
art.on('video:timeupdate', () => {
if (!this.url)
return
const state = this.art.template.$video.webkitDisplayingFullscreen
if (typeof state !== 'boolean')
return
if (state !== lastState) {
lastState = state
this.createTrack(state ? 'subtitles' : 'metadata', this.url)
}
})
}
get url() {
return this.art.template.$track.src
}
set url(url) {
this.switch(url)
}
get textTrack() {
return this.art.template.$video?.textTracks?.[0]
}
get activeCues() {
if (!this.textTrack)
return []
return Array.from(this.textTrack.activeCues)
}
get cues() {
if (!this.textTrack)
return []
return Array.from(this.textTrack.cues)
}
style(key, value) {
const { $subtitle } = this.art.template
if (typeof key === 'object') {
return setStyles($subtitle, key)
}
return setStyle($subtitle, key, value)
}
update() {
const {
option: { subtitle },
template: { $subtitle },
} = this.art
$subtitle.innerHTML = ''
if (!this.activeCues.length)
return
this.art.emit('subtitleBeforeUpdate', this.activeCues)
$subtitle.innerHTML = this.activeCues
.map((cue, index) =>
cue.text
.split(/\r?\n/)
.filter(line => line.trim())
.map(
line =>
`<div class="art-subtitle-line" data-group="${index}">
${subtitle.escape ? escape(line) : line}
</div>`,
)
.join(''),
)
.join('')
this.art.emit('subtitleAfterUpdate', this.activeCues)
}
async switch(url, newOption = {}) {
const { i18n, notice, option } = this.art
const subtitleOption = { ...option.subtitle, ...newOption, url }
const subUrl = await this.init(subtitleOption)
if (newOption.name) {
notice.show = `${i18n.get('Switch Subtitle')}: ${newOption.name}`
}
return subUrl
}
createTrack(kind, url) {
const { template, proxy, option } = this.art
const { $video, $track } = template
const $newTrack = createElement('track')
$newTrack.default = true
$newTrack.kind = kind
$newTrack.src = url
$newTrack.label = option.subtitle.name || 'Artplayer'
$newTrack.track.mode = 'hidden'
$newTrack.onload = () => {
this.art.emit('subtitleLoad', this.cues, this.option)
}
this.art.events.remove(this.destroyEvent)
$track.onload = null
remove($track)
append($video, $newTrack)
template.$track = $newTrack
this.destroyEvent = proxy(this.textTrack, 'cuechange', () => this.update())
}
async init(subtitleOption) {
const {
notice,
template: { $subtitle },
} = this.art
if (!this.textTrack)
return null
validator(subtitleOption, scheme.subtitle)
if (!subtitleOption.url)
return
this.option = subtitleOption
this.style(subtitleOption.style)
return fetch(subtitleOption.url)
.then(response => response.arrayBuffer())
.then((buffer) => {
const decoder = new TextDecoder(subtitleOption.encoding)
const text = decoder.decode(buffer)
switch (subtitleOption.type || getExt(subtitleOption.url)) {
case 'srt': {
const vtt = srtToVtt(text)
const vttNew = subtitleOption.onVttLoad(vtt)
return vttToBlob(vttNew)
}
case 'ass': {
const vtt = assToVtt(text)
const vttNew = subtitleOption.onVttLoad(vtt)
return vttToBlob(vttNew)
}
case 'vtt': {
const vttNew = subtitleOption.onVttLoad(text)
return vttToBlob(vttNew)
}
default:
return subtitleOption.url
}
})
.then((subUrl) => {
$subtitle.innerHTML = ''
if (this.url === subUrl)
return subUrl
URL.revokeObjectURL(this.url)
this.createTrack('metadata', subUrl)
return subUrl
})
.catch((err) => {
$subtitle.innerHTML = ''
notice.show = err
throw err
})
}
}
+151
View File
@@ -0,0 +1,151 @@
import type { Subtitle as SubtitleInput } from '../../types/subtitle'
import type { SubtitleCue, SubtitleHost, SubtitleOption } from './types'
import validator from 'option-validator'
import scheme from '../scheme'
import { setStyle, setStyles, vttToBlob } from '../utils'
import Component from '../utils/component'
import { parseSubtitle } from './parse'
import { renderSubtitle } from './render'
import { beginSubtitleRequest, initSubtitleState, subtitleState } from './state'
import { replaceSubtitleTrack } from './track'
// Retain Component's runtime prototype and method binding without its registry update type.
const SubtitleBase: new (art: SubtitleHost) => Omit<Component<SubtitleHost>, 'update'> = Component
export default class Subtitle extends SubtitleBase {
declare name: string
declare option: SubtitleOption | null
declare destroyEvent: () => unknown
constructor(art: SubtitleHost) {
super(art)
this.name = 'subtitle'
this.option = null
this.destroyEvent = () => null
const state = initSubtitleState(this, art)
// init reports active failures through notice; construction has no promise consumer.
void this.init(art.option.subtitle).catch(() => {})
let lastState = false
const timeupdate = () => {
if (state.scope.closed || !this.url)
return
const fullscreen = art.template.$video.webkitDisplayingFullscreen
if (typeof fullscreen === 'boolean' && fullscreen !== lastState) {
lastState = fullscreen
this.createTrack(fullscreen ? 'subtitles' : 'metadata', this.url)
}
}
art.on('video:timeupdate', timeupdate)
state.scope.add(() => {
art.off('video:timeupdate', timeupdate)
})
}
get url(): string {
return this.art.template.$track.src
}
set url(url: string) {
void this.switch(url).catch(() => {})
}
get textTrack(): TextTrack | undefined {
return this.art.template.$video?.textTracks?.[0]
}
get activeCues(): SubtitleCue[] {
// ArtPlayer's converted resources are WebVTT; disabled tracks expose null cue lists.
return Array.from(this.textTrack?.activeCues || []) as SubtitleCue[]
}
get cues(): SubtitleCue[] {
return Array.from(this.textTrack?.cues || []) as SubtitleCue[]
}
style(styles: Partial<CSSStyleDeclaration>): HTMLDivElement
style(key: string, value: string | number): HTMLDivElement
style(key: string | Partial<CSSStyleDeclaration>, value?: string | number): HTMLDivElement {
const { $subtitle } = this.art.template
return typeof key === 'object' ? setStyles($subtitle, key) : setStyle($subtitle, key, value)
}
update(): void {
renderSubtitle(this)
}
async switch(url: string, newOption: SubtitleInput = {}): Promise<string | null | undefined> {
const { i18n, notice, option } = this.art
const subtitleOption = { ...option.subtitle, ...newOption, url }
const state = subtitleState(this)
const revision = state.revision + 1
const subUrl = await this.init(subtitleOption)
if (!state.scope.closed && state.revision === revision && newOption.name)
notice.show = `${i18n.get('Switch Subtitle')}: ${newOption.name}`
return subUrl
}
createTrack(kind: string, url: string): void {
replaceSubtitleTrack(this, kind, url)
}
async init(subtitleOption: SubtitleOption): Promise<string | null | undefined> {
const state = subtitleState(this)
const request = beginSubtitleRequest(state)
const { notice, template: { $subtitle } } = this.art
return request.run(async () => {
if (!this.textTrack)
return null
validator(subtitleOption, scheme.subtitle)
if (!subtitleOption.url)
return undefined
this.option = subtitleOption
this.style(subtitleOption.style)
let generated: string | undefined
try {
if (!request.active)
return undefined
const response = await fetch(subtitleOption.url, { signal: request.signal })
if (!request.active)
return undefined
if (!response.ok)
throw new Error(`Failed to load subtitle: ${response.status} ${response.statusText}`)
const buffer = await response.arrayBuffer()
if (!request.active)
return undefined
const vtt = parseSubtitle(buffer, subtitleOption)
if (!request.active)
return undefined
const subUrl = vtt === undefined ? subtitleOption.url : (generated = vttToBlob(vtt.vtt))
if (!request.active)
return undefined
$subtitle.innerHTML = ''
const trackRevision = state.trackRevision + 1
if (this.url !== subUrl)
this.createTrack('metadata', subUrl)
if (!request.active || state.trackRevision > trackRevision)
return undefined
if (generated) {
state.ownedURL = generated
generated = undefined
}
return subUrl
}
catch (error) {
if (!request.active)
return undefined
$subtitle.innerHTML = ''
notice.show = error
throw error
}
finally {
if (generated) {
// A replacement can commit before an old listener cleanup throws.
if (!state.scope.closed && this.url === generated)
state.ownedURL = generated
else
URL.revokeObjectURL(generated)
}
}
})
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { SubtitleOption } from './types'
import { getExt } from '../utils'
import { assToVtt, srtToVtt } from '../utils/subtitle'
// Keep conversion and callback binding identical to the public utility parsers.
export function parseSubtitle(buffer: ArrayBuffer, option: SubtitleOption): { vtt: string } | undefined {
const text = new TextDecoder(option.encoding).decode(buffer)
switch (option.type || getExt(option.url)) {
case 'srt': return { vtt: option.onVttLoad(srtToVtt(text)) }
case 'ass': return { vtt: option.onVttLoad(assToVtt(text)) }
case 'vtt': return { vtt: option.onVttLoad(text) }
default: return undefined
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { SubtitleView } from './types'
import { escape } from '../utils'
import { subtitleState } from './state'
export function renderSubtitle(subtitle: SubtitleView): void {
const state = subtitleState(subtitle)
if (state.scope.closed)
return
const revision = state.revision
const render = ++state.render
const active = () => !state.scope.closed && state.revision === revision && state.render === render
const { art } = subtitle
const { $subtitle } = art.template
$subtitle.innerHTML = ''
if (!subtitle.activeCues.length)
return
art.emit('subtitleBeforeUpdate', subtitle.activeCues)
if (!active())
return
const html = subtitle.activeCues.map((cue, index) => cue.text
.split(/\r?\n/)
.filter(line => line.trim())
.map(line => `<div class="art-subtitle-line" data-group="${index}">
${art.option.subtitle.escape ? escape(line) : line}
</div>`)
.join('')).join('')
if (!active())
return
$subtitle.innerHTML = html
art.emit('subtitleAfterUpdate', subtitle.activeCues)
}
@@ -0,0 +1,38 @@
import type ResourceScope from '../lifecycle/scope'
import { requestController } from '../lifecycle/resources'
// Cancellation settles callers even when a fetch adapter ignores AbortSignal.
export default class SubtitleRequest {
readonly scope: ResourceScope
readonly signal?: AbortSignal
private readonly cancelled: Promise<undefined>
constructor(parent: ResourceScope) {
this.scope = parent.child()
this.cancelled = new Promise((resolve) => {
this.scope.add(() => {
resolve(undefined)
})
})
this.signal = requestController(this.scope)?.signal
}
get active(): boolean {
return !this.scope.closed
}
cancel(): void {
this.scope.dispose()
}
async run<Result>(work: () => Promise<Result>): Promise<Result | undefined> {
if (!this.active)
return undefined
try {
return await Promise.race([work(), this.cancelled])
}
finally {
this.cancel()
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import type ResourceScope from '../lifecycle/scope'
import { getScope } from '../lifecycle/instance'
import SubtitleRequest from './request'
export interface SubtitleState {
scope: ResourceScope
request?: SubtitleRequest
track?: ResourceScope
ownedURL?: string
revision: number
render: number
trackRevision: number
}
const states = new WeakMap<object, SubtitleState>()
export function initSubtitleState(subtitle: object, art: object): SubtitleState {
const scope = getScope(art).child()
const state: SubtitleState = { scope, revision: 0, render: 0, trackRevision: 0 }
states.set(subtitle, state)
scope.add(() => {
releaseSubtitleURL(state)
})
return state
}
export function subtitleState(subtitle: object): SubtitleState {
return states.get(subtitle)!
}
export function beginSubtitleRequest(state: SubtitleState): SubtitleRequest {
const previous = state.request
const request = new SubtitleRequest(state.scope)
state.request = request
state.revision += 1
previous?.cancel()
return request
}
export function releaseSubtitleURL(state: SubtitleState): void {
const url = state.ownedURL
state.ownedURL = undefined
if (url)
URL.revokeObjectURL(url)
}
+83
View File
@@ -0,0 +1,83 @@
import type { SubtitleView } from './types'
import { releaseSubtitleURL, subtitleState } from './state'
export function replaceSubtitleTrack(subtitle: SubtitleView, kind: string, url: string): void {
const state = subtitleState(subtitle)
if (state.scope.closed)
return
const revision = ++state.trackRevision
const { art } = subtitle
const { template } = art
const previous = template.$track
const parent = previous.parentNode
const next = previous.nextSibling
const track = document.createElement('track')
const scope = state.scope.child()
scope.add(() => {
track.onload = null
track.onerror = null
})
let committed = false
try {
track.default = true
track.kind = kind
track.src = url
track.label = art.option.subtitle.name || 'Artplayer'
track.track.mode = 'hidden'
track.onload = () => {
if (!scope.closed && template.$track === track)
art.emit('subtitleLoad', subtitle.cues, subtitle.option)
}
track.onerror = () => {
if (!scope.closed && template.$track === track)
art.notice.show = new Error('Failed to load subtitle track')
}
if (scope.closed || state.trackRevision !== revision) {
scope.dispose()
return
}
previous.remove()
template.$video.appendChild(track)
template.$track = track
const textTrack = subtitle.textTrack
const cleanup = textTrack
? art.proxy(textTrack, 'cuechange', () => {
if (!scope.closed && template.$track === track)
subtitle.update()
})
: () => {}
scope.add(() => {
art.events.remove(cleanup)
})
if (scope.closed || state.trackRevision !== revision) {
scope.dispose()
track.remove()
return
}
const previousScope = state.track
state.track = scope
subtitle.destroyEvent = cleanup
previous.onload = null
previous.onerror = null
committed = true
try {
previousScope?.dispose()
}
finally {
if (state.ownedURL && state.ownedURL !== url)
releaseSubtitleURL(state)
}
}
catch (error) {
if (committed)
throw error
scope.dispose()
track.remove()
if (state.scope.closed || state.trackRevision !== revision)
throw error
template.$track = previous
if (parent)
parent.insertBefore(previous, next?.parentNode === parent ? next : null)
throw error
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { Subtitle as SubtitleInput } from '../../types/subtitle'
import type { ComponentHost } from '../component/types'
import type { OptionalMediaCapabilities } from '../media/types'
export type SubtitleOption = Required<SubtitleInput>
export interface SubtitleCue extends TextTrackCue {
text: string
originalStartTime?: number
originalEndTime?: number
}
export interface SubtitleTrack extends HTMLTrackElement {
offset?: number
}
export interface SubtitleHost extends ComponentHost {
template: { $player: HTMLElement, $video: HTMLElement & OptionalMediaCapabilities, $subtitle: HTMLDivElement, $track: SubtitleTrack }
option: { subtitle: SubtitleOption }
proxy: ComponentHost['events']['proxy']
on: (name: 'video:timeupdate', callback: () => void) => unknown
off: (name: 'video:timeupdate', callback: () => void) => unknown
notice: { set show(value: unknown) }
i18n: { get: (key: string) => string }
}
export interface SubtitleView {
art: SubtitleHost
option: SubtitleOption | null
destroyEvent: () => unknown
readonly cues: SubtitleCue[]
readonly activeCues: SubtitleCue[]
readonly textTrack: TextTrack | undefined
update: () => void
}
export interface SubtitleOffsetHost {
template: { $track?: SubtitleTrack | null }
subtitle: { readonly cues: SubtitleCue[], update: () => void }
readonly duration: number
notice: { set show(value: string) }
i18n: { get: (key: string) => string }
emit: (name: 'subtitleOffset', value: number) => unknown
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,58 @@
# CORE-15 字幕请求、track 与渲染
状态:完成。以下阶段记录保留当时的进度;当前交付结论以末尾最终验收为准。
## 结构和兼容边界
- 原 src/subtitle.js 拆为 subtitle/index.ts、types.ts、request.ts、parse.ts、state.ts、track.ts、render.ts;字幕偏移 mixin 同时迁移 TS。
- 保留实际 Component 原型链和 update 绑定、url/style/show/toggle/init/switch/createTrack、原生 cue 对象、默认 escape 与 DOM/CSS 行结构。正常转换仍调用现有 SRT/ASS 工具,onVttLoad 的 this 仍为字幕选项对象。
- 正常 switch/init 返回转换 URL;无 textTrack 返回 null;空 URL 返回 undefined。字幕切换仍基于 art.option.subtitle 合并,不静默把上一次 switch 的临时配置变成默认值。
- 内部准确声明 SubtitleCue[]。旧公开声明及 CORE-07 的显式数组监听重载保持;旧隐式 scalar 回调冲突仍由 BASE-TYPE-07 / CORE-21 协调。
## 已复现问题和修正
三个真实浏览器引擎分别对发布基线和迁移前候选执行受控异步请求。HTTP 响应由测试控制,track/Blob/DOM 为原生浏览器对象。不是网络可靠性或实体设备测试。
1. 旧 first 请求晚于 second 完成时覆盖后者,notice 也回到 first;已加入请求代次及 AbortController,过期结果不再写页面。
2. 销毁后,旧请求仍生成 Blob 并替换 track;请求归实例作用域,关闭时及时以 undefined 结算,即使 fetch 忽略 signal 也不等待它。
3. 旧代码替换字幕时撤销调用者传入的 Blob URL,却未在销毁时撤销最后的自建 URL;现在仅释放自己创建的资源,原生全屏 track 重建保留当前资源。
这些异常竞争路径按缺陷修正处理。主动调用 switch/init 的真实错误仍拒绝;构造和 url setter 没有可接收的 Promise,内部处理拒绝,避免已显示 notice 的失败再成为未处理异常。HTTP 非成功响应在生成 track 前报告错误,具体新旧对照及错误回归仍在补齐。
render.ts 在 before 事件后检查代次,防止监听者切换/销毁后旧渲染继续写入;正常回调修改 cue 文本仍在同次渲染生效。track.ts 持有 onload/cuechange 清理,失败安装尝试恢复旧节点。复杂重入和清理异常仍需补齐测试后才能验收。
## 阶段证据
- 初始浏览器复现报告 core15-baseline-browser.json:首轮 notice 断言包含模板空白导致额外三项失败,已保留,未作为通过证据。
- 修正后的 core15-baseline-corrected-browser.json:18 项中,发布基线 9 项确认旧缺陷;候选 9 项按目标行为失败。修改源码前生成。
- 第一批 core15-first-browser.json:30 项三浏览器通过,含上述生命周期路径和既有声明契约。
- 第一批严格类型及五组消费者检查通过:135 个生产 TS 文件;后续代码和新增用例仍需重新检查。
- Node 新增请求取消/异常/能力缺失、解析及偏移共 9 项通过。
- 最终产物、原生全屏、完整浏览器回归和失败路径证据待补充。
## 依赖、脚本和后续
不增加依赖。test:unit 将加入 test/subtitle.test.js;仍使用 Node 24.21.0 / Yarn Classic 1.22.22。不修改公开类型、版本或 lockfile。本任务完成后独立本地提交,不推送/发布。
需继续审查:track 事件错误与原生全屏、重入/清理失败、代理无 textTracks、cue/偏移组合、产物与安装消费。源码、测试、文档一起回退,不覆盖 BASE 历史发布证据。
规范参考:[HTML TextTrack 模型](https://html.spec.whatwg.org/multipage/media.html#text-track-api)规定 disabled 模式 cue 列表可为 null;内部 getter 对此返回空数组。真实设备 Safari/iOS 能力仍需按全项目环境矩阵验证。
## 最终验收
- 七个字幕 TS 模块及字幕偏移 mixin 完成,核心 130 个与 chapter 5 个生产 TS 文件严格检查。旧公开声明保持,内部明确 cue 数组与 string/null/undefined 返回。
- yarn ci:check:210 项通过(181 单元、4 工程、25 基线);新增字幕 Node 10 项覆盖取消、错误、能力缺失、解析、偏移及属性失败后的作用域释放。
- 实际核心/chapter tarball 仓库外安装:27 项运行时、五组类型零诊断。最终安装 UMD 和 legacy 各 600 项三浏览器通过,其中各 81 项字幕检查;无重试、跳过或未处理页面错误。
- [最终证据](../baselines/subtitle-validation.json)核对当前全部核心源码与构建快照、三个分发格式与 tarball/docs、公开类型、包内架构说明、22 个语言文件及独立分发许可。
- 真实 native fullscreen 在三个桌面引擎执行进出与字幕 DOM 检查;另外驱动 WebKit 视频全屏信号验证 metadata/subtitles track 重建、旧 handler 清理及 URL 保留。这不是实体 iOS 验证。
- 插入失败恢复旧 track;注册重入保留胜出的 track;旧清理抛错后保留已提交新资源。标签属性抛错立即释放本次作用域。HTTP 失败、实际 native track 失败、disabled cue 列表、转换重入和空 URL 取消均有覆盖。
- Node/cue 身份与默认 escape、SRT/ASS 转换 this、样式返回、show/toggle 事件、偏移原始时间和旧 cue 数组事件均保留。正式 TYPE-07 出口还在 CORE-18/21;不把保留的历史公开声明说成全部精确。
### 验证中发现并保留的失败
- 最初 notice 文本包含模板空白;断言改为 trim 后保留旧缺陷/候选失败对照。VTT 空白行在 Firefox 是 cue 分隔符,改用有效 VTT,并直接改变原生 cue 文本验证空行过滤。
- WebKit 原生 cue 改文案后会暂时更新 activeCues,改为等待真实字幕行就绪后读取结果;没有放宽最终行文本或样式断言。短暂 notice 通过实际 setter 完成后的 DOM 写入记录验收,避免错过自动隐藏的提示;没有替换真实 setter。
- 第一次安装 UMD 全套 600 项通过后,额外修正属性失败作用域释放;最终重新构建/安装,UMD 与 legacy 都基于新包再验证。旧结果仅作阶段证据。
- 两次 Windows copyfile UNKNOWN(分别复制 mjs/js 到 docs/compiled)保留日志。磁盘/权限/独立复制检查未复现根因;后续串行正常构建通过,未修改依赖或跳过分发复制。
关闭 BASE-LIFE-15/16。当前 214 项中 51 完成、163 待办;下一项 CORE-16 全屏、PiP、mini 与尺寸模式。全生态 SDK、实体设备、远端 CI、多轮复盘和发布门槛仍按原计划继续。本任务独立本地提交,不推送或发布。
+1 -1
View File
@@ -21,7 +21,7 @@
| 播放流程 | play/pause/toggle/seek/url/switch/eventInit | 媒体状态、操作身份、加载恢复与 UI 响应分离 |
| UI 注册 | utils/component、control、layer、contextmenu | 保留 name/cache/update/remove;明确 DOM 和绑定的归属 |
| 设置面板 | setting/index.ts | 分开树整理、选择状态、渲染、布局、绑定;共享能共享的逻辑,不强行统一返回类型 |
| 字幕 | subtitle.js、utils/subtitle.js | 获取、解析、track/DOM 渲染、取消和对象 URL 回收分开 |
| 字幕 | subtitle/index.ts、utils/subtitle.ts | 获取、解析、track/DOM 渲染、取消和对象 URL 回收分开 |
| 显示模式 | fullscreen/fullscreenWeb/pip/mini/auto* | 显式恢复位置和样式,保持互斥顺序、用户手势与移动端语义 |
| 输入 | events、hotkey | 鼠标/触控/键盘/焦点归属清晰,跨 document 重新绑定和清理 |
| 无障碍 | template、control、setting、显示模式 | 明确键盘导航、可访问名称、焦点进入退出和字幕能力,保留旧 DOM/CSS 与热键契约 |
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 164 / doing 0 / blocked 0 / done 50 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 163 / doing 0 / blocked 0 / done 51 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -118,7 +118,7 @@
| CORE-12 | artplayer<br>迁移模板及公开资源入口 | CORE-06, CORE-04, BASE-04 | template/icons/i18n/style、SSR 与 proxy 挂载 | DOM/CSS/global/样式注入、i18n 子路径及 SSR import 保持;不把 SSR import/useSSR 保持扩大为服务端构造播放器;复跑 BASE-05 的 SSR import/模板、浏览器 useSSR 节点复用和非浏览器构造错误;接续 VENDOR-01/02,核对 screenfull/hint 复制版本、修改与分发通知,保留 DOM/CSS/fullscreen 行为 | H | done |
| CORE-13 | artplayer<br>迁移组件注册与控件 | CORE-12, CORE-08 | Component/control/layer/contextmenu 的资源与 TS 边界 | add/update/remove、name、selector、mounted/beforeUnmount 通过;处理 BASE-DOM-02 窄容器控件裁切,保留 controls.update 的旧 undefined 返回值 | H | done |
| CORE-14 | artplayer<br>拆分设置树、渲染和布局 | CORE-13 | setting model/selection/render/layout 的分批实现 | 不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位;接续 BASE-TYPE-07 的 find null、add/update item、remove undefined 声明冲突 | H | done |
| CORE-15 | artplayer<br>迁移字幕获取、解析和渲染 | CORE-11, CORE-13 | subtitle 请求/track/DOM/URL 生命周期与类型 | 过期结果、偏移、cue 数组、原生全屏及错误路径通过;接续 BASE-TYPE-07 的真实 cue 数组与旧隐式 scalar 回调声明兼容 | H | todo |
| CORE-15 | artplayer<br>迁移字幕获取、解析和渲染 | CORE-11, CORE-13 | subtitle 请求/track/DOM/URL 生命周期与类型 | 过期结果、偏移、cue 数组、原生全屏及错误路径通过;接续 BASE-TYPE-07 的真实 cue 数组与旧隐式 scalar 回调声明兼容 | H | done |
| CORE-16 | artplayer<br>迁移全屏、PiP、mini 与尺寸模式 | CORE-11, CORE-12 | 显示模式服务和原公开属性门面 | 模式互斥/恢复位置、手势权限、移动方向和退出失败通过 | H | todo |
| CORE-17 | artplayer<br>迁移键盘、手势、焦点和全局事件 | CORE-13, CORE-16 | events/hotkey 的明确 document 与资源归属 | 跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应;修复 BASE-PERF-01 pending resize debounce 销毁后执行,并验证正常 resize 不变 | H | todo |
| CORE-18 | artplayer<br>迁移内置插件和提示模块 | CORE-14, CORE-16, CORE-17 | autoPlayback/autoOrientation/fastForward/lock/miniProgressBar 及 info/notice/loading/mask | 默认启用条件、storage 格式、定时器/动画清理通过;结合 BASE-PERF-01 防止销毁后重新安排 notice timer;接续 BASE-TYPE-07 的 notice.show boolean getter 与历史读类型冲突 | M | todo |
@@ -449,6 +449,7 @@
- CORE-12: [记录](changes/2026-09-11-CORE-12-template-resources.md) [记录](baselines/template-resources-validation.json)
- CORE-13: [记录](changes/2026-09-11-CORE-13-components.md) [记录](baselines/components-validation.json)
- CORE-14: [记录](changes/2026-09-11-CORE-14-setting.md) [记录](baselines/setting-validation.json)
- CORE-15: [记录](changes/2026-09-11-CORE-15-subtitle.md) [记录](baselines/subtitle-validation.json)
- PKG-CHAPTER-01: [记录](changes/2026-09-10-PKG-CHAPTER-01-contract.md) [记录](baselines/chapter-contract.md)
- PKG-CHAPTER-02: [记录](changes/2026-09-10-PKG-CHAPTER-02-tests.md) [记录](baselines/chapter-validation.json)
- PKG-CHAPTER-03: [记录](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md) [记录](baselines/chapter-migration-validation.json)
+8
View File
@@ -1,5 +1,13 @@
# 进度与证据
## 当前实施:CORE-15 字幕请求、track 与渲染完成
字幕管理器拆为 7 个 TS 模块,字幕偏移 mixin 同时迁移;请求取消及时结算,过期结果停止写入;仅释放自建 URL,处理 track 重入/失败恢复、原生 cue/全屏、错误及清理。正常转换、回调、DOM、返回和事件契约保留。
CI 210 项通过,核心 130 个与 chapter 5 个生产 TS 文件严格检查;实际安装 UMD 与 legacy 各 600 项三浏览器通过,其中各 81 项字幕检查。真实 tarball 27 项运行时、五组类型零诊断;当前源码/产物/声明/文档/语言/许可指纹一致。见 [交付](changes/2026-09-11-CORE-15-subtitle.md) 和 [最终证据](baselines/subtitle-validation.json)。
关闭 BASE-LIFE-15/16,BASE-TYPE-07 公开类型协调保持开放。当前 214 项,51 完成、163 待办;下一项 CORE-16 全屏、PiP、mini 与尺寸模式。当前仅本地提交,未推送或发布。
## 当前实施:CORE-14 设置树、渲染与布局完成
设置目录迁移为 17 个 TS 模块,拆清树模型、渲染、布局、选择、条目/面板资源、更新事务、注册代次与根事件;保留原对象和返回约定,修复失效回调、残留订阅、失败恢复、跨实例绑定抢占及长文本控件裁切。
+2
View File
@@ -68,3 +68,5 @@
| BASE-LIFE-13 | resolved / 已复现 | Obsolete setting callback completions overwrite current UI and failures are unhandled | CORE-14 |
| BASE-LIFE-14 | resolved / 已复现 | Removed or replaced setting items still run deferred mounted callbacks | CORE-14 |
| BASE-DOM-08 | resolved / 已复现 | Sharing a live setting object leaves a second published instance blank and can steal candidate bindings | CORE-14 |
| BASE-LIFE-15 | resolved / 已复现 | Subtitle requests overwrite newer state and mutate tracks after destroy | CORE-15 |
| BASE-LIFE-16 | resolved / 已复现 | Subtitle URLs revoke caller resources and leak the final generated resource | CORE-15 |
+45 -1
View File
@@ -900,7 +900,7 @@
"compatibleResolution": "Keep legacy consumers; add explicit cue-array listener overloads, type internal implementations accurately, and resolve remaining generated/public facade contracts at CORE-21 without false intersections.",
"closureCriteria": "Actual setting/null/void and notice boolean returns, cue array callbacks and old legal type consumers all have an explicit compatible declaration strategy and package tests.",
"scopeNote": "CORE-07 provides runtime evidence and explicit cue-array overloads; old inferred scalar event callbacks and return signatures are not claimed corrected.",
"workspaceState": "CORE-14 source manager accurately models add/update item, remove void and missing find null; installed historical public consumers still compile. Public declaration coordination remains open for CORE-15/18/21."
"workspaceState": "CORE-14 source models item/null/void setting returns; CORE-15 source models cue arrays and string/null/undefined subtitle results. Historical public consumers and explicit array listener overloads still compile; scalar inference and public return facade coordination remain CORE-18/21."
},
{
"id": "BASE-TYPE-08",
@@ -1306,6 +1306,50 @@
"test/setting-resources.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass the complete 519-case three-engine matrix. Managed tree/DOM/resource rollback, async generation guards, scoped mounts and panel/header ownership preserve normal identities, callbacks and returns. Narrow layouts and native input/touch interactions pass; simultaneous live object sharing fails before mutation while transfer after removal/destruction preserves the original object. Source and shipped package bytes match the tested artifacts."
},
{
"id": "BASE-LIFE-15",
"title": "Subtitle requests overwrite newer state and mutate tracks after destroy",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-15"
],
"evidence": [
"test/browser/subtitle-lifecycle.spec.js",
"refactor/changes/2026-09-11-CORE-15-subtitle.md"
],
"compatibleResolution": "Scope requests, abort and settle superseded calls; ignore stale conversion, errors and notices.",
"closureCriteria": "Installed UMD/legacy cover out-of-order fetch/body completion, rejection, cancellation, reentry and destroy.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-15-subtitle.md",
"refactor/baselines/subtitle-validation.json",
"test/browser/subtitle-lifecycle.spec.js",
"test/subtitle.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass 600 real-browser checks. Subtitle cancellation settles promptly and stale work cannot commit. Only generated URLs are owned; native fullscreen-kind rebuilds retain them and failure/reentry/destroy cleanup is verified. Runtime and type consumers pass against the exact packed source and artifacts."
},
{
"id": "BASE-LIFE-16",
"title": "Subtitle URLs revoke caller resources and leak the final generated resource",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-15"
],
"evidence": [
"test/browser/subtitle-lifecycle.spec.js",
"refactor/changes/2026-09-11-CORE-15-subtitle.md"
],
"compatibleResolution": "Own only generated URLs and release track callbacks/resources on replace, failure and destroy.",
"closureCriteria": "Installed UMD/legacy prove caller URL survival, owned URL release, full-screen reuse, failed replacement and callback cleanup.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-15-subtitle.md",
"refactor/baselines/subtitle-validation.json",
"test/browser/subtitle-lifecycle.spec.js",
"test/subtitle.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass 600 real-browser checks. Subtitle cancellation settles promptly and stale work cannot commit. Only generated URLs are owned; native fullscreen-kind rebuilds retain them and failure/reentry/destroy cleanup is verified. Runtime and type consumers pass against the exact packed source and artifacts."
}
]
}
+5 -2
View File
@@ -1029,11 +1029,14 @@
"CORE-11",
"CORE-13"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "subtitle 请求/track/DOM/URL 生命周期与类型",
"acceptance": "过期结果、偏移、cue 数组、原生全屏及错误路径通过;接续 BASE-TYPE-07 的真实 cue 数组与旧隐式 scalar 回调声明兼容",
"evidence": []
"evidence": [
"changes/2026-09-11-CORE-15-subtitle.md",
"baselines/subtitle-validation.json"
]
},
{
"id": "CORE-16",
+455
View File
@@ -0,0 +1,455 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
const container = document.createElement('div')
Object.assign(container.style, { width: '640px', height: '360px' })
document.body.append(container)
window.art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true })
window.subtitleRequests = new Map()
window.subtitleURLs = []
window.subtitleRevoked = []
const fetchOriginal = window.fetch
const create = URL.createObjectURL.bind(URL)
const revoke = URL.revokeObjectURL.bind(URL)
URL.createObjectURL = (blob) => {
const url = create(blob)
window.subtitleURLs.push(url)
return url
}
URL.revokeObjectURL = (url) => {
window.subtitleRevoked.push(url)
revoke(url)
}
window.fetch = (url, options) => {
if (!String(url).startsWith('/controlled-subtitle/'))
return fetchOriginal(url, options)
// Deliberately ignore AbortSignal: generation checks must work with custom fetch adapters.
return new Promise((resolve, reject) => window.subtitleRequests.set(url, { resolve, reject, signal: options?.signal }))
}
window.completeSubtitle = (name, text) => window.subtitleRequests.get(`/controlled-subtitle/${name}.vtt`).resolve(new Response(`WEBVTT\n\n00:00:00.000 --> 00:00:07.000\n${text}\n`))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
for (const format of ['srt', 'ass']) {
test(`${core}: ${format} conversion reaches native cues with original callback binding`, async ({ page }) => {
await setup(page, core)
await page.evaluate(async (format) => {
const art = window.art
window.subtitleConversion = []
const request = art.subtitle.switch('/controlled-subtitle/convert.vtt', { type: format, onVttLoad(vtt) {
window.subtitleConversion.push({ bound: this === art.subtitle.option, vtt: vtt.startsWith('WEBVTT') })
return vtt
} })
const text = format === 'srt'
? '1\n00:00:01,200 --> 00:00:03,400\nConverted\n'
: '[Events]\nDialogue: 0,0:00:01.20,0:00:03.40,Default,,0,0,0,,Converted\n'
window.subtitleRequests.get('/controlled-subtitle/convert.vtt').resolve(new Response(text))
await request
}, format)
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
expect(await page.evaluate(() => {
const cue = window.art.subtitle.cues[0]
return { text: cue.text, start: cue.startTime, end: cue.endTime, conversion: window.subtitleConversion }
})).toEqual({ text: 'Converted', start: 1.2, end: 3.4, conversion: [{ bound: true, vtt: true }] })
await page.evaluate(() => {
window.art.destroy()
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
})
})
}
test(`${core}: video fullscreen signal rebuilds track kind while retaining its URL`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
const request = art.subtitle.switch('/controlled-subtitle/mobile-fullscreen.vtt')
window.completeSubtitle('mobile-fullscreen', 'Mobile fullscreen branch')
const url = await request
const kinds = []
// This drives the WebKit-specific signal branch; it does not emulate a physical iOS fullscreen surface.
Object.defineProperty(art.template.$video, 'webkitDisplayingFullscreen', { configurable: true, value: true })
art.emit('video:timeupdate')
kinds.push(art.template.$track.kind)
const fullscreenTrack = art.template.$track
art.emit('video:timeupdate')
const stable = art.template.$track === fullscreenTrack
Object.defineProperty(art.template.$video, 'webkitDisplayingFullscreen', { configurable: true, value: false })
art.emit('video:timeupdate')
kinds.push(art.template.$track.kind)
const result = { kinds, stable, sameURL: art.subtitle.url === url, notRevoked: !window.subtitleRevoked.includes(url), oldHandlerCleared: fullscreenTrack.onload === null }
art.destroy()
for (const item of window.subtitleURLs) URL.revokeObjectURL(item)
return result
})
expect(result).toEqual({ kinds: ['subtitles', 'metadata'], stable: true, sameURL: true, notRevoked: true, oldHandlerCleared: true })
})
test(`${core}: subtitle remains visible through real native fullscreen`, async ({ page }, testInfo) => {
await setup(page, core)
await page.evaluate(async () => {
const request = window.art.subtitle.switch('/controlled-subtitle/fullscreen.vtt')
window.completeSubtitle('fullscreen', 'Fullscreen cue')
await request
window.initialSubtitleTrack = window.art.template.$track
document.querySelector('#play').onclick = () => {
window.art.fullscreen = true
}
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
await page.evaluate(() => {
window.art.seek = 2
})
await expect.poll(() => page.evaluate(() => window.art.template.$subtitle.textContent.trim())).toBe('Fullscreen cue')
const native = await page.evaluate(() => Boolean(document.fullscreenEnabled || document.webkitFullscreenEnabled))
await testInfo.attach('subtitle-fullscreen-capability', { contentType: 'application/json', body: JSON.stringify({ native }) })
expect(native, 'Desktop engine must execute native fullscreen in this test').toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => (document.fullscreenElement || document.webkitFullscreenElement) === window.art.template.$player)).toBe(true)
expect(await page.evaluate(() => ({ text: window.art.template.$subtitle.textContent.trim(), track: window.art.template.$track === window.initialSubtitleTrack, visible: window.art.subtitle.show }))).toEqual({ text: 'Fullscreen cue', track: true, visible: true })
await page.evaluate(() => {
window.art.fullscreen = false
})
await expect.poll(() => page.evaluate(() => !window.art.fullscreen)).toBe(true)
await page.evaluate(() => {
window.art.destroy()
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
})
})
test(`${core}: native subtitle cues, offset, styles and show contracts`, async ({ page }) => {
await setup(page, core)
await page.evaluate(async () => {
const art = window.art
window.subtitleLoads = []
art.on('subtitleLoad', (cues, option) => window.subtitleLoads.push({ array: Array.isArray(cues), native: cues[0] instanceof VTTCue, sameOption: option === art.subtitle.option }))
const request = art.subtitle.switch('/controlled-subtitle/native.vtt', { style: { color: 'red' } })
window.completeSubtitle('native', '<b>One</b>\nTwo')
await request
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
await expect.poll(() => page.evaluate(() => window.subtitleLoads.length)).toBe(1)
await page.evaluate(() => {
window.art.seek = 2
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.activeCues.length)).toBe(1)
await page.evaluate(() => {
window.art.subtitle.cues[0].text = '<b>One</b>\n \nTwo'
})
await expect.poll(() => page.evaluate(() => {
window.art.subtitle.update()
return window.art.template.$subtitle.querySelectorAll('.art-subtitle-line').length
})).toBe(2)
const result = await page.evaluate(() => {
const art = window.art
const before = art.subtitle.cues[0]
const lines = Array.from(art.template.$subtitle.querySelectorAll('.art-subtitle-line'), element => ({ text: element.textContent.trim(), group: element.dataset.group }))
const escaped = !art.template.$subtitle.querySelector('b')
const style = art.subtitle.style('fontSize', '22px') === art.template.$subtitle
const color = art.template.$subtitle.style.color
art.option.subtitle.escape = false
art.subtitle.update()
const raw = art.template.$subtitle.querySelector('b')?.textContent
art.subtitleOffset = 1
const offset = { start: before.startTime, end: before.endTime, value: art.subtitleOffset, original: before.originalStartTime, identity: before === art.subtitle.cues[0] }
const showEvents = []
art.on('subtitle', value => showEvents.push(value))
art.subtitle.show = false
art.subtitle.toggle()
const shown = art.subtitle.show
const result = { lines, escaped, style, color, offset, showEvents, shown, raw, loads: window.subtitleLoads }
art.destroy()
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
return result
})
expect(result).toEqual({ lines: [{ text: '<b>One</b>', group: '0' }, { text: 'Two', group: '0' }], escaped: true, style: true, color: 'red', offset: { start: 1, end: 8, value: 1, original: 0, identity: true }, showEvents: [false, true], shown: true, raw: 'One', loads: [{ array: true, native: true, sameOption: true }] })
})
test(`${core}: subtitle latest request wins and cancelled calls settle`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
const first = art.subtitle.switch('/controlled-subtitle/first.vtt', { name: 'First' })
const second = art.subtitle.switch('/controlled-subtitle/second.vtt', { name: 'Second' })
window.completeSubtitle('second', 'Second')
const latest = await second
window.completeSubtitle('first', 'First')
const stale = await first
const result = { winner: art.subtitle.url === latest, cancelled: stale === undefined, notice: art.template.$notice.textContent.trim(), aborted: window.subtitleRequests.get('/controlled-subtitle/first.vtt').signal?.aborted === true }
art.destroy()
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
return result
})
expect(result).toEqual(core === 'candidate'
? { winner: true, cancelled: true, notice: 'Switch Subtitle: Second', aborted: true }
: { winner: false, cancelled: false, notice: 'Switch Subtitle: First', aborted: false })
})
test(`${core}: subtitle pending completion cannot replace track after destroy`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
const track = art.template.$track
const request = art.subtitle.switch('/controlled-subtitle/late.vtt')
art.destroy(false)
window.completeSubtitle('late', 'Late')
const value = await request
const result = { unchanged: art.template.$track === track, cancelled: value === undefined, created: window.subtitleURLs.length }
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
return result
})
expect(result).toEqual(core === 'candidate' ? { unchanged: true, cancelled: true, created: 0 } : { unchanged: false, cancelled: false, created: 1 })
})
test(`${core}: subtitle owns generated URLs and leaves caller URLs alive`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
const foreign = URL.createObjectURL(new Blob(['WEBVTT\n\n00:00:00.000 --> 00:00:07.000\nForeign\n'], { type: 'text/vtt' }))
await art.subtitle.switch(foreign, { type: 'external' })
const request = art.subtitle.switch('/controlled-subtitle/owned.vtt')
window.completeSubtitle('owned', 'Owned')
const owned = await request
art.destroy()
const result = { foreignRevoked: window.subtitleRevoked.includes(foreign), ownedRevoked: window.subtitleRevoked.includes(owned) }
for (const url of window.subtitleURLs) URL.revokeObjectURL(url)
return result
})
expect(result).toEqual(core === 'candidate' ? { foreignRevoked: false, ownedRevoked: true } : { foreignRevoked: true, ownedRevoked: false })
})
}
test('candidate: subtitle cancellation settles before an ignored request or body finishes', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
const art = window.art
window.cancelledSubtitles = []
art.subtitle.switch('/controlled-subtitle/never.vtt').then(value => window.cancelledSubtitles.push(value === undefined))
const second = art.subtitle.switch('/controlled-subtitle/body.vtt')
second.then(value => window.cancelledSubtitles.push(value === undefined))
window.subtitleRequests.get('/controlled-subtitle/body.vtt').resolve({ ok: true, arrayBuffer: () => new Promise((resolve) => {
window.finishSubtitleBody = resolve
}) })
})
await expect.poll(() => page.evaluate(() => typeof window.finishSubtitleBody)).toBe('function')
await page.evaluate(() => window.art.subtitle.switch(''))
await expect.poll(() => page.evaluate(() => window.cancelledSubtitles)).toEqual([true, true])
expect(await page.evaluate(async () => {
window.finishSubtitleBody(new TextEncoder().encode('WEBVTT').buffer)
window.subtitleRequests.get('/controlled-subtitle/never.vtt').reject(new Error('obsolete transport'))
await new Promise(resolve => setTimeout(resolve, 0))
const count = window.subtitleURLs.length
window.art.destroy()
return count
})).toBe(0)
})
test('candidate: subtitle conversion reentry preserves the newer operation and notice', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async () => {
const art = window.art
let second
const first = art.subtitle.switch('/controlled-subtitle/first.vtt', { name: 'Obsolete', onVttLoad(vtt) {
second = art.subtitle.switch('/controlled-subtitle/second.vtt', { name: 'Winner' })
window.completeSubtitle('second', 'Winner')
return vtt
} })
window.completeSubtitle('first', 'Obsolete')
const cancelled = await first
const winner = await second
const result = { cancelled: cancelled === undefined, winner: art.subtitle.url === winner, notice: art.template.$notice.textContent.trim(), generated: window.subtitleURLs.length }
art.destroy()
return result
})
expect(result).toEqual({ cancelled: true, winner: true, notice: 'Switch Subtitle: Winner', generated: 1 })
})
test('candidate: active subtitle errors reject, preserve track and allow a later retry', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async () => {
const art = window.art
const initial = art.template.$track
const error = new Error('controlled subtitle failure')
const promise = art.subtitle.switch('/controlled-subtitle/fail.vtt')
const failure = promise.catch(caught => caught === error)
window.subtitleRequests.get('/controlled-subtitle/fail.vtt').reject(error)
const rejected = await failure
const sameTrack = art.template.$track === initial
const notice = art.template.$notice.textContent.trim()
const retry = art.subtitle.switch('/controlled-subtitle/retry.vtt')
window.completeSubtitle('retry', 'Retry')
const url = await retry
const success = art.subtitle.url === url
art.destroy()
return { rejected, sameTrack, notice, success }
})
expect(result).toEqual({ rejected: true, sameTrack: true, notice: 'controlled subtitle failure', success: true })
})
test('candidate: subtitle HTTP failures do not become usable tracks', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async () => {
const art = window.art
const track = art.template.$track
const request = art.subtitle.switch('/controlled-subtitle/http.vtt').catch(error => error.message)
window.subtitleRequests.get('/controlled-subtitle/http.vtt').resolve(new Response('not subtitles', { status: 503, statusText: 'Unavailable' }))
const error = await request
const result = { error, sameTrack: track === art.template.$track, created: window.subtitleURLs.length }
art.destroy()
return result
})
expect(result).toEqual({ error: 'Failed to load subtitle: 503 Unavailable', sameTrack: true, created: 0 })
})
test('candidate: disabled native tracks expose empty cue arrays without throwing', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
art.subtitle.textTrack.mode = 'disabled'
const result = [art.subtitle.cues.length, art.subtitle.activeCues.length]
art.subtitle.update()
art.destroy()
return result
})).toEqual([0, 0])
})
test('candidate: native subtitle track failure is reported and obsolete callbacks are removed', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(async () => {
const request = window.art.subtitle.switch('/controlled-subtitle/track-error.vtt', { type: 'external' })
window.completeSubtitle('track-error', 'Not served to track')
await request
})
await expect.poll(() => page.evaluate(() => window.art.template.$notice.textContent.trim())).toBe('Failed to load subtitle track')
const result = await page.evaluate(async () => {
const art = window.art
const previous = art.template.$track
const oldError = previous.onerror
const request = art.subtitle.switch('/controlled-subtitle/recovered.vtt', { name: 'Recovered' })
window.completeSubtitle('recovered', 'Recovered')
await request
oldError(new Event('error'))
const result = { notice: art.template.$notice.textContent.trim(), callbacksCleared: previous.onload === null && previous.onerror === null }
const current = art.template.$track
art.destroy()
result.destroyCleared = current.onload === null && current.onerror === null
return result
})
expect(result).toEqual({ notice: 'Switch Subtitle: Recovered', callbacksCleared: true, destroyCleared: true })
})
test('candidate: constructor and URL setter failures notify without unhandled promises', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
const container = window.art.option.container
window.art.destroy()
const art = window.art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, subtitle: { url: '/controlled-subtitle/constructor.vtt' } })
window.subtitleNoticeWrites = []
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(art.notice), 'show')
Object.defineProperty(art.notice, 'show', { get: descriptor.get, set(value) {
descriptor.set.call(this, value)
window.subtitleNoticeWrites.push(art.template.$notice.textContent.trim())
} })
window.subtitleRequests.get('/controlled-subtitle/constructor.vtt').reject(new Error('constructor subtitle error'))
window.subtitleRetry = () => {
art.subtitle.url = '/controlled-subtitle/setter.vtt'
window.subtitleRequests.get('/controlled-subtitle/setter.vtt').reject(new Error('setter subtitle error'))
}
})
await expect.poll(() => page.evaluate(() => window.subtitleNoticeWrites)).toContain('constructor subtitle error')
await page.evaluate(() => window.subtitleRetry())
await expect.poll(() => page.evaluate(() => window.subtitleNoticeWrites)).toContain('setter subtitle error')
await page.evaluate(() => window.art.destroy())
})
for (const fail of [false, true]) {
test(`candidate: reentrant track replacement keeps the winner when outer throws=${fail}`, async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async (fail) => {
const art = window.art
const proxy = art.events.proxy
let winner
const failure = new Error('outer track registration failure')
art.events.proxy = (...args) => {
art.events.proxy = proxy
art.subtitle.createTrack('subtitles', art.subtitle.url)
winner = art.template.$track
if (fail)
throw failure
return proxy(...args)
}
const promise = art.subtitle.switch('/controlled-subtitle/reentry.vtt').catch(error => error === failure)
window.completeSubtitle('reentry', 'Reentry')
const settled = await promise
let updates = 0
art.subtitle.update = () => updates++
winner.track.dispatchEvent(new Event('cuechange'))
const result = { winner: art.template.$track === winner, kind: winner.kind, updates, settled: fail ? settled === true : settled === undefined, live: !window.subtitleRevoked.includes(winner.src) }
art.destroy()
result.released = window.subtitleRevoked.includes(winner.src)
return result
}, fail)
expect(result).toEqual({ winner: true, kind: 'subtitles', updates: 1, settled: true, live: true, released: true })
})
}
test('candidate: failed track insertion restores the old live track and releases the attempted URL', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async () => {
const art = window.art
const first = art.subtitle.switch('/controlled-subtitle/old.vtt')
window.completeSubtitle('old', 'Old')
const oldURL = await first
const track = art.template.$track
const video = art.template.$video
const append = video.appendChild
const failure = new Error('insert failure')
video.appendChild = () => {
throw failure
}
const attempt = art.subtitle.switch('/controlled-subtitle/failed.vtt').catch(error => error === failure)
window.completeSubtitle('failed', 'Failed')
const rejected = await attempt
video.appendChild = append
let updates = 0
art.subtitle.update = () => updates++
track.track.dispatchEvent(new Event('cuechange'))
const result = { rejected, same: art.template.$track === track, attached: track.parentNode === video, updates, oldLive: !window.subtitleRevoked.includes(oldURL), newReleased: window.subtitleRevoked.includes(window.subtitleURLs[1]) }
art.destroy()
return result
})
expect(result).toEqual({ rejected: true, same: true, attached: true, updates: 1, oldLive: true, newReleased: true })
})
test('candidate: cleanup failure after track commit does not revoke the committed URL', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(async () => {
const art = window.art
const first = art.subtitle.switch('/controlled-subtitle/old.vtt')
window.completeSubtitle('old', 'Old')
const oldURL = await first
const oldCleanup = art.subtitle.destroyEvent
const remove = art.events.remove.bind(art.events)
art.events.remove = (cleanup) => {
remove(cleanup)
if (cleanup === oldCleanup)
throw new Error('old cleanup failed')
}
const attempt = art.subtitle.switch('/controlled-subtitle/committed.vtt').then(() => false, () => true)
window.completeSubtitle('committed', 'Committed')
const rejected = await attempt
art.events.remove = remove
const url = art.subtitle.url
let updates = 0
art.subtitle.update = () => updates++
art.subtitle.textTrack.dispatchEvent(new Event('cuechange'))
const result = { rejected, replaced: url !== oldURL, updates, oldReleased: window.subtitleRevoked.includes(oldURL), currentLive: !window.subtitleRevoked.includes(url) }
art.destroy()
result.released = window.subtitleRevoked.includes(url)
return result
})
expect(result).toEqual({ rejected: true, replaced: true, updates: 1, oldReleased: true, currentLive: true, released: true })
})
+172
View File
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Exercise resource promises with Node's test runner.
import { test as it } from 'node:test'
import { loadModules } from './helpers/load.js'
const { ResourceScope, SubtitleRequest, parseSubtitle, subtitleOffsetMix, beginLifecycle, initSubtitleState, replaceSubtitleTrack } = await loadModules({
ResourceScope: 'packages/artplayer/src/lifecycle/scope',
SubtitleRequest: 'packages/artplayer/src/subtitle/request',
parseSubtitle: { file: 'packages/artplayer/src/subtitle/parse', name: 'parseSubtitle' },
subtitleOffsetMix: 'packages/artplayer/src/player/subtitleOffsetMix',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
initSubtitleState: { file: 'packages/artplayer/src/subtitle/state', name: 'initSubtitleState' },
replaceSubtitleTrack: { file: 'packages/artplayer/src/subtitle/track', name: 'replaceSubtitleTrack' },
})
it('subtitle track attribute failure releases its new scope and preserves the original error', () => {
const original = globalThis.document
const error = new Error('label getter failed')
const view = { art: { option: { subtitle: { get name() {
throw error
} } }, template: { $track: { parentNode: null, nextSibling: null } } } }
beginLifecycle(view.art)
const state = initSubtitleState(view, view.art)
const child = state.scope.child.bind(state.scope)
let open = 0
state.scope.child = () => {
const scope = child()
open += 1
scope.add(() => {
open -= 1
})
return scope
}
try {
globalThis.document = { createElement: () => ({ track: {}, remove() {} }) }
for (let attempt = 0; attempt < 3; attempt++) {
assert.throws(() => replaceSubtitleTrack(view, 'metadata', '/test.vtt'), caught => caught === error)
assert.equal(open, 0)
}
}
finally {
state.scope.dispose()
globalThis.document = original
}
})
it('subtitle requests settle immediately on cancel, ignore late resolution and abort transport', async () => {
const root = new ResourceScope()
const request = new SubtitleRequest(root)
let resolve
const result = request.run(() => new Promise((done) => {
resolve = done
}))
request.cancel()
assert.equal(await result, undefined)
assert.equal(request.signal.aborted, true)
resolve('late')
root.dispose()
})
it('subtitle cancellation observes late rejection without rejecting the cancelled caller', async () => {
const root = new ResourceScope()
const request = new SubtitleRequest(root)
let reject
const result = request.run(() => new Promise((_resolve, fail) => {
reject = fail
}))
root.dispose()
assert.equal(await result, undefined)
reject(new Error('late failure'))
await new Promise(resolve => setImmediate(resolve))
})
it('subtitle successful value and active rejection retain their identity', async () => {
const root = new ResourceScope()
const value = {}
assert.equal(await new SubtitleRequest(root).run(async () => value), value)
const error = new Error('active failure')
await assert.rejects(new SubtitleRequest(root).run(async () => {
throw error
}), caught => caught === error)
root.dispose()
})
it('subtitle closed roots never execute work and repeated cancellation is harmless', async () => {
const root = new ResourceScope()
root.dispose()
const request = new SubtitleRequest(root)
let calls = 0
assert.equal(await request.run(async () => {
calls += 1
}), undefined)
request.cancel()
request.cancel()
assert.equal(calls, 0)
})
it('subtitle requests still cancel without AbortController', async () => {
const original = globalThis.AbortController
try {
globalThis.AbortController = undefined
const root = new ResourceScope()
const request = new SubtitleRequest(root)
assert.equal(request.signal, undefined)
const result = request.run(() => new Promise(() => {}))
root.dispose()
assert.equal(await result, undefined)
}
finally {
globalThis.AbortController = original
}
})
it('subtitle conversion keeps callback this, encoded text and inferred extension', () => {
const option = { url: '/sub.SRT?token=1', encoding: 'utf-8', type: '', onVttLoad(vtt) {
assert.equal(this, option)
return `${vtt}NOTE transformed\n`
} }
const buffer = new TextEncoder().encode('1\n00:00:00,1 --> 00:00:01,23\nHello\n').buffer
const result = parseSubtitle(buffer, option)
assert.match(result.vtt, /00:00:00\.100 --> 00:00:01\.230/)
assert.match(result.vtt, /NOTE transformed/)
const latin = parseSubtitle(Uint8Array.from([0x63, 0x61, 0x66, 0xE9]).buffer, { ...option, type: 'vtt', encoding: 'windows-1252', onVttLoad: value => value })
assert.equal(latin.vtt, 'café')
})
it('subtitle pass-through skips conversion callback; invalid decoder and callback errors propagate', () => {
const buffer = new ArrayBuffer(0)
assert.equal(parseSubtitle(buffer, { url: '/subtitle.unknown', encoding: 'utf-8', onVttLoad() {
assert.fail('unexpected callback')
} }), undefined)
assert.throws(() => parseSubtitle(buffer, { url: '/subtitle.vtt', encoding: 'invalid-encoding' }), RangeError)
const error = new Error('callback failure')
assert.throws(() => parseSubtitle(buffer, { url: '/subtitle.vtt', onVttLoad() {
throw error
} }), caught => caught === error)
// Unknown formats and invalid JS callback return values must remain distinct paths.
assert.deepEqual(parseSubtitle(buffer, { url: '/subtitle.vtt', onVttLoad() {} }), { vtt: undefined })
})
it('subtitle offsets preserve cue identity and original bounds across repeated changes', () => {
const cue = { startTime: 1, endTime: 5 }
const events = []
const art = { template: { $track: {} }, duration: 8, subtitle: { cues: [cue], update() {
events.push('update')
} }, notice: {}, i18n: { get: key => key }, emit: (...args) => events.push(args) }
subtitleOffsetMix(art)
art.subtitleOffset = 2
assert.deepEqual(cue, { startTime: 3, endTime: 7, originalStartTime: 1, originalEndTime: 5 })
art.subtitleOffset = -3
assert.deepEqual(cue, { startTime: 0, endTime: 2, originalStartTime: 1, originalEndTime: 5 })
assert.equal(art.subtitleOffset, -3)
assert.equal(art.notice.show, 'Subtitle Offset: -3s')
assert.deepEqual(events, ['update', ['subtitleOffset', 2], 'update', ['subtitleOffset', -3]])
art.subtitleOffset = 20
assert.equal(art.subtitleOffset, 10)
assert.equal(art.notice.show, 'Subtitle Offset: 20s')
})
it('subtitle offsets with no cues retain the default state and do not notify', () => {
const art = { template: {}, subtitle: { cues: [], update() {
assert.fail()
} }, notice: {}, i18n: { get() {
assert.fail()
} }, emit() {
assert.fail()
} }
subtitleOffsetMix(art)
art.subtitleOffset = 1
assert.equal(art.subtitleOffset, 0)
assert.equal(Object.hasOwn(art.notice, 'show'), false)
})
+27
View File
@@ -0,0 +1,27 @@
import type Subtitle from '../../packages/artplayer/src/subtitle'
import type { SubtitleCue, SubtitleHost, SubtitleOption } from '../../packages/artplayer/src/subtitle/types'
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
type Expect<T extends true> = T
declare const subtitle: Subtitle
declare const host: SubtitleHost
declare const options: SubtitleOption
subtitle.init(options)
subtitle.switch('/subtitle.vtt', { onVttLoad: vtt => vtt })
subtitle.style({ color: 'red' })
subtitle.style('fontSize', '22px')
subtitle.url = '/subtitle.srt'
host.emit('subtitleBeforeUpdate', subtitle.activeCues)
type Cues = Expect<Equal<typeof subtitle.cues, SubtitleCue[]>>
type Switch = Expect<Equal<ReturnType<Subtitle['switch']>, Promise<string | null | undefined>>>
type Update = Expect<Equal<ReturnType<Subtitle['update']>, void>>
type Style = Expect<Equal<ReturnType<Subtitle['style']>, HTMLDivElement>>
export type Assertions = [Cues, Switch, Update, Style]
// @ts-expect-error Subtitle update does not accept registry entries.
subtitle.update({ name: 'entry' })
// @ts-expect-error Conversion callbacks produce text synchronously.
subtitle.switch('/subtitle.vtt', { onVttLoad: async vtt => vtt })