mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
refactor(core): CORE-15 type subtitles and own request and track lifetimes
This commit is contained in:
1 parent
64d1939daa
commit
1b83081e8f
28 files changed
+11286
-405
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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)
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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、多轮复盘和发布门槛仍按原计划继续。本任务独立本地提交,不推送或发布。
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 模块,拆清树模型、渲染、布局、选择、条目/面板资源、更新事务、注册代次与根事件;保留原对象和返回约定,修复失效回调、残留订阅、失败恢复、跨实例绑定抢占及长文本控件裁切。
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
Reference in new issue
Block a user