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

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

No files matched your search

+32
View File
@@ -4,6 +4,38 @@ ArtPlayer keeps its existing constructor, player mixins, plugins and DOM/CSS hoo
The production entry is still `src/index.js`; this document marks actual migrated
boundaries rather than describing the entire core as TypeScript.
## Subtitles (CORE-15)
src/subtitle/index.ts keeps the Component prototype, bound update, public methods and
normal URL results. The request, parse, state, track and render modules now separate
transport cancellation, conversion, ownership, native DOM events and cue rendering.
The offset mixin remains the player property entry, with original cue bounds retained
on native cue objects. Validation evidence and remaining release gates live in refactor/.
request.ts races work against scope closure. Superseded requests settle undefined even
when the transport ignores AbortSignal; active failures reject. state.ts owns request
and track scopes and only generated object URLs. A caller-owned URL is never revoked.
Replacing a track for native fullscreen keeps the same owned subtitle resource.
parse.ts delegates SRT/ASS conversion to the public utility parsers, preserving callback
this and type/extension/encoding behavior. Unknown formats retain the caller URL.
render.ts retains the line/group markup and uses current global subtitle.escape; a
switch-specific escape override does not silently change the legacy rendering contract.
Before-update listeners may still edit cues; switching/destroying prevents obsolete DOM
writes. Disabled text tracks return empty cue arrays; native cue identity is preserved.
To change transport policy start with request.ts and index.ts; to change native track
ownership start with track.ts and state.ts. Run test/subtitle.test.js, the browser
subtitle-lifecycle and declarations specs, and strict source/consumer type checks.
Track generations prevent a reentrant replacement from losing its callbacks when an
older registration returns or throws. Failed insertion restores the previous node;
cleanup failures after commit retain the new resource and remain observable to callers.
Track load errors notify only while that node is current; all owned load/error/cuechange
callbacks are cleared on replacement or destroy. HTTP errors reject before conversion.
Constructor and URL-setter calls report through notice and consume their otherwise
unobservable rejected promises; explicit init/switch calls keep rejection semantics.
Public cue event inference remains tracked by BASE-TYPE-07 / CORE-21. Browser-engine
tests do not certify physical iOS/Safari fullscreen or every proxy implementation.
## Settings (CORE-14)
setting/index.ts remains the specialized manager: panel cache keys are option arrays,
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+259 -104
View File
@@ -361,13 +361,13 @@ class ResourceScope {
throw new ResourceCleanupError(errors);
}
}
const states$1 = /* @__PURE__ */ new WeakMap();
const states$2 = /* @__PURE__ */ new WeakMap();
const containers = /* @__PURE__ */ new WeakMap();
function beginLifecycle(owner) {
states$1.set(owner, { scope: new ResourceScope(), destroying: false });
states$2.set(owner, { scope: new ResourceScope(), destroying: false });
}
function stateOf(owner) {
const state2 = states$1.get(owner);
const state2 = states$2.get(owner);
if (!state2)
throw new Error("ArtPlayer lifecycle has not been initialized");
return state2;
@@ -1433,6 +1433,15 @@ function animationFrame(scope, callback) {
});
return release;
}
function requestController(scope) {
if (typeof AbortController === "undefined")
return void 0;
const controller = new AbortController();
scope.add(() => {
controller.abort();
});
return controller;
}
function wait(scope, delay = 0) {
if (scope.closed)
return Promise.resolve(false);
@@ -5297,17 +5306,17 @@ function beginSettingAdd(item) {
}
};
}
const states = /* @__PURE__ */ new WeakMap();
const states$1 = /* @__PURE__ */ new WeakMap();
function rememberSettingState(item, capture) {
if (capture)
states.set(item, capture);
states$1.set(item, capture);
else
states.delete(item);
states$1.delete(item);
}
function captureSettingItem(item) {
const descriptors = new Map(Reflect.ownKeys(item).map((key) => [key, Object.getOwnPropertyDescriptor(item, key)]));
const nodes = [];
const state2 = states.get(item);
const state2 = states$1.get(item);
const restoreState = state2?.();
for (const key of ["html", "icon", "tooltip"]) {
const value = descriptors.get(key)?.value;
@@ -6134,142 +6143,288 @@ class Storage {
}
}
const css = ".art-video-player {\n --art-theme: #f00;\n --art-font-color: #fff;\n --art-background-color: #000;\n --art-text-shadow-color: rgba(0, 0, 0, 0.5);\n --art-transition-duration: 0.2s;\n --art-padding: 10px;\n --art-border-radius: 3px;\n --art-progress-height: 6px;\n --art-progress-color: rgba(255, 255, 255, 0.25);\n --art-progress-top-gap: 10px;\n --art-hover-color: rgba(255, 255, 255, 0.25);\n --art-loaded-color: rgba(255, 255, 255, 0.25);\n --art-state-size: 80px;\n --art-state-opacity: 0.8;\n --art-bottom-height: 100px;\n --art-bottom-offset: 20px;\n --art-bottom-gap: 5px;\n --art-highlight-width: 8px;\n --art-highlight-color: rgba(255, 255, 255, 0.5);\n --art-control-height: 46px;\n --art-control-opacity: 0.75;\n --art-control-icon-size: 36px;\n --art-control-icon-scale: 1.1;\n --art-volume-height: 120px;\n --art-volume-handle-size: 14px;\n --art-lock-size: 36px;\n --art-indicator-scale: 0;\n --art-indicator-size: 16px;\n --art-fullscreen-web-index: 9999;\n --art-settings-icon-size: 24px;\n --art-settings-max-height: 300px;\n --art-selector-max-height: 300px;\n --art-contextmenus-min-width: 250px;\n --art-subtitle-font-size: 20px;\n --art-subtitle-gap: 5px;\n --art-subtitle-bottom: 15px;\n --art-subtitle-border: #000;\n --art-widget-background: rgba(0, 0, 0, 0.85);\n --art-tip-background: rgba(0, 0, 0, 0.7);\n --art-scrollbar-size: 4px;\n --art-scrollbar-background: rgba(255, 255, 255, 0.25);\n --art-scrollbar-background-hover: rgba(255, 255, 255, 0.5);\n --art-mini-progress-height: 2px;\n}\n.art-bg-cover {\n background-position: center center;\n background-repeat: no-repeat;\n background-size: cover;\n}\n.art-bottom-gradient {\n background-image: linear-gradient(to top, #000, rgba(0, 0, 0, 0.4), transparent);\n background-repeat: repeat-x;\n background-position: center bottom;\n}\n.art-backdrop-filter {\n -webkit-backdrop-filter: saturate(180%) blur(20px);\n backdrop-filter: saturate(180%) blur(20px);\n background-color: rgba(0, 0, 0, 0.75) !important;\n}\n.art-truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.art-video-player {\n position: relative;\n margin: 0 auto;\n width: 100%;\n height: 100%;\n outline: 0;\n zoom: 1;\n padding: 0;\n text-align: left;\n direction: ltr;\n font-size: 14px;\n line-height: 1.3;\n user-select: none;\n box-sizing: border-box;\n color: var(--art-font-color);\n background-color: var(--art-background-color);\n text-shadow: 0 0 2px var(--art-text-shadow-color);\n font-family: PingFang SC, Helvetica Neue, Microsoft YaHei, Roboto, Arial, sans-serif;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -ms-touch-action: manipulation;\n touch-action: manipulation;\n -ms-high-contrast-adjust: none;\n}\n.art-video-player *,\n.art-video-player *::before,\n.art-video-player *::after {\n box-sizing: border-box;\n}\n.art-video-player ::-webkit-scrollbar {\n width: var(--art-scrollbar-size);\n height: var(--art-scrollbar-size);\n}\n.art-video-player ::-webkit-scrollbar-thumb {\n background-color: var(--art-scrollbar-background);\n}\n.art-video-player ::-webkit-scrollbar-thumb:hover {\n background-color: var(--art-scrollbar-background-hover);\n}\n.art-video-player img {\n max-width: 100%;\n vertical-align: top;\n}\n.art-video-player svg {\n fill: var(--art-font-color);\n}\n.art-video-player a {\n color: var(--art-font-color);\n text-decoration: none;\n}\n.art-icon {\n line-height: 1;\n display: flex;\n justify-content: center;\n align-items: center;\n}\n.art-video-player.art-backdrop .art-contextmenus,\n.art-video-player.art-backdrop .art-info,\n.art-video-player.art-backdrop .art-settings,\n.art-video-player.art-backdrop .art-layer-auto-playback,\n.art-video-player.art-backdrop .art-selector-list,\n.art-video-player.art-backdrop .art-volume-inner {\n -webkit-backdrop-filter: saturate(180%) blur(20px);\n backdrop-filter: saturate(180%) blur(20px);\n background-color: rgba(0, 0, 0, 0.75) !important;\n}\n.art-video {\n position: absolute;\n inset: 0;\n z-index: 10;\n width: 100%;\n height: 100%;\n}\n.art-poster {\n position: absolute;\n inset: 0;\n z-index: 11;\n width: 100%;\n height: 100%;\n background-position: center center;\n background-repeat: no-repeat;\n background-size: cover;\n pointer-events: none;\n}\n.art-video-player .art-subtitle {\n display: none;\n justify-content: center;\n align-items: center;\n flex-direction: column;\n position: absolute;\n z-index: 20;\n width: 100%;\n padding: 0 5%;\n text-align: center;\n pointer-events: none;\n gap: var(--art-subtitle-gap);\n bottom: var(--art-subtitle-bottom);\n font-size: var(--art-subtitle-font-size);\n transition: bottom var(--art-transition-duration) ease;\n text-shadow: var(--art-subtitle-border) 1px 0 1px, var(--art-subtitle-border) 0 1px 1px, var(--art-subtitle-border) -1px 0 1px, var(--art-subtitle-border) 0 -1px 1px, var(--art-subtitle-border) 1px 1px 1px,Line truncated
class Subtitle extends Component {
function parseSubtitle(buffer, option) {
const text = new TextDecoder(option.encoding).decode(buffer);
switch (option.type || getExt(option.url)) {
case "srt":
return { vtt: option.onVttLoad(srtToVtt(text)) };
case "ass":
return { vtt: option.onVttLoad(assToVtt(text)) };
case "vtt":
return { vtt: option.onVttLoad(text) };
default:
return void 0;
}
}
class SubtitleRequest {
constructor(parent) {
this.scope = parent.child();
this.cancelled = new Promise((resolve) => {
this.scope.add(() => {
resolve(void 0);
});
});
this.signal = requestController(this.scope)?.signal;
}
get active() {
return !this.scope.closed;
}
cancel() {
this.scope.dispose();
}
async run(work) {
if (!this.active)
return void 0;
try {
return await Promise.race([work(), this.cancelled]);
} finally {
this.cancel();
}
}
}
const states = /* @__PURE__ */ new WeakMap();
function initSubtitleState(subtitle, art) {
const scope = getScope(art).child();
const state2 = { scope, revision: 0, render: 0, trackRevision: 0 };
states.set(subtitle, state2);
scope.add(() => {
releaseSubtitleURL(state2);
});
return state2;
}
function subtitleState(subtitle) {
return states.get(subtitle);
}
function beginSubtitleRequest(state2) {
const previous = state2.request;
const request = new SubtitleRequest(state2.scope);
state2.request = request;
state2.revision += 1;
previous?.cancel();
return request;
}
function releaseSubtitleURL(state2) {
const url = state2.ownedURL;
state2.ownedURL = void 0;
if (url)
URL.revokeObjectURL(url);
}
function renderSubtitle(subtitle) {
const state2 = subtitleState(subtitle);
if (state2.scope.closed)
return;
const revision = state2.revision;
const render = ++state2.render;
const active = () => !state2.scope.closed && state2.revision === revision && state2.render === render;
const { art } = subtitle;
const { $subtitle } = art.template;
$subtitle.innerHTML = "";
if (!subtitle.activeCues.length)
return;
art.emit("subtitleBeforeUpdate", subtitle.activeCues);
if (!active())
return;
const html2 = subtitle.activeCues.map((cue, index) => cue.text.split(/\r?\n/).filter((line) => line.trim()).map((line) => `<div class="art-subtitle-line" data-group="${index}">
${art.option.subtitle.escape ? escape(line) : line}
</div>`).join("")).join("");
if (!active())
return;
$subtitle.innerHTML = html2;
art.emit("subtitleAfterUpdate", subtitle.activeCues);
}
function replaceSubtitleTrack(subtitle, kind, url) {
const state2 = subtitleState(subtitle);
if (state2.scope.closed)
return;
const revision = ++state2.trackRevision;
const { art } = subtitle;
const { template } = art;
const previous = template.$track;
const parent = previous.parentNode;
const next = previous.nextSibling;
const track = document.createElement("track");
const scope = state2.scope.child();
scope.add(() => {
track.onload = null;
track.onerror = null;
});
let committed = false;
try {
track.default = true;
track.kind = kind;
track.src = url;
track.label = art.option.subtitle.name || "Artplayer";
track.track.mode = "hidden";
track.onload = () => {
if (!scope.closed && template.$track === track)
art.emit("subtitleLoad", subtitle.cues, subtitle.option);
};
track.onerror = () => {
if (!scope.closed && template.$track === track)
art.notice.show = new Error("Failed to load subtitle track");
};
if (scope.closed || state2.trackRevision !== revision) {
scope.dispose();
return;
}
previous.remove();
template.$video.appendChild(track);
template.$track = track;
const textTrack = subtitle.textTrack;
const cleanup = textTrack ? art.proxy(textTrack, "cuechange", () => {
if (!scope.closed && template.$track === track)
subtitle.update();
}) : () => {
};
scope.add(() => {
art.events.remove(cleanup);
});
if (scope.closed || state2.trackRevision !== revision) {
scope.dispose();
track.remove();
return;
}
const previousScope = state2.track;
state2.track = scope;
subtitle.destroyEvent = cleanup;
previous.onload = null;
previous.onerror = null;
committed = true;
try {
previousScope?.dispose();
} finally {
if (state2.ownedURL && state2.ownedURL !== url)
releaseSubtitleURL(state2);
}
} catch (error2) {
if (committed)
throw error2;
scope.dispose();
track.remove();
if (state2.scope.closed || state2.trackRevision !== revision)
throw error2;
template.$track = previous;
if (parent)
parent.insertBefore(previous, next?.parentNode === parent ? next : null);
throw error2;
}
}
const SubtitleBase = Component;
class Subtitle extends SubtitleBase {
constructor(art) {
super(art);
this.name = "subtitle";
this.option = null;
this.destroyEvent = () => null;
this.init(art.option.subtitle);
const state2 = initSubtitleState(this, art);
void this.init(art.option.subtitle).catch(() => {
});
let lastState = false;
art.on("video:timeupdate", () => {
if (!this.url)
const timeupdate = () => {
if (state2.scope.closed || !this.url)
return;
const state2 = this.art.template.$video.webkitDisplayingFullscreen;
if (typeof state2 !== "boolean")
return;
if (state2 !== lastState) {
lastState = state2;
this.createTrack(state2 ? "subtitles" : "metadata", this.url);
const fullscreen2 = art.template.$video.webkitDisplayingFullscreen;
if (typeof fullscreen2 === "boolean" && fullscreen2 !== lastState) {
lastState = fullscreen2;
this.createTrack(fullscreen2 ? "subtitles" : "metadata", this.url);
}
};
art.on("video:timeupdate", timeupdate);
state2.scope.add(() => {
art.off("video:timeupdate", timeupdate);
});
}
get url() {
return this.art.template.$track.src;
}
set url(url) {
this.switch(url);
void this.switch(url).catch(() => {
});
}
get textTrack() {
return this.art.template.$video?.textTracks?.[0];
}
get activeCues() {
if (!this.textTrack)
return [];
return Array.from(this.textTrack.activeCues);
return Array.from(this.textTrack?.activeCues || []);
}
get cues() {
if (!this.textTrack)
return [];
return Array.from(this.textTrack.cues);
return Array.from(this.textTrack?.cues || []);
}
style(key, value) {
const { $subtitle } = this.art.template;
if (typeof key === "object") {
return setStyles($subtitle, key);
}
return setStyle($subtitle, key, value);
return typeof key === "object" ? setStyles($subtitle, key) : setStyle($subtitle, key, value);
}
update() {
const {
option: { subtitle },
template: { $subtitle }
} = this.art;
$subtitle.innerHTML = "";
if (!this.activeCues.length)
return;
this.art.emit("subtitleBeforeUpdate", this.activeCues);
$subtitle.innerHTML = this.activeCues.map(
(cue, index) => cue.text.split(/\r?\n/).filter((line) => line.trim()).map(
(line) => `<div class="art-subtitle-line" data-group="${index}">
${subtitle.escape ? escape(line) : line}
</div>`
).join("")
).join("");
this.art.emit("subtitleAfterUpdate", this.activeCues);
renderSubtitle(this);
}
async switch(url, newOption = {}) {
const { i18n, notice, option } = this.art;
const subtitleOption = { ...option.subtitle, ...newOption, url };
const state2 = subtitleState(this);
const revision = state2.revision + 1;
const subUrl = await this.init(subtitleOption);
if (newOption.name) {
if (!state2.scope.closed && state2.revision === revision && newOption.name)
notice.show = `${i18n.get("Switch Subtitle")}: ${newOption.name}`;
}
return subUrl;
}
createTrack(kind, url) {
const { template, proxy, option } = this.art;
const { $video, $track } = template;
const $newTrack = createElement("track");
$newTrack.default = true;
$newTrack.kind = kind;
$newTrack.src = url;
$newTrack.label = option.subtitle.name || "Artplayer";
$newTrack.track.mode = "hidden";
$newTrack.onload = () => {
this.art.emit("subtitleLoad", this.cues, this.option);
};
this.art.events.remove(this.destroyEvent);
$track.onload = null;
remove($track);
append($video, $newTrack);
template.$track = $newTrack;
this.destroyEvent = proxy(this.textTrack, "cuechange", () => this.update());
replaceSubtitleTrack(this, kind, url);
}
async init(subtitleOption) {
const {
notice,
template: { $subtitle }
} = this.art;
if (!this.textTrack)
return null;
validator(subtitleOption, scheme.subtitle);
if (!subtitleOption.url)
return;
this.option = subtitleOption;
this.style(subtitleOption.style);
return fetch(subtitleOption.url).then((response) => response.arrayBuffer()).then((buffer) => {
const decoder = new TextDecoder(subtitleOption.encoding);
const text = decoder.decode(buffer);
switch (subtitleOption.type || getExt(subtitleOption.url)) {
case "srt": {
const vtt = srtToVtt(text);
const vttNew = subtitleOption.onVttLoad(vtt);
return vttToBlob(vttNew);
const state2 = subtitleState(this);
const request = beginSubtitleRequest(state2);
const { notice, template: { $subtitle } } = this.art;
return request.run(async () => {
if (!this.textTrack)
return null;
validator(subtitleOption, scheme.subtitle);
if (!subtitleOption.url)
return void 0;
this.option = subtitleOption;
this.style(subtitleOption.style);
let generated;
try {
if (!request.active)
return void 0;
const response = await fetch(subtitleOption.url, { signal: request.signal });
if (!request.active)
return void 0;
if (!response.ok)
throw new Error(`Failed to load subtitle: ${response.status} ${response.statusText}`);
const buffer = await response.arrayBuffer();
if (!request.active)
return void 0;
const vtt = parseSubtitle(buffer, subtitleOption);
if (!request.active)
return void 0;
const subUrl = vtt === void 0 ? subtitleOption.url : generated = vttToBlob(vtt.vtt);
if (!request.active)
return void 0;
$subtitle.innerHTML = "";
const trackRevision = state2.trackRevision + 1;
if (this.url !== subUrl)
this.createTrack("metadata", subUrl);
if (!request.active || state2.trackRevision > trackRevision)
return void 0;
if (generated) {
state2.ownedURL = generated;
generated = void 0;
}
case "ass": {
const vtt = assToVtt(text);
const vttNew = subtitleOption.onVttLoad(vtt);
return vttToBlob(vttNew);
}
case "vtt": {
const vttNew = subtitleOption.onVttLoad(text);
return vttToBlob(vttNew);
}
default:
return subtitleOption.url;
}
}).then((subUrl) => {
$subtitle.innerHTML = "";
if (this.url === subUrl)
return subUrl;
URL.revokeObjectURL(this.url);
this.createTrack("metadata", subUrl);
return subUrl;
}).catch((err) => {
$subtitle.innerHTML = "";
notice.show = err;
throw err;
} catch (error2) {
if (!request.active)
return void 0;
$subtitle.innerHTML = "";
notice.show = error2;
throw error2;
} finally {
if (generated) {
if (!state2.scope.closed && this.url === generated)
state2.ownedURL = generated;
else
URL.revokeObjectURL(generated);
}
}
});
}
}
@@ -1,20 +1,21 @@
import type { SubtitleOffsetHost } from '../subtitle/types'
import { clamp, def } from '../utils'
export default function subtitleOffsetMix(art) {
export default function subtitleOffsetMix(art: SubtitleOffsetHost): void {
const { notice, i18n, template } = art
def(art, 'subtitleOffset', {
get() {
return template.$track?.offset || 0
},
set(value) {
set(value: number) {
const { cues } = art.subtitle
if (!template.$track || cues.length === 0)
return
const offset = clamp(value, -10, 10)
template.$track.offset = offset
for (let index = 0; index < cues.length; index++) {
const cue = cues[index]
const cue = cues[index]!
cue.originalStartTime = cue.originalStartTime ?? cue.startTime
cue.originalEndTime = cue.originalEndTime ?? cue.endTime
cue.startTime = clamp(cue.originalStartTime + offset, 0, art.duration)
-183
View File
@@ -1,183 +0,0 @@
import validator from 'option-validator'
import scheme from './scheme'
import {
append,
assToVtt,
createElement,
escape,
getExt,
remove,
setStyle,
setStyles,
srtToVtt,
vttToBlob,
} from './utils'
import Component from './utils/component'
export default class Subtitle extends Component {
constructor(art) {
super(art)
this.name = 'subtitle'
this.option = null
this.destroyEvent = () => null
this.init(art.option.subtitle)
let lastState = false
art.on('video:timeupdate', () => {
if (!this.url)
return
const state = this.art.template.$video.webkitDisplayingFullscreen
if (typeof state !== 'boolean')
return
if (state !== lastState) {
lastState = state
this.createTrack(state ? 'subtitles' : 'metadata', this.url)
}
})
}
get url() {
return this.art.template.$track.src
}
set url(url) {
this.switch(url)
}
get textTrack() {
return this.art.template.$video?.textTracks?.[0]
}
get activeCues() {
if (!this.textTrack)
return []
return Array.from(this.textTrack.activeCues)
}
get cues() {
if (!this.textTrack)
return []
return Array.from(this.textTrack.cues)
}
style(key, value) {
const { $subtitle } = this.art.template
if (typeof key === 'object') {
return setStyles($subtitle, key)
}
return setStyle($subtitle, key, value)
}
update() {
const {
option: { subtitle },
template: { $subtitle },
} = this.art
$subtitle.innerHTML = ''
if (!this.activeCues.length)
return
this.art.emit('subtitleBeforeUpdate', this.activeCues)
$subtitle.innerHTML = this.activeCues
.map((cue, index) =>
cue.text
.split(/\r?\n/)
.filter(line => line.trim())
.map(
line =>
`<div class="art-subtitle-line" data-group="${index}">
${subtitle.escape ? escape(line) : line}
</div>`,
)
.join(''),
)
.join('')
this.art.emit('subtitleAfterUpdate', this.activeCues)
}
async switch(url, newOption = {}) {
const { i18n, notice, option } = this.art
const subtitleOption = { ...option.subtitle, ...newOption, url }
const subUrl = await this.init(subtitleOption)
if (newOption.name) {
notice.show = `${i18n.get('Switch Subtitle')}: ${newOption.name}`
}
return subUrl
}
createTrack(kind, url) {
const { template, proxy, option } = this.art
const { $video, $track } = template
const $newTrack = createElement('track')
$newTrack.default = true
$newTrack.kind = kind
$newTrack.src = url
$newTrack.label = option.subtitle.name || 'Artplayer'
$newTrack.track.mode = 'hidden'
$newTrack.onload = () => {
this.art.emit('subtitleLoad', this.cues, this.option)
}
this.art.events.remove(this.destroyEvent)
$track.onload = null
remove($track)
append($video, $newTrack)
template.$track = $newTrack
this.destroyEvent = proxy(this.textTrack, 'cuechange', () => this.update())
}
async init(subtitleOption) {
const {
notice,
template: { $subtitle },
} = this.art
if (!this.textTrack)
return null
validator(subtitleOption, scheme.subtitle)
if (!subtitleOption.url)
return
this.option = subtitleOption
this.style(subtitleOption.style)
return fetch(subtitleOption.url)
.then(response => response.arrayBuffer())
.then((buffer) => {
const decoder = new TextDecoder(subtitleOption.encoding)
const text = decoder.decode(buffer)
switch (subtitleOption.type || getExt(subtitleOption.url)) {
case 'srt': {
const vtt = srtToVtt(text)
const vttNew = subtitleOption.onVttLoad(vtt)
return vttToBlob(vttNew)
}
case 'ass': {
const vtt = assToVtt(text)
const vttNew = subtitleOption.onVttLoad(vtt)
return vttToBlob(vttNew)
}
case 'vtt': {
const vttNew = subtitleOption.onVttLoad(text)
return vttToBlob(vttNew)
}
default:
return subtitleOption.url
}
})
.then((subUrl) => {
$subtitle.innerHTML = ''
if (this.url === subUrl)
return subUrl
URL.revokeObjectURL(this.url)
this.createTrack('metadata', subUrl)
return subUrl
})
.catch((err) => {
$subtitle.innerHTML = ''
notice.show = err
throw err
})
}
}
+151
View File
@@ -0,0 +1,151 @@
import type { Subtitle as SubtitleInput } from '../../types/subtitle'
import type { SubtitleCue, SubtitleHost, SubtitleOption } from './types'
import validator from 'option-validator'
import scheme from '../scheme'
import { setStyle, setStyles, vttToBlob } from '../utils'
import Component from '../utils/component'
import { parseSubtitle } from './parse'
import { renderSubtitle } from './render'
import { beginSubtitleRequest, initSubtitleState, subtitleState } from './state'
import { replaceSubtitleTrack } from './track'
// Retain Component's runtime prototype and method binding without its registry update type.
const SubtitleBase: new (art: SubtitleHost) => Omit<Component<SubtitleHost>, 'update'> = Component
export default class Subtitle extends SubtitleBase {
declare name: string
declare option: SubtitleOption | null
declare destroyEvent: () => unknown
constructor(art: SubtitleHost) {
super(art)
this.name = 'subtitle'
this.option = null
this.destroyEvent = () => null
const state = initSubtitleState(this, art)
// init reports active failures through notice; construction has no promise consumer.
void this.init(art.option.subtitle).catch(() => {})
let lastState = false
const timeupdate = () => {
if (state.scope.closed || !this.url)
return
const fullscreen = art.template.$video.webkitDisplayingFullscreen
if (typeof fullscreen === 'boolean' && fullscreen !== lastState) {
lastState = fullscreen
this.createTrack(fullscreen ? 'subtitles' : 'metadata', this.url)
}
}
art.on('video:timeupdate', timeupdate)
state.scope.add(() => {
art.off('video:timeupdate', timeupdate)
})
}
get url(): string {
return this.art.template.$track.src
}
set url(url: string) {
void this.switch(url).catch(() => {})
}
get textTrack(): TextTrack | undefined {
return this.art.template.$video?.textTracks?.[0]
}
get activeCues(): SubtitleCue[] {
// ArtPlayer's converted resources are WebVTT; disabled tracks expose null cue lists.
return Array.from(this.textTrack?.activeCues || []) as SubtitleCue[]
}
get cues(): SubtitleCue[] {
return Array.from(this.textTrack?.cues || []) as SubtitleCue[]
}
style(styles: Partial<CSSStyleDeclaration>): HTMLDivElement
style(key: string, value: string | number): HTMLDivElement
style(key: string | Partial<CSSStyleDeclaration>, value?: string | number): HTMLDivElement {
const { $subtitle } = this.art.template
return typeof key === 'object' ? setStyles($subtitle, key) : setStyle($subtitle, key, value)
}
update(): void {
renderSubtitle(this)
}
async switch(url: string, newOption: SubtitleInput = {}): Promise<string | null | undefined> {
const { i18n, notice, option } = this.art
const subtitleOption = { ...option.subtitle, ...newOption, url }
const state = subtitleState(this)
const revision = state.revision + 1
const subUrl = await this.init(subtitleOption)
if (!state.scope.closed && state.revision === revision && newOption.name)
notice.show = `${i18n.get('Switch Subtitle')}: ${newOption.name}`
return subUrl
}
createTrack(kind: string, url: string): void {
replaceSubtitleTrack(this, kind, url)
}
async init(subtitleOption: SubtitleOption): Promise<string | null | undefined> {
const state = subtitleState(this)
const request = beginSubtitleRequest(state)
const { notice, template: { $subtitle } } = this.art
return request.run(async () => {
if (!this.textTrack)
return null
validator(subtitleOption, scheme.subtitle)
if (!subtitleOption.url)
return undefined
this.option = subtitleOption
this.style(subtitleOption.style)
let generated: string | undefined
try {
if (!request.active)
return undefined
const response = await fetch(subtitleOption.url, { signal: request.signal })
if (!request.active)
return undefined
if (!response.ok)
throw new Error(`Failed to load subtitle: ${response.status} ${response.statusText}`)
const buffer = await response.arrayBuffer()
if (!request.active)
return undefined
const vtt = parseSubtitle(buffer, subtitleOption)
if (!request.active)
return undefined
const subUrl = vtt === undefined ? subtitleOption.url : (generated = vttToBlob(vtt.vtt))
if (!request.active)
return undefined
$subtitle.innerHTML = ''
const trackRevision = state.trackRevision + 1
if (this.url !== subUrl)
this.createTrack('metadata', subUrl)
if (!request.active || state.trackRevision > trackRevision)
return undefined
if (generated) {
state.ownedURL = generated
generated = undefined
}
return subUrl
}
catch (error) {
if (!request.active)
return undefined
$subtitle.innerHTML = ''
notice.show = error
throw error
}
finally {
if (generated) {
// A replacement can commit before an old listener cleanup throws.
if (!state.scope.closed && this.url === generated)
state.ownedURL = generated
else
URL.revokeObjectURL(generated)
}
}
})
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { SubtitleOption } from './types'
import { getExt } from '../utils'
import { assToVtt, srtToVtt } from '../utils/subtitle'
// Keep conversion and callback binding identical to the public utility parsers.
export function parseSubtitle(buffer: ArrayBuffer, option: SubtitleOption): { vtt: string } | undefined {
const text = new TextDecoder(option.encoding).decode(buffer)
switch (option.type || getExt(option.url)) {
case 'srt': return { vtt: option.onVttLoad(srtToVtt(text)) }
case 'ass': return { vtt: option.onVttLoad(assToVtt(text)) }
case 'vtt': return { vtt: option.onVttLoad(text) }
default: return undefined
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { SubtitleView } from './types'
import { escape } from '../utils'
import { subtitleState } from './state'
export function renderSubtitle(subtitle: SubtitleView): void {
const state = subtitleState(subtitle)
if (state.scope.closed)
return
const revision = state.revision
const render = ++state.render
const active = () => !state.scope.closed && state.revision === revision && state.render === render
const { art } = subtitle
const { $subtitle } = art.template
$subtitle.innerHTML = ''
if (!subtitle.activeCues.length)
return
art.emit('subtitleBeforeUpdate', subtitle.activeCues)
if (!active())
return
const html = subtitle.activeCues.map((cue, index) => cue.text
.split(/\r?\n/)
.filter(line => line.trim())
.map(line => `<div class="art-subtitle-line" data-group="${index}">
${art.option.subtitle.escape ? escape(line) : line}
</div>`)
.join('')).join('')
if (!active())
return
$subtitle.innerHTML = html
art.emit('subtitleAfterUpdate', subtitle.activeCues)
}
@@ -0,0 +1,38 @@
import type ResourceScope from '../lifecycle/scope'
import { requestController } from '../lifecycle/resources'
// Cancellation settles callers even when a fetch adapter ignores AbortSignal.
export default class SubtitleRequest {
readonly scope: ResourceScope
readonly signal?: AbortSignal
private readonly cancelled: Promise<undefined>
constructor(parent: ResourceScope) {
this.scope = parent.child()
this.cancelled = new Promise((resolve) => {
this.scope.add(() => {
resolve(undefined)
})
})
this.signal = requestController(this.scope)?.signal
}
get active(): boolean {
return !this.scope.closed
}
cancel(): void {
this.scope.dispose()
}
async run<Result>(work: () => Promise<Result>): Promise<Result | undefined> {
if (!this.active)
return undefined
try {
return await Promise.race([work(), this.cancelled])
}
finally {
this.cancel()
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import type ResourceScope from '../lifecycle/scope'
import { getScope } from '../lifecycle/instance'
import SubtitleRequest from './request'
export interface SubtitleState {
scope: ResourceScope
request?: SubtitleRequest
track?: ResourceScope
ownedURL?: string
revision: number
render: number
trackRevision: number
}
const states = new WeakMap<object, SubtitleState>()
export function initSubtitleState(subtitle: object, art: object): SubtitleState {
const scope = getScope(art).child()
const state: SubtitleState = { scope, revision: 0, render: 0, trackRevision: 0 }
states.set(subtitle, state)
scope.add(() => {
releaseSubtitleURL(state)
})
return state
}
export function subtitleState(subtitle: object): SubtitleState {
return states.get(subtitle)!
}
export function beginSubtitleRequest(state: SubtitleState): SubtitleRequest {
const previous = state.request
const request = new SubtitleRequest(state.scope)
state.request = request
state.revision += 1
previous?.cancel()
return request
}
export function releaseSubtitleURL(state: SubtitleState): void {
const url = state.ownedURL
state.ownedURL = undefined
if (url)
URL.revokeObjectURL(url)
}
+83
View File
@@ -0,0 +1,83 @@
import type { SubtitleView } from './types'
import { releaseSubtitleURL, subtitleState } from './state'
export function replaceSubtitleTrack(subtitle: SubtitleView, kind: string, url: string): void {
const state = subtitleState(subtitle)
if (state.scope.closed)
return
const revision = ++state.trackRevision
const { art } = subtitle
const { template } = art
const previous = template.$track
const parent = previous.parentNode
const next = previous.nextSibling
const track = document.createElement('track')
const scope = state.scope.child()
scope.add(() => {
track.onload = null
track.onerror = null
})
let committed = false
try {
track.default = true
track.kind = kind
track.src = url
track.label = art.option.subtitle.name || 'Artplayer'
track.track.mode = 'hidden'
track.onload = () => {
if (!scope.closed && template.$track === track)
art.emit('subtitleLoad', subtitle.cues, subtitle.option)
}
track.onerror = () => {
if (!scope.closed && template.$track === track)
art.notice.show = new Error('Failed to load subtitle track')
}
if (scope.closed || state.trackRevision !== revision) {
scope.dispose()
return
}
previous.remove()
template.$video.appendChild(track)
template.$track = track
const textTrack = subtitle.textTrack
const cleanup = textTrack
? art.proxy(textTrack, 'cuechange', () => {
if (!scope.closed && template.$track === track)
subtitle.update()
})
: () => {}
scope.add(() => {
art.events.remove(cleanup)
})
if (scope.closed || state.trackRevision !== revision) {
scope.dispose()
track.remove()
return
}
const previousScope = state.track
state.track = scope
subtitle.destroyEvent = cleanup
previous.onload = null
previous.onerror = null
committed = true
try {
previousScope?.dispose()
}
finally {
if (state.ownedURL && state.ownedURL !== url)
releaseSubtitleURL(state)
}
}
catch (error) {
if (committed)
throw error
scope.dispose()
track.remove()
if (state.scope.closed || state.trackRevision !== revision)
throw error
template.$track = previous
if (parent)
parent.insertBefore(previous, next?.parentNode === parent ? next : null)
throw error
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { Subtitle as SubtitleInput } from '../../types/subtitle'
import type { ComponentHost } from '../component/types'
import type { OptionalMediaCapabilities } from '../media/types'
export type SubtitleOption = Required<SubtitleInput>
export interface SubtitleCue extends TextTrackCue {
text: string
originalStartTime?: number
originalEndTime?: number
}
export interface SubtitleTrack extends HTMLTrackElement {
offset?: number
}
export interface SubtitleHost extends ComponentHost {
template: { $player: HTMLElement, $video: HTMLElement & OptionalMediaCapabilities, $subtitle: HTMLDivElement, $track: SubtitleTrack }
option: { subtitle: SubtitleOption }
proxy: ComponentHost['events']['proxy']
on: (name: 'video:timeupdate', callback: () => void) => unknown
off: (name: 'video:timeupdate', callback: () => void) => unknown
notice: { set show(value: unknown) }
i18n: { get: (key: string) => string }
}
export interface SubtitleView {
art: SubtitleHost
option: SubtitleOption | null
destroyEvent: () => unknown
readonly cues: SubtitleCue[]
readonly activeCues: SubtitleCue[]
readonly textTrack: TextTrack | undefined
update: () => void
}
export interface SubtitleOffsetHost {
template: { $track?: SubtitleTrack | null }
subtitle: { readonly cues: SubtitleCue[], update: () => void }
readonly duration: number
notice: { set show(value: string) }
i18n: { get: (key: string) => string }
emit: (name: 'subtitleOffset', value: number) => unknown
}