refactor(multiple-subtitles): [PKG-MULTI-SUB-03] own requests and subtitle resources

This commit is contained in:
Harvey Zhao committed 2026-09-13 11:01:47 +08:00
1 parent 36cd151095
commit beaa93d6cc
25 files changed
+7908 -250

No files matched your search

@@ -55,4 +55,4 @@
*
* d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginMultipleSubtitles=t(),define(function(){return e.artplayerPluginMultipleSubtitles})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginMultipleSubtitles=t()}(this,function(){"use strict";var e={direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center"},t=function(t){t||(t={"&amp":"&","&lt":"<","&gt":">","&lrm":"\u200e","&rlm":"\u200f","&nbsp":"\xa0"}),this.entities=t,this.parse=function(a,s){a=a.replace(/\0/g,"\ufffd");var r=Date.now(),o=0,l=a.split(/\r\n|\r|\n/),f=!1,c=[],u=[],d=[];function p(e,t){d.push({message:e,line:o+1,col:t})}var m=l[o],g=m.length,h="WEBVTT",v=0,b=6;for("\ufeff"===m[0]&&(v=1,b+=1),(g<b||m.indexOf(h)!==0+v||g>b&&" "!==m[b]&&"\t"!==m[b])&&p('No valid signature. (File needs to start with "WEBVTT".)'),o++;""!=l[o]&&void 0!=l[o];){if(p("No blank line after the signature."),-1!=l[o].indexOf("--\x3e")){f=!0;break}o++}for(;void 0!=l[o];){for(var x;!f&&""==l[o];)o++;if(!f&&void 0==l[o])break;x=Object.assign({},e,{id:"",startTime:0,endTime:0,pauseOnExit:!1,direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center",text:"",tree:null});var T=!0;if(-1==l[o].indexOf("--\x3e")){if(x.id=l[o],/^NOTE($|[ \t])/.test(x.id)){for(o++;""!=l[o]&&void 0!=l[o];)-1!=l[o].indexOf("--\x3e")&&p("Cannot have timestamp in a comment."),o++;continue}if(/^STYLE($|[ \t])/.test(x.id)){var y=[],w=!1;for(o++;""!=l[o]&&void 0!=l[o];)-1!=l[o].indexOf("--\x3e")&&(p("Cannot have timestamp in a style block."),w=!0),y.push(l[o]),o++;if(u.length){p("Style blocks cannot appear after the first cue.");continue}w||c.push(y.join("\n"));continue}if(""==l[++o]||void 0==l[o]){p("Cue identifier cannot be standalone.");continue}if(-1==l[o].indexOf("--\x3e")){T=!1,p("Cue identifier needs to be followed by timestamp.");continue}}f=!1;var O=new n(l[o],p),L=0;if(u.length>0&&(L=u[u.length-1].startTime),!T||O.parse(x,L)){for(o++;""!=l[o]&&void 0!=l[o];){if(-1!=l[o].indexOf("--\x3e")){p("Blank line missing before cue."),f=!0;break}""!=x.text&&(x.text+="\n"),x.text+=l[o],o++}var I=new i(x.text,p,s,t);x.tree=I.parse(x.startTime,x.endTime),u.push(x)}else for(x=null,o++;""!=l[o]&&void 0!=l[o];){if(-1!=l[o].indexOf("--\x3e")){f=!0;break}o++}}return u.sort(function(e,t){return e.startTime<t.startTime?-1:e.startTime>t.startTime?1:e.endTime>t.endTime?-1:e.endTime<t.endTime?1:0}),{cues:u,errors:d,time:Date.now()-r,styles:c}}},n=function(e,t){var n=/[\u0020\t\f]/,i=/[^\u0020\t\f]/,a=(e=e,0),s=function(e){t(e,a+1)};function r(t){for(;void 0!=e[a]&&t.test(e[a]);)a++}function o(t){for(var n="";void 0!=e[a]&&t.test(e[a]);)n+=e[a],a++;return n}function l(){var t,n,i,r,l="minutes";if(void 0!=e[a])if(/\d/.test(e[a]))if(((t=o(/\d/)).length>2||parseInt(t,10)>59)&&(l="hours"),":"==e[a])if(a++,2==(n=o(/\d/)).length){if("hours"==l||":"==e[a]){if(":"!=e[a])return void s("No seconds found or minutes is greater than 59.");if(a++,2!=(i=o(/\d/)).length)return void s("Must be exactly two digits.")}else{if(2!=t.length)return void s("Must be exactly two digits.");i=n,n=t,t="0"}if("."==e[a])if(a++,3==(r=o(/\d/)).length)if(parseInt(n,10)>59)s("You cannot have more than 59 minutes.");else{if(!(parseInt(i,10)>59))return 60*parseInt(t,10)*60+60*parseInt(n,10)+parseInt(i,10)+parseInt(r,10)/1e3;s("You cannot have more than 59 seconds.")}else s("Milliseconds must be given in three digits.");else s('No decimal separator (".") found.')}else s("Must be exactly two digits.");else s("No time unit separator found.");else s("Timestamp must start with a character in the range 0-9.");else s("No timestamp found.")}this.parse=function(t,o){if(r(n),t.startTime=l(),void 0!=t.startTime)if(t.startTime<o&&s("Start timestamp is not greater than or equal to start timestamp of previous cue."),i.test(e[a])&&s("Timestamp not separated from '--\x3e' by whitespace."),r(n),"-"==e[a])if(a++,"-"==e[a])if(a++,">"==e[a]){if(a++,i.test(e[a])&&s("'--\x3e' not separated from timestamp by whitespace."),r(n),t.endTime=l(),void 0!=t.endTime)return t.endTime<=t.startTime&&s("End timestamp is not greater than start timestamp."),i.test(e[a]),r(n),function(e,t){for(var i=e.split(n),a=[],r=0;r<i.length;r++)if(""!=i[r]){var o=i[r].indexOf(":"),l=i[r].slice(0,o),f=i[r].slice(o+1);if(-1!=a.indexOf(l)&&s("Duplicate setting."),a.push(l),""==f)return void s("No value for setting defined.");if("vertical"==l){if("rl"!=f&&"lr"!=f){s("Writing direction can only be set to 'rl' or 'rl'.");continue}t.direction=f}else if("line"==l){if(/,/.test(f)){var c=f.split(",");f=c[0];var u=c[1]}if(!/^[-\d](\d*)(\.\d+)?%?$/.test(f)){s("Line position takes a number or percentage.");continue}if(-1!=f.indexOf("-",1)){s("Line position can only have '-' at the start.");continue}if(-1!=f.indexOf("%")&&f.indexOf("%")!=f.Line truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginMultipleSubtitles=t(),define(function(){return e.artplayerPluginMultipleSubtitles})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginMultipleSubtitles=t()}(this,function(){"use strict";var e={direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center"},t=function(t){t||(t={"&amp":"&","&lt":"<","&gt":">","&lrm":"\u200e","&rlm":"\u200f","&nbsp":"\xa0"}),this.entities=t,this.parse=function(s,a){s=s.replace(/\0/g,"\ufffd");var r=Date.now(),o=0,l=s.split(/\r\n|\r|\n/),c=!1,f=[],u=[],d=[];function p(e,t){d.push({message:e,line:o+1,col:t})}var m=l[o],g=m.length,h="WEBVTT",v=0,b=6;for("\ufeff"===m[0]&&(v=1,b+=1),(g<b||m.indexOf(h)!==0+v||g>b&&" "!==m[b]&&"\t"!==m[b])&&p('No valid signature. (File needs to start with "WEBVTT".)'),o++;""!=l[o]&&void 0!=l[o];){if(p("No blank line after the signature."),-1!=l[o].indexOf("--\x3e")){c=!0;break}o++}for(;void 0!=l[o];){for(var x;!c&&""==l[o];)o++;if(!c&&void 0==l[o])break;x=Object.assign({},e,{id:"",startTime:0,endTime:0,pauseOnExit:!1,direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center",text:"",tree:null});var T=!0;if(-1==l[o].indexOf("--\x3e")){if(x.id=l[o],/^NOTE($|[ \t])/.test(x.id)){for(o++;""!=l[o]&&void 0!=l[o];)-1!=l[o].indexOf("--\x3e")&&p("Cannot have timestamp in a comment."),o++;continue}if(/^STYLE($|[ \t])/.test(x.id)){var y=[],w=!1;for(o++;""!=l[o]&&void 0!=l[o];)-1!=l[o].indexOf("--\x3e")&&(p("Cannot have timestamp in a style block."),w=!0),y.push(l[o]),o++;if(u.length){p("Style blocks cannot appear after the first cue.");continue}w||f.push(y.join("\n"));continue}if(""==l[++o]||void 0==l[o]){p("Cue identifier cannot be standalone.");continue}if(-1==l[o].indexOf("--\x3e")){T=!1,p("Cue identifier needs to be followed by timestamp.");continue}}c=!1;var O=new n(l[o],p),A=0;if(u.length>0&&(A=u[u.length-1].startTime),!T||O.parse(x,A)){for(o++;""!=l[o]&&void 0!=l[o];){if(-1!=l[o].indexOf("--\x3e")){p("Blank line missing before cue."),c=!0;break}""!=x.text&&(x.text+="\n"),x.text+=l[o],o++}var L=new i(x.text,p,a,t);x.tree=L.parse(x.startTime,x.endTime),u.push(x)}else for(x=null,o++;""!=l[o]&&void 0!=l[o];){if(-1!=l[o].indexOf("--\x3e")){c=!0;break}o++}}return u.sort(function(e,t){return e.startTime<t.startTime?-1:e.startTime>t.startTime?1:e.endTime>t.endTime?-1:e.endTime<t.endTime?1:0}),{cues:u,errors:d,time:Date.now()-r,styles:f}}},n=function(e,t){var n=/[\u0020\t\f]/,i=/[^\u0020\t\f]/,s=(e=e,0),a=function(e){t(e,s+1)};function r(t){for(;void 0!=e[s]&&t.test(e[s]);)s++}function o(t){for(var n="";void 0!=e[s]&&t.test(e[s]);)n+=e[s],s++;return n}function l(){var t,n,i,r,l="minutes";if(void 0!=e[s])if(/\d/.test(e[s]))if(((t=o(/\d/)).length>2||parseInt(t,10)>59)&&(l="hours"),":"==e[s])if(s++,2==(n=o(/\d/)).length){if("hours"==l||":"==e[s]){if(":"!=e[s])return void a("No seconds found or minutes is greater than 59.");if(s++,2!=(i=o(/\d/)).length)return void a("Must be exactly two digits.")}else{if(2!=t.length)return void a("Must be exactly two digits.");i=n,n=t,t="0"}if("."==e[s])if(s++,3==(r=o(/\d/)).length)if(parseInt(n,10)>59)a("You cannot have more than 59 minutes.");else{if(!(parseInt(i,10)>59))return 60*parseInt(t,10)*60+60*parseInt(n,10)+parseInt(i,10)+parseInt(r,10)/1e3;a("You cannot have more than 59 seconds.")}else a("Milliseconds must be given in three digits.");else a('No decimal separator (".") found.')}else a("Must be exactly two digits.");else a("No time unit separator found.");else a("Timestamp must start with a character in the range 0-9.");else a("No timestamp found.")}this.parse=function(t,o){if(r(n),t.startTime=l(),void 0!=t.startTime)if(t.startTime<o&&a("Start timestamp is not greater than or equal to start timestamp of previous cue."),i.test(e[s])&&a("Timestamp not separated from '--\x3e' by whitespace."),r(n),"-"==e[s])if(s++,"-"==e[s])if(s++,">"==e[s]){if(s++,i.test(e[s])&&a("'--\x3e' not separated from timestamp by whitespace."),r(n),t.endTime=l(),void 0!=t.endTime)return t.endTime<=t.startTime&&a("End timestamp is not greater than start timestamp."),i.test(e[s]),r(n),function(e,t){for(var i=e.split(n),s=[],r=0;r<i.length;r++)if(""!=i[r]){var o=i[r].indexOf(":"),l=i[r].slice(0,o),c=i[r].slice(o+1);if(-1!=s.indexOf(l)&&a("Duplicate setting."),s.push(l),""==c)return void a("No value for setting defined.");if("vertical"==l){if("rl"!=c&&"lr"!=c){a("Writing direction can only be set to 'rl' or 'rl'.");continue}t.direction=c}else if("line"==l){if(/,/.test(c)){var f=c.split(",");c=f[0];var u=f[1]}if(!/^[-\d](\d*)(\.\d+)?%?$/.test(c)){a("Line position takes a number or percentage.");continue}if(-1!=c.indexOf("-",1)){a("Line position can only have '-' at the start.");continue}if(-1!=c.indexOf("%")&&c.indexOf("%")!=c.Line truncated
@@ -55,4 +55,4 @@
*
* d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginMultipleSubtitles=t(),define(function(){return e.artplayerPluginMultipleSubtitles})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginMultipleSubtitles=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.defineProperties,n=Object.getOwnPropertyDescriptors,i=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=(t,n,i)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:i}):t[n]=i,o=(e,t)=>{for(var n in t||(t={}))a.call(t,n)&&r(e,n,t[n]);if(i)for(var n of i(t))s.call(t,n)&&r(e,n,t[n]);return e},l=(e,t,n)=>new Promise((i,a)=>{var s=e=>{try{o(n.next(e))}catch(t){a(t)}},r=e=>{try{o(n.throw(e))}catch(t){a(t)}},o=e=>e.done?i(e.value):Promise.resolve(e.value).then(s,r);o((n=n.apply(e,t)).next())}),f={direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center"},c=function(e){e||(e={"&amp":"&","&lt":"<","&gt":">","&lrm":"\u200e","&rlm":"\u200f","&nbsp":"\xa0"}),this.entities=e,this.parse=function(t,n){t=t.replace(/\0/g,"\ufffd");var i=Date.now(),a=0,s=t.split(/\r\n|\r|\n/),r=!1,o=[],l=[],c=[];function p(e,t){c.push({message:e,line:a+1,col:t})}var m=s[a],g=m.length,h="WEBVTT",v=0,b=6;for("\ufeff"===m[0]&&(v=1,b+=1),(g<b||m.indexOf(h)!==0+v||g>b&&" "!==m[b]&&"\t"!==m[b])&&p('No valid signature. (File needs to start with "WEBVTT".)'),a++;""!=s[a]&&void 0!=s[a];){if(p("No blank line after the signature."),-1!=s[a].indexOf("--\x3e")){r=!0;break}a++}for(;void 0!=s[a];){for(var x;!r&&""==s[a];)a++;if(!r&&void 0==s[a])break;x=Object.assign({},f,{id:"",startTime:0,endTime:0,pauseOnExit:!1,direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center",text:"",tree:null});var T=!0;if(-1==s[a].indexOf("--\x3e")){if(x.id=s[a],/^NOTE($|[ \t])/.test(x.id)){for(a++;""!=s[a]&&void 0!=s[a];)-1!=s[a].indexOf("--\x3e")&&p("Cannot have timestamp in a comment."),a++;continue}if(/^STYLE($|[ \t])/.test(x.id)){var y=[],w=!1;for(a++;""!=s[a]&&void 0!=s[a];)-1!=s[a].indexOf("--\x3e")&&(p("Cannot have timestamp in a style block."),w=!0),y.push(s[a]),a++;if(l.length){p("Style blocks cannot appear after the first cue.");continue}w||o.push(y.join("\n"));continue}if(""==s[++a]||void 0==s[a]){p("Cue identifier cannot be standalone.");continue}if(-1==s[a].indexOf("--\x3e")){T=!1,p("Cue identifier needs to be followed by timestamp.");continue}}r=!1;var O=new u(s[a],p),P=0;if(l.length>0&&(P=l[l.length-1].startTime),!T||O.parse(x,P)){for(a++;""!=s[a]&&void 0!=s[a];){if(-1!=s[a].indexOf("--\x3e")){p("Blank line missing before cue."),r=!0;break}""!=x.text&&(x.text+="\n"),x.text+=s[a],a++}var I=new d(x.text,p,n,e);x.tree=I.parse(x.startTime,x.endTime),l.push(x)}else for(x=null,a++;""!=s[a]&&void 0!=s[a];){if(-1!=s[a].indexOf("--\x3e")){r=!0;break}a++}}return l.sort(function(e,t){return e.startTime<t.startTime?-1:e.startTime>t.startTime?1:e.endTime>t.endTime?-1:e.endTime<t.endTime?1:0}),{cues:l,errors:c,time:Date.now()-i,styles:o}}},u=function(e,t){var n=/[\u0020\t\f]/,i=/[^\u0020\t\f]/,a=(e=e,0),s=function(e){t(e,a+1)};function r(t){for(;void 0!=e[a]&&t.test(e[a]);)a++}function o(t){for(var n="";void 0!=e[a]&&t.test(e[a]);)n+=e[a],a++;return n}function l(){var t,n,i,r,l="minutes";if(void 0!=e[a])if(/\d/.test(e[a]))if(((t=o(/\d/)).length>2||parseInt(t,10)>59)&&(l="hours"),":"==e[a])if(a++,2==(n=o(/\d/)).length){if("hours"==l||":"==e[a]){if(":"!=e[a])return void s("No seconds found or minutes is greater than 59.");if(a++,2!=(i=o(/\d/)).length)return void s("Must be exactly two digits.")}else{if(2!=t.length)return void s("Must be exactly two digits.");i=n,n=t,t="0"}if("."==e[a])if(a++,3==(r=o(/\d/)).length)if(parseInt(n,10)>59)s("You cannot have more than 59 minutes.");else{if(!(parseInt(i,10)>59))return 60*parseInt(t,10)*60+60*parseInt(n,10)+parseInt(i,10)+parseInt(r,10)/1e3;s("You cannot have more than 59 seconds.")}else s("Milliseconds must be given in three digits.");else s('No decimal separator (".") found.')}else s("Must be exactly two digits.");else s("No time unit separator found.");else s("Timestamp must start with a character in the range 0-9.");else s("No timestamp found.")}this.parse=function(t,o){if(r(n),t.startTime=l(),void 0!=t.startTime)if(t.startTime<o&&s("Start timestamp is not greater than or equal to start timestamp of previous cue."),i.test(e[a])&&s("Timestamp not separated from '--\x3e' by whitespace."),r(n),"-"==e[a])if(a++,"-"==e[a])if(a++,">"==e[a]){if(a++,i.test(e[a])&&s("'--\x3e' not separated from timestamp by whitespace."),r(n),t.endTime=l(),void 0!=t.endTime)return t.endTime<=t.startTime&&s("End timestamp is not greater than start timestamp."),i.test(e[a]),r(n),function(e,t){for(var i=e.split(n),a=[],r=0;r<i.length;r++)ifLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginMultipleSubtitles=t(),define(function(){return e.artplayerPluginMultipleSubtitles})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginMultipleSubtitles=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.defineProperties,n=Object.getOwnPropertyDescriptors,i=Object.getOwnPropertySymbols,r=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,a=(t,n,i)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:i}):t[n]=i,o=(e,t)=>{for(var n in t||(t={}))r.call(t,n)&&a(e,n,t[n]);if(i)for(var n of i(t))s.call(t,n)&&a(e,n,t[n]);return e},l=(e,i)=>t(e,n(i)),c=(e,t,n)=>new Promise((i,r)=>{var s=e=>{try{o(n.next(e))}catch(t){r(t)}},a=e=>{try{o(n.throw(e))}catch(t){r(t)}},o=e=>e.done?i(e.value):Promise.resolve(e.value).then(s,a);o((n=n.apply(e,t)).next())});var f={direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center"},u=function(e){e||(e={"&amp":"&","&lt":"<","&gt":">","&lrm":"\u200e","&rlm":"\u200f","&nbsp":"\xa0"}),this.entities=e,this.parse=function(t,n){t=t.replace(/\0/g,"\ufffd");var i=Date.now(),r=0,s=t.split(/\r\n|\r|\n/),a=!1,o=[],l=[],c=[];function u(e,t){c.push({message:e,line:r+1,col:t})}var m=s[r],g=m.length,h="WEBVTT",v=0,b=6;for("\ufeff"===m[0]&&(v=1,b+=1),(g<b||m.indexOf(h)!==0+v||g>b&&" "!==m[b]&&"\t"!==m[b])&&u('No valid signature. (File needs to start with "WEBVTT".)'),r++;""!=s[r]&&void 0!=s[r];){if(u("No blank line after the signature."),-1!=s[r].indexOf("--\x3e")){a=!0;break}r++}for(;void 0!=s[r];){for(var y;!a&&""==s[r];)r++;if(!a&&void 0==s[r])break;y=Object.assign({},f,{id:"",startTime:0,endTime:0,pauseOnExit:!1,direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center",text:"",tree:null});var x=!0;if(-1==s[r].indexOf("--\x3e")){if(y.id=s[r],/^NOTE($|[ \t])/.test(y.id)){for(r++;""!=s[r]&&void 0!=s[r];)-1!=s[r].indexOf("--\x3e")&&u("Cannot have timestamp in a comment."),r++;continue}if(/^STYLE($|[ \t])/.test(y.id)){var T=[],w=!1;for(r++;""!=s[r]&&void 0!=s[r];)-1!=s[r].indexOf("--\x3e")&&(u("Cannot have timestamp in a style block."),w=!0),T.push(s[r]),r++;if(l.length){u("Style blocks cannot appear after the first cue.");continue}w||o.push(T.join("\n"));continue}if(""==s[++r]||void 0==s[r]){u("Cue identifier cannot be standalone.");continue}if(-1==s[r].indexOf("--\x3e")){x=!1,u("Cue identifier needs to be followed by timestamp.");continue}}a=!1;var O=new d(s[r],u),P=0;if(l.length>0&&(P=l[l.length-1].startTime),!x||O.parse(y,P)){for(r++;""!=s[r]&&void 0!=s[r];){if(-1!=s[r].indexOf("--\x3e")){u("Blank line missing before cue."),a=!0;break}""!=y.text&&(y.text+="\n"),y.text+=s[r],r++}var A=new p(y.text,u,n,e);y.tree=A.parse(y.startTime,y.endTime),l.push(y)}else for(y=null,r++;""!=s[r]&&void 0!=s[r];){if(-1!=s[r].indexOf("--\x3e")){a=!0;break}r++}}return l.sort(function(e,t){return e.startTime<t.startTime?-1:e.startTime>t.startTime?1:e.endTime>t.endTime?-1:e.endTime<t.endTime?1:0}),{cues:l,errors:c,time:Date.now()-i,styles:o}}},d=function(e,t){var n=/[\u0020\t\f]/,i=/[^\u0020\t\f]/,r=(e=e,0),s=function(e){t(e,r+1)};function a(t){for(;void 0!=e[r]&&t.test(e[r]);)r++}function o(t){for(var n="";void 0!=e[r]&&t.test(e[r]);)n+=e[r],r++;return n}function l(){var t,n,i,a,l="minutes";if(void 0!=e[r])if(/\d/.test(e[r]))if(((t=o(/\d/)).length>2||parseInt(t,10)>59)&&(l="hours"),":"==e[r])if(r++,2==(n=o(/\d/)).length){if("hours"==l||":"==e[r]){if(":"!=e[r])return void s("No seconds found or minutes is greater than 59.");if(r++,2!=(i=o(/\d/)).length)return void s("Must be exactly two digits.")}else{if(2!=t.length)return void s("Must be exactly two digits.");i=n,n=t,t="0"}if("."==e[r])if(r++,3==(a=o(/\d/)).length)if(parseInt(n,10)>59)s("You cannot have more than 59 minutes.");else{if(!(parseInt(i,10)>59))return 60*parseInt(t,10)*60+60*parseInt(n,10)+parseInt(i,10)+parseInt(a,10)/1e3;s("You cannot have more than 59 seconds.")}else s("Milliseconds must be given in three digits.");else s('No decimal separator (".") found.')}else s("Must be exactly two digits.");else s("No time unit separator found.");else s("Timestamp must start with a character in the range 0-9.");else s("No timestamp found.")}this.parse=function(t,o){if(a(n),t.startTime=l(),void 0!=t.startTime)if(t.startTime<o&&s("Start timestamp is not greater than or equal to start timestamp of previous cue."),i.test(e[r])&&s("Timestamp not separated from '--\x3e' by whitespace."),a(n),"-"==e[r])if(r++,"-"==e[r])if(r++,">"==e[r]){if(r++,i.test(e[r])&&s("'--\x3e' not separated from timestamp by whitespace."),a(n),t.endTime=l(),void 0!=t.endTime)return t.endTime<=t.startTime&&s("End timestamp is not greater than start timestamp."),i.test(e[r]),a(n),function(e,t){for(var i=e.split(n),r=[Line truncated
@@ -55,6 +55,56 @@
*
* d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
*/
function createLifetime(art) {
let closed = Boolean(art.isDestroy);
const cleanups = /* @__PURE__ */ new Set();
let cancel;
const cancelled = new Promise((resolve) => {
cancel = resolve;
});
function run(cleanup) {
try {
cleanup();
} catch (error) {
console.warn("Failed to clean up multiple subtitles:", error);
}
}
const lifetime = {
get closed() {
return closed;
},
own(cleanup) {
if (closed)
run(cleanup);
else
cleanups.add(cleanup);
return () => cleanups.delete(cleanup);
},
wait(value) {
return Promise.race([value, cancelled]);
},
dispose() {
if (closed)
return;
closed = true;
cancel();
const pending = [...cleanups].reverse();
cleanups.clear();
for (const cleanup of pending)
run(cleanup);
}
};
if (!closed) {
lifetime.own(() => art.off("destroy", lifetime.dispose));
try {
art.on("destroy", lifetime.dispose);
} catch (error) {
lifetime.dispose();
throw error;
}
}
return lifetime;
}
var defaultCueSettings = {
direction: "horizontal",
snapToLines: true,
@@ -799,85 +849,166 @@ var WebVTTSerializer = function() {
return result;
};
};
async function loadVtt(option, { getExt, srtToVtt, assToVtt }) {
const response = await fetch(option.url);
const buffer = await response.arrayBuffer();
const decoder = new TextDecoder(option.encoding || "utf-8");
const text = decoder.decode(buffer);
switch (option.type || getExt(option.url)) {
case "srt": {
return srtToVtt(text);
}
case "ass": {
return assToVtt(text);
}
case "vtt": {
return text;
}
default:
return "";
}
}
function mergeTrees(trees) {
function parseTracks(vtts, subtitles) {
const parser = new WebVTTParser();
const result = parser.parse("", "metadata");
for (let i = 0; i < trees.length; i++) {
const tree = trees[i];
if (!tree.updated) {
tree.updated = true;
for (let j = 0; j < tree.cues.length; j++) {
const cue = tree.cues[j];
for (let k = 0; k < cue.tree.children.length; k++) {
const children = cue.tree.children[k];
children.value = `<div class="art-subtitle-${tree.name}">${children.value}</div>`;
return vtts.map((vtt, index) => {
const tree = parser.parse(vtt, "metadata");
tree.url = subtitles[index].url;
tree.name = subtitles[index].name;
return tree;
});
}
function serializeTracks(trees) {
const cues = [];
for (const tree of trees) {
for (const cue of tree.cues) {
cues.push({
...cue,
tree: {
...cue.tree,
children: cue.tree.children.map((child) => ({
...child,
value: `<div class="art-subtitle-${tree.name}">${child.value}</div>`
}))
}
}
});
}
result.cues.push(...tree.cues);
}
return result;
return new WebVTTSerializer().serialize(cues);
}
function createRenderer(art, lifetime, unescape) {
let current = null;
function own(url) {
let freed = false;
let release = () => {
};
const entry = {
free() {
if (freed)
return;
freed = true;
release();
URL.revokeObjectURL(url);
}
};
release = lifetime.own(entry.free);
return entry;
}
return (vtt) => {
if (lifetime.closed)
return;
const url = URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
const entry = own(url);
if (lifetime.closed)
return;
const previous = current;
let option;
let escape;
let configured = false;
current = entry;
try {
option = art.option.subtitle;
escape = option.escape;
if (lifetime.closed || current !== entry)
return;
option.escape = false;
configured = true;
const config = { ...option, url, type: "vtt", onVttLoad: unescape };
if (lifetime.closed || current !== entry)
return;
const pending = art.subtitle.init(config);
Promise.resolve(pending).catch((error) => {
if (current === entry) {
current = null;
entry.free();
}
if (!lifetime.closed)
console.warn("Failed to initialize multiple subtitles:", error);
});
} catch (error) {
if (current === entry) {
current = previous;
if (configured && option.escape === false)
option.escape = escape;
}
entry.free();
throw error;
} finally {
if (previous !== current)
previous?.free();
if (entry !== current)
entry.free();
}
};
}
async function loadVtt(option, { getExt, srtToVtt, assToVtt }, lifetime) {
if (lifetime.closed)
return;
const controller = typeof AbortController === "function" ? new AbortController() : null;
const release = lifetime.own(() => controller?.abort());
try {
const url = option.url;
if (lifetime.closed)
return;
const response = await lifetime.wait(controller ? fetch(url, { signal: controller.signal }) : fetch(url));
if (lifetime.closed)
return;
if (response.ok === false)
throw new Error(`Failed to fetch multiple subtitles: HTTP ${response.status}`);
const buffer = await lifetime.wait(response.arrayBuffer());
if (lifetime.closed)
return;
const text = new TextDecoder(option.encoding || "utf-8").decode(buffer);
switch (option.type || getExt(option.url)) {
case "srt":
return srtToVtt(text);
case "ass":
return assToVtt(text);
case "vtt":
return text;
default:
return "";
}
} catch (error) {
controller?.abort();
throw error;
} finally {
release();
}
}
function artplayerPluginMultipleSubtitles({ subtitles = [] }) {
return async (art) => {
const { unescape, getExt, srtToVtt, assToVtt } = art.constructor.utils;
const parser = new WebVTTParser();
const seri = new WebVTTSerializer();
const vtts = await Promise.all(
subtitles.map((option) => {
return loadVtt(option, { getExt, srtToVtt, assToVtt });
})
);
const trees = vtts.map((vtt, index) => {
const tree = parser.parse(vtt, "metadata");
tree.url = subtitles[index].url;
tree.name = subtitles[index].name;
return tree;
});
let lastUrl = "";
function setTracks(trees2 = []) {
const tree = mergeTrees(trees2);
const vtt = seri.serialize(tree.cues);
URL.revokeObjectURL(lastUrl);
const url = URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
lastUrl = url;
art.option.subtitle.escape = false;
art.subtitle.init({
...art.option.subtitle,
url,
type: "vtt",
onVttLoad: unescape
});
const lifetime = createLifetime(art);
const render = createRenderer(art, lifetime, unescape);
let trees = [];
function setTracks(selected) {
if (!lifetime.closed)
render(serializeTracks(selected));
}
setTracks(trees);
return {
const result = {
name: "multipleSubtitles",
tracks(names = []) {
return setTracks(names.map((name) => trees.find((tree) => tree.name === name)));
if (!lifetime.closed)
setTracks(names.map((name) => trees.find((tree) => tree.name === name)));
},
reset() {
return setTracks(trees);
setTracks(trees);
}
};
try {
if (lifetime.closed)
return result;
const vtts = await Promise.all(subtitles.map((option) => loadVtt(option, { getExt, srtToVtt, assToVtt }, lifetime)));
if (lifetime.closed)
return result;
trees = parseTracks(vtts, subtitles);
setTracks(trees);
return result;
} catch (error) {
lifetime.dispose();
throw error;
}
};
}
export {
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:unit": "node --test test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -88,7 +88,7 @@
"test:auto-thumbnail": "node --test test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs",
"test:vtt-thumbnail": "node --test test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js refactor/scripts/vtt-thumbnail-contract.test.mjs refactor/scripts/vtt-thumbnail-types.test.mjs",
"test:vtt-thumbnail-types-package": "node refactor/scripts/vtt-thumbnail-package-types.mjs",
"test:multiple-subtitles": "node --test test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js refactor/scripts/multiple-subtitles-contract.test.mjs"
"test:multiple-subtitles": "node --test test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js refactor/scripts/multiple-subtitles-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -8,9 +8,15 @@ task PKG-MULTI-SUB-04 owns that compatibility work. Do not infer runtime behavio
## Current module boundaries
- `src/index.js` currently combines download/decode, conversion, parsing, merging, Blob URL
replacement and public registration. Task 03 will separate pure merge, requests and owned
resources while preserving the factory and CSS hooks.
- `src/index.js` composes registration, named selection and the unchanged public result.
- `src/request.js` owns each fetch through byte decoding and delegates SRT/ASS conversion
to the existing core utils. Failure aborts the request; registration failure closes siblings.
- `src/merge.js` parses metadata and serializes selected cues without mutating parsed trees.
It depends only on the vendor parser, not the player or browser resource APIs.
- `src/lifetime.js` owns the destroy listener, request cancellation and resource callbacks.
Its cancellation Promise also settles pending operations without AbortController support.
- `src/render.js` owns generated Blob URLs and synchronous/async host installation outcomes.
Reentrant selections and stale host rejections cannot release the latest selected resource.
- `src/parser.js` is vendored WebVTT parser/serializer code with a CC0 dedication. It is not
owned plugin code to mechanically migrate to TypeScript. Keep it distinct from task 04.
The pinned upstream reference and full adaptation check are in
@@ -23,19 +29,22 @@ task PKG-MULTI-SUB-04 owns that compatibility work. Do not infer runtime behavio
All tracks download concurrently and decode through TextDecoder. Explicit type wins over
the URL extension; SRT/ASS conversion delegates to existing core utils. `onParser` appears
in declarations but is not called. Parsing runs in metadata mode. Each track's top-level
cue text receives a name-based div once; current merging concatenates cue arrays by track
cue text receives a name-based div in a copied cue; merging concatenates cue arrays by track
order, whereas 1.0.0 merged by cue index and retained the first track's timestamps.
Each selection serializes a new VTT, revokes the preceding URL, creates a text/vtt Blob,
sets the live subtitle option's escape to false and calls subtitle.init with the live
configuration plus URL/type/onVttLoad overrides. The latest URL and pending requests have
no destroy owner yet. These are explicit future fixes, not behavior to silently call safe.
Each selection serializes VTT and allocates a text/vtt Blob before replacing the preceding
URL. It sets the live subtitle option's escape to false and calls subtitle.init with live
configuration plus URL/type/onVttLoad overrides. Allocation failure preserves the prior URL;
synchronous installation failure releases the new URL and restores the previous ownership.
Async host failure is observed and releases only the failed active installation, with a warning
while alive. The plugin cannot generically undo host side effects after asynchronous failure.
Registration does not await host media loading, and selection methods remain void.
The failure baseline now reproduces late fetch/body installation, retained and newly allocated
URLs after destroy, ignored HTTP failures, orphaned URLs after init failures, and revoking the
old URL before a new allocation can fail. Async init rejection is currently unobserved by the
plugin. Task 03 must handle these internal failures while retaining the void selection methods.
Keep the current parser's best-effort behavior separate from these resource fixes: invalid-header
Destroy cancels pending requests, settles registration with an inert result, releases owned
URLs and removes its listener. Retained methods become no-ops; restart does not refetch tracks.
Do not restore the shared escape option on normal destroy: another consumer may now own it.
The historical failure suite deliberately retains the old outcomes; the candidate lifecycle
suite verifies their fixes. Preserve parser best-effort behavior independently: invalid-header
diagnostics can coexist with usable cues, and metadata is intentionally read after download.
## Vendor boundary and validation
@@ -51,6 +60,7 @@ Use the pinned Node and Yarn 1.22.22:
```sh
yarn test:multiple-subtitles
yarn test:browser test/browser/multiple-subtitles-history.spec.js
yarn test:browser test/browser/multiple-subtitles-lifecycle.spec.js
yarn build artplayer-plugin-multiple-subtitles
```
@@ -63,5 +73,9 @@ uses actual 1.2.0 main with published/candidate cores: SRT display and selection
fetch after destroy, and native Blob readability/late reset allocations. It covers Chromium,
Firefox and Windows WebKit; it is not all historical cores, ASS display or physical Safari.
The failure unit suite uses real current core converters and TextDecoder for SRT/ASS/encodings.
Candidate lifecycle and merge suites cover cancellation, failures, resource ownership,
reentrancy and immutable serialization. The candidate browser suite adds real video source
switching, HTTP rejection and Blob revocation. For main/legacy validation, set the artifact
environment variable above before running it; source builds are used when it is absent.
Tasks 05/06 still own complete core/device combinations and demo acceptance.
See `../../refactor/baselines/multiple-subtitles-contract.md` for exact historical differences.
@@ -55,4 +55,4 @@
*
* d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginMultipleSubtitles=t(),define(function(){return e.artplayerPluginMultipleSubtitles})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginMultipleSubtitles=t()}(this,function(){"use strict";var e={direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center"},t=function(t){t||(t={"&amp":"&","&lt":"<","&gt":">","&lrm":"\u200e","&rlm":"\u200f","&nbsp":"\xa0"}),this.entities=t,this.parse=function(a,s){a=a.replace(/\0/g,"\ufffd");var r=Date.now(),o=0,l=a.split(/\r\n|\r|\n/),f=!1,c=[],u=[],d=[];function p(e,t){d.push({message:e,line:o+1,col:t})}var m=l[o],g=m.length,h="WEBVTT",v=0,b=6;for("\ufeff"===m[0]&&(v=1,b+=1),(g<b||m.indexOf(h)!==0+v||g>b&&" "!==m[b]&&"\t"!==m[b])&&p('No valid signature. (File needs to start with "WEBVTT".)'),o++;""!=l[o]&&void 0!=l[o];){if(p("No blank line after the signature."),-1!=l[o].indexOf("--\x3e")){f=!0;break}o++}for(;void 0!=l[o];){for(var x;!f&&""==l[o];)o++;if(!f&&void 0==l[o])break;x=Object.assign({},e,{id:"",startTime:0,endTime:0,pauseOnExit:!1,direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center",text:"",tree:null});var T=!0;if(-1==l[o].indexOf("--\x3e")){if(x.id=l[o],/^NOTE($|[ \t])/.test(x.id)){for(o++;""!=l[o]&&void 0!=l[o];)-1!=l[o].indexOf("--\x3e")&&p("Cannot have timestamp in a comment."),o++;continue}if(/^STYLE($|[ \t])/.test(x.id)){var y=[],w=!1;for(o++;""!=l[o]&&void 0!=l[o];)-1!=l[o].indexOf("--\x3e")&&(p("Cannot have timestamp in a style block."),w=!0),y.push(l[o]),o++;if(u.length){p("Style blocks cannot appear after the first cue.");continue}w||c.push(y.join("\n"));continue}if(""==l[++o]||void 0==l[o]){p("Cue identifier cannot be standalone.");continue}if(-1==l[o].indexOf("--\x3e")){T=!1,p("Cue identifier needs to be followed by timestamp.");continue}}f=!1;var O=new n(l[o],p),L=0;if(u.length>0&&(L=u[u.length-1].startTime),!T||O.parse(x,L)){for(o++;""!=l[o]&&void 0!=l[o];){if(-1!=l[o].indexOf("--\x3e")){p("Blank line missing before cue."),f=!0;break}""!=x.text&&(x.text+="\n"),x.text+=l[o],o++}var I=new i(x.text,p,s,t);x.tree=I.parse(x.startTime,x.endTime),u.push(x)}else for(x=null,o++;""!=l[o]&&void 0!=l[o];){if(-1!=l[o].indexOf("--\x3e")){f=!0;break}o++}}return u.sort(function(e,t){return e.startTime<t.startTime?-1:e.startTime>t.startTime?1:e.endTime>t.endTime?-1:e.endTime<t.endTime?1:0}),{cues:u,errors:d,time:Date.now()-r,styles:c}}},n=function(e,t){var n=/[\u0020\t\f]/,i=/[^\u0020\t\f]/,a=(e=e,0),s=function(e){t(e,a+1)};function r(t){for(;void 0!=e[a]&&t.test(e[a]);)a++}function o(t){for(var n="";void 0!=e[a]&&t.test(e[a]);)n+=e[a],a++;return n}function l(){var t,n,i,r,l="minutes";if(void 0!=e[a])if(/\d/.test(e[a]))if(((t=o(/\d/)).length>2||parseInt(t,10)>59)&&(l="hours"),":"==e[a])if(a++,2==(n=o(/\d/)).length){if("hours"==l||":"==e[a]){if(":"!=e[a])return void s("No seconds found or minutes is greater than 59.");if(a++,2!=(i=o(/\d/)).length)return void s("Must be exactly two digits.")}else{if(2!=t.length)return void s("Must be exactly two digits.");i=n,n=t,t="0"}if("."==e[a])if(a++,3==(r=o(/\d/)).length)if(parseInt(n,10)>59)s("You cannot have more than 59 minutes.");else{if(!(parseInt(i,10)>59))return 60*parseInt(t,10)*60+60*parseInt(n,10)+parseInt(i,10)+parseInt(r,10)/1e3;s("You cannot have more than 59 seconds.")}else s("Milliseconds must be given in three digits.");else s('No decimal separator (".") found.')}else s("Must be exactly two digits.");else s("No time unit separator found.");else s("Timestamp must start with a character in the range 0-9.");else s("No timestamp found.")}this.parse=function(t,o){if(r(n),t.startTime=l(),void 0!=t.startTime)if(t.startTime<o&&s("Start timestamp is not greater than or equal to start timestamp of previous cue."),i.test(e[a])&&s("Timestamp not separated from '--\x3e' by whitespace."),r(n),"-"==e[a])if(a++,"-"==e[a])if(a++,">"==e[a]){if(a++,i.test(e[a])&&s("'--\x3e' not separated from timestamp by whitespace."),r(n),t.endTime=l(),void 0!=t.endTime)return t.endTime<=t.startTime&&s("End timestamp is not greater than start timestamp."),i.test(e[a]),r(n),function(e,t){for(var i=e.split(n),a=[],r=0;r<i.length;r++)if(""!=i[r]){var o=i[r].indexOf(":"),l=i[r].slice(0,o),f=i[r].slice(o+1);if(-1!=a.indexOf(l)&&s("Duplicate setting."),a.push(l),""==f)return void s("No value for setting defined.");if("vertical"==l){if("rl"!=f&&"lr"!=f){s("Writing direction can only be set to 'rl' or 'rl'.");continue}t.direction=f}else if("line"==l){if(/,/.test(f)){var c=f.split(",");f=c[0];var u=c[1]}if(!/^[-\d](\d*)(\.\d+)?%?$/.test(f)){s("Line position takes a number or percentage.");continue}if(-1!=f.indexOf("-",1)){s("Line position can only have '-' at the start.");continue}if(-1!=f.indexOf("%")&&f.indexOf("%")!=f.Line truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginMultipleSubtitles=t(),define(function(){return e.artplayerPluginMultipleSubtitles})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginMultipleSubtitles=t()}(this,function(){"use strict";var e={direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center"},t=function(t){t||(t={"&amp":"&","&lt":"<","&gt":">","&lrm":"\u200e","&rlm":"\u200f","&nbsp":"\xa0"}),this.entities=t,this.parse=function(s,a){s=s.replace(/\0/g,"\ufffd");var r=Date.now(),o=0,l=s.split(/\r\n|\r|\n/),c=!1,f=[],u=[],d=[];function p(e,t){d.push({message:e,line:o+1,col:t})}var m=l[o],g=m.length,h="WEBVTT",v=0,b=6;for("\ufeff"===m[0]&&(v=1,b+=1),(g<b||m.indexOf(h)!==0+v||g>b&&" "!==m[b]&&"\t"!==m[b])&&p('No valid signature. (File needs to start with "WEBVTT".)'),o++;""!=l[o]&&void 0!=l[o];){if(p("No blank line after the signature."),-1!=l[o].indexOf("--\x3e")){c=!0;break}o++}for(;void 0!=l[o];){for(var x;!c&&""==l[o];)o++;if(!c&&void 0==l[o])break;x=Object.assign({},e,{id:"",startTime:0,endTime:0,pauseOnExit:!1,direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center",text:"",tree:null});var T=!0;if(-1==l[o].indexOf("--\x3e")){if(x.id=l[o],/^NOTE($|[ \t])/.test(x.id)){for(o++;""!=l[o]&&void 0!=l[o];)-1!=l[o].indexOf("--\x3e")&&p("Cannot have timestamp in a comment."),o++;continue}if(/^STYLE($|[ \t])/.test(x.id)){var y=[],w=!1;for(o++;""!=l[o]&&void 0!=l[o];)-1!=l[o].indexOf("--\x3e")&&(p("Cannot have timestamp in a style block."),w=!0),y.push(l[o]),o++;if(u.length){p("Style blocks cannot appear after the first cue.");continue}w||f.push(y.join("\n"));continue}if(""==l[++o]||void 0==l[o]){p("Cue identifier cannot be standalone.");continue}if(-1==l[o].indexOf("--\x3e")){T=!1,p("Cue identifier needs to be followed by timestamp.");continue}}c=!1;var O=new n(l[o],p),A=0;if(u.length>0&&(A=u[u.length-1].startTime),!T||O.parse(x,A)){for(o++;""!=l[o]&&void 0!=l[o];){if(-1!=l[o].indexOf("--\x3e")){p("Blank line missing before cue."),c=!0;break}""!=x.text&&(x.text+="\n"),x.text+=l[o],o++}var L=new i(x.text,p,a,t);x.tree=L.parse(x.startTime,x.endTime),u.push(x)}else for(x=null,o++;""!=l[o]&&void 0!=l[o];){if(-1!=l[o].indexOf("--\x3e")){c=!0;break}o++}}return u.sort(function(e,t){return e.startTime<t.startTime?-1:e.startTime>t.startTime?1:e.endTime>t.endTime?-1:e.endTime<t.endTime?1:0}),{cues:u,errors:d,time:Date.now()-r,styles:f}}},n=function(e,t){var n=/[\u0020\t\f]/,i=/[^\u0020\t\f]/,s=(e=e,0),a=function(e){t(e,s+1)};function r(t){for(;void 0!=e[s]&&t.test(e[s]);)s++}function o(t){for(var n="";void 0!=e[s]&&t.test(e[s]);)n+=e[s],s++;return n}function l(){var t,n,i,r,l="minutes";if(void 0!=e[s])if(/\d/.test(e[s]))if(((t=o(/\d/)).length>2||parseInt(t,10)>59)&&(l="hours"),":"==e[s])if(s++,2==(n=o(/\d/)).length){if("hours"==l||":"==e[s]){if(":"!=e[s])return void a("No seconds found or minutes is greater than 59.");if(s++,2!=(i=o(/\d/)).length)return void a("Must be exactly two digits.")}else{if(2!=t.length)return void a("Must be exactly two digits.");i=n,n=t,t="0"}if("."==e[s])if(s++,3==(r=o(/\d/)).length)if(parseInt(n,10)>59)a("You cannot have more than 59 minutes.");else{if(!(parseInt(i,10)>59))return 60*parseInt(t,10)*60+60*parseInt(n,10)+parseInt(i,10)+parseInt(r,10)/1e3;a("You cannot have more than 59 seconds.")}else a("Milliseconds must be given in three digits.");else a('No decimal separator (".") found.')}else a("Must be exactly two digits.");else a("No time unit separator found.");else a("Timestamp must start with a character in the range 0-9.");else a("No timestamp found.")}this.parse=function(t,o){if(r(n),t.startTime=l(),void 0!=t.startTime)if(t.startTime<o&&a("Start timestamp is not greater than or equal to start timestamp of previous cue."),i.test(e[s])&&a("Timestamp not separated from '--\x3e' by whitespace."),r(n),"-"==e[s])if(s++,"-"==e[s])if(s++,">"==e[s]){if(s++,i.test(e[s])&&a("'--\x3e' not separated from timestamp by whitespace."),r(n),t.endTime=l(),void 0!=t.endTime)return t.endTime<=t.startTime&&a("End timestamp is not greater than start timestamp."),i.test(e[s]),r(n),function(e,t){for(var i=e.split(n),s=[],r=0;r<i.length;r++)if(""!=i[r]){var o=i[r].indexOf(":"),l=i[r].slice(0,o),c=i[r].slice(o+1);if(-1!=s.indexOf(l)&&a("Duplicate setting."),s.push(l),""==c)return void a("No value for setting defined.");if("vertical"==l){if("rl"!=c&&"lr"!=c){a("Writing direction can only be set to 'rl' or 'rl'.");continue}t.direction=c}else if("line"==l){if(/,/.test(c)){var f=c.split(",");c=f[0];var u=f[1]}if(!/^[-\d](\d*)(\.\d+)?%?$/.test(c)){a("Line position takes a number or percentage.");continue}if(-1!=c.indexOf("-",1)){a("Line position can only have '-' at the start.");continue}if(-1!=c.indexOf("%")&&c.indexOf("%")!=c.Line truncated
@@ -55,4 +55,4 @@
*
* d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginMultipleSubtitles=t(),define(function(){return e.artplayerPluginMultipleSubtitles})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginMultipleSubtitles=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.defineProperties,n=Object.getOwnPropertyDescriptors,i=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=(t,n,i)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:i}):t[n]=i,o=(e,t)=>{for(var n in t||(t={}))a.call(t,n)&&r(e,n,t[n]);if(i)for(var n of i(t))s.call(t,n)&&r(e,n,t[n]);return e},l=(e,t,n)=>new Promise((i,a)=>{var s=e=>{try{o(n.next(e))}catch(t){a(t)}},r=e=>{try{o(n.throw(e))}catch(t){a(t)}},o=e=>e.done?i(e.value):Promise.resolve(e.value).then(s,r);o((n=n.apply(e,t)).next())}),f={direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center"},c=function(e){e||(e={"&amp":"&","&lt":"<","&gt":">","&lrm":"\u200e","&rlm":"\u200f","&nbsp":"\xa0"}),this.entities=e,this.parse=function(t,n){t=t.replace(/\0/g,"\ufffd");var i=Date.now(),a=0,s=t.split(/\r\n|\r|\n/),r=!1,o=[],l=[],c=[];function p(e,t){c.push({message:e,line:a+1,col:t})}var m=s[a],g=m.length,h="WEBVTT",v=0,b=6;for("\ufeff"===m[0]&&(v=1,b+=1),(g<b||m.indexOf(h)!==0+v||g>b&&" "!==m[b]&&"\t"!==m[b])&&p('No valid signature. (File needs to start with "WEBVTT".)'),a++;""!=s[a]&&void 0!=s[a];){if(p("No blank line after the signature."),-1!=s[a].indexOf("--\x3e")){r=!0;break}a++}for(;void 0!=s[a];){for(var x;!r&&""==s[a];)a++;if(!r&&void 0==s[a])break;x=Object.assign({},f,{id:"",startTime:0,endTime:0,pauseOnExit:!1,direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center",text:"",tree:null});var T=!0;if(-1==s[a].indexOf("--\x3e")){if(x.id=s[a],/^NOTE($|[ \t])/.test(x.id)){for(a++;""!=s[a]&&void 0!=s[a];)-1!=s[a].indexOf("--\x3e")&&p("Cannot have timestamp in a comment."),a++;continue}if(/^STYLE($|[ \t])/.test(x.id)){var y=[],w=!1;for(a++;""!=s[a]&&void 0!=s[a];)-1!=s[a].indexOf("--\x3e")&&(p("Cannot have timestamp in a style block."),w=!0),y.push(s[a]),a++;if(l.length){p("Style blocks cannot appear after the first cue.");continue}w||o.push(y.join("\n"));continue}if(""==s[++a]||void 0==s[a]){p("Cue identifier cannot be standalone.");continue}if(-1==s[a].indexOf("--\x3e")){T=!1,p("Cue identifier needs to be followed by timestamp.");continue}}r=!1;var O=new u(s[a],p),P=0;if(l.length>0&&(P=l[l.length-1].startTime),!T||O.parse(x,P)){for(a++;""!=s[a]&&void 0!=s[a];){if(-1!=s[a].indexOf("--\x3e")){p("Blank line missing before cue."),r=!0;break}""!=x.text&&(x.text+="\n"),x.text+=s[a],a++}var I=new d(x.text,p,n,e);x.tree=I.parse(x.startTime,x.endTime),l.push(x)}else for(x=null,a++;""!=s[a]&&void 0!=s[a];){if(-1!=s[a].indexOf("--\x3e")){r=!0;break}a++}}return l.sort(function(e,t){return e.startTime<t.startTime?-1:e.startTime>t.startTime?1:e.endTime>t.endTime?-1:e.endTime<t.endTime?1:0}),{cues:l,errors:c,time:Date.now()-i,styles:o}}},u=function(e,t){var n=/[\u0020\t\f]/,i=/[^\u0020\t\f]/,a=(e=e,0),s=function(e){t(e,a+1)};function r(t){for(;void 0!=e[a]&&t.test(e[a]);)a++}function o(t){for(var n="";void 0!=e[a]&&t.test(e[a]);)n+=e[a],a++;return n}function l(){var t,n,i,r,l="minutes";if(void 0!=e[a])if(/\d/.test(e[a]))if(((t=o(/\d/)).length>2||parseInt(t,10)>59)&&(l="hours"),":"==e[a])if(a++,2==(n=o(/\d/)).length){if("hours"==l||":"==e[a]){if(":"!=e[a])return void s("No seconds found or minutes is greater than 59.");if(a++,2!=(i=o(/\d/)).length)return void s("Must be exactly two digits.")}else{if(2!=t.length)return void s("Must be exactly two digits.");i=n,n=t,t="0"}if("."==e[a])if(a++,3==(r=o(/\d/)).length)if(parseInt(n,10)>59)s("You cannot have more than 59 minutes.");else{if(!(parseInt(i,10)>59))return 60*parseInt(t,10)*60+60*parseInt(n,10)+parseInt(i,10)+parseInt(r,10)/1e3;s("You cannot have more than 59 seconds.")}else s("Milliseconds must be given in three digits.");else s('No decimal separator (".") found.')}else s("Must be exactly two digits.");else s("No time unit separator found.");else s("Timestamp must start with a character in the range 0-9.");else s("No timestamp found.")}this.parse=function(t,o){if(r(n),t.startTime=l(),void 0!=t.startTime)if(t.startTime<o&&s("Start timestamp is not greater than or equal to start timestamp of previous cue."),i.test(e[a])&&s("Timestamp not separated from '--\x3e' by whitespace."),r(n),"-"==e[a])if(a++,"-"==e[a])if(a++,">"==e[a]){if(a++,i.test(e[a])&&s("'--\x3e' not separated from timestamp by whitespace."),r(n),t.endTime=l(),void 0!=t.endTime)return t.endTime<=t.startTime&&s("End timestamp is not greater than start timestamp."),i.test(e[a]),r(n),function(e,t){for(var i=e.split(n),a=[],r=0;r<i.length;r++)ifLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginMultipleSubtitles=t(),define(function(){return e.artplayerPluginMultipleSubtitles})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginMultipleSubtitles=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.defineProperties,n=Object.getOwnPropertyDescriptors,i=Object.getOwnPropertySymbols,r=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,a=(t,n,i)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:i}):t[n]=i,o=(e,t)=>{for(var n in t||(t={}))r.call(t,n)&&a(e,n,t[n]);if(i)for(var n of i(t))s.call(t,n)&&a(e,n,t[n]);return e},l=(e,i)=>t(e,n(i)),c=(e,t,n)=>new Promise((i,r)=>{var s=e=>{try{o(n.next(e))}catch(t){r(t)}},a=e=>{try{o(n.throw(e))}catch(t){r(t)}},o=e=>e.done?i(e.value):Promise.resolve(e.value).then(s,a);o((n=n.apply(e,t)).next())});var f={direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center"},u=function(e){e||(e={"&amp":"&","&lt":"<","&gt":">","&lrm":"\u200e","&rlm":"\u200f","&nbsp":"\xa0"}),this.entities=e,this.parse=function(t,n){t=t.replace(/\0/g,"\ufffd");var i=Date.now(),r=0,s=t.split(/\r\n|\r|\n/),a=!1,o=[],l=[],c=[];function u(e,t){c.push({message:e,line:r+1,col:t})}var m=s[r],g=m.length,h="WEBVTT",v=0,b=6;for("\ufeff"===m[0]&&(v=1,b+=1),(g<b||m.indexOf(h)!==0+v||g>b&&" "!==m[b]&&"\t"!==m[b])&&u('No valid signature. (File needs to start with "WEBVTT".)'),r++;""!=s[r]&&void 0!=s[r];){if(u("No blank line after the signature."),-1!=s[r].indexOf("--\x3e")){a=!0;break}r++}for(;void 0!=s[r];){for(var y;!a&&""==s[r];)r++;if(!a&&void 0==s[r])break;y=Object.assign({},f,{id:"",startTime:0,endTime:0,pauseOnExit:!1,direction:"horizontal",snapToLines:!0,linePosition:"auto",lineAlign:"start",textPosition:"auto",positionAlign:"auto",size:100,alignment:"center",text:"",tree:null});var x=!0;if(-1==s[r].indexOf("--\x3e")){if(y.id=s[r],/^NOTE($|[ \t])/.test(y.id)){for(r++;""!=s[r]&&void 0!=s[r];)-1!=s[r].indexOf("--\x3e")&&u("Cannot have timestamp in a comment."),r++;continue}if(/^STYLE($|[ \t])/.test(y.id)){var T=[],w=!1;for(r++;""!=s[r]&&void 0!=s[r];)-1!=s[r].indexOf("--\x3e")&&(u("Cannot have timestamp in a style block."),w=!0),T.push(s[r]),r++;if(l.length){u("Style blocks cannot appear after the first cue.");continue}w||o.push(T.join("\n"));continue}if(""==s[++r]||void 0==s[r]){u("Cue identifier cannot be standalone.");continue}if(-1==s[r].indexOf("--\x3e")){x=!1,u("Cue identifier needs to be followed by timestamp.");continue}}a=!1;var O=new d(s[r],u),P=0;if(l.length>0&&(P=l[l.length-1].startTime),!x||O.parse(y,P)){for(r++;""!=s[r]&&void 0!=s[r];){if(-1!=s[r].indexOf("--\x3e")){u("Blank line missing before cue."),a=!0;break}""!=y.text&&(y.text+="\n"),y.text+=s[r],r++}var A=new p(y.text,u,n,e);y.tree=A.parse(y.startTime,y.endTime),l.push(y)}else for(y=null,r++;""!=s[r]&&void 0!=s[r];){if(-1!=s[r].indexOf("--\x3e")){a=!0;break}r++}}return l.sort(function(e,t){return e.startTime<t.startTime?-1:e.startTime>t.startTime?1:e.endTime>t.endTime?-1:e.endTime<t.endTime?1:0}),{cues:l,errors:c,time:Date.now()-i,styles:o}}},d=function(e,t){var n=/[\u0020\t\f]/,i=/[^\u0020\t\f]/,r=(e=e,0),s=function(e){t(e,r+1)};function a(t){for(;void 0!=e[r]&&t.test(e[r]);)r++}function o(t){for(var n="";void 0!=e[r]&&t.test(e[r]);)n+=e[r],r++;return n}function l(){var t,n,i,a,l="minutes";if(void 0!=e[r])if(/\d/.test(e[r]))if(((t=o(/\d/)).length>2||parseInt(t,10)>59)&&(l="hours"),":"==e[r])if(r++,2==(n=o(/\d/)).length){if("hours"==l||":"==e[r]){if(":"!=e[r])return void s("No seconds found or minutes is greater than 59.");if(r++,2!=(i=o(/\d/)).length)return void s("Must be exactly two digits.")}else{if(2!=t.length)return void s("Must be exactly two digits.");i=n,n=t,t="0"}if("."==e[r])if(r++,3==(a=o(/\d/)).length)if(parseInt(n,10)>59)s("You cannot have more than 59 minutes.");else{if(!(parseInt(i,10)>59))return 60*parseInt(t,10)*60+60*parseInt(n,10)+parseInt(i,10)+parseInt(a,10)/1e3;s("You cannot have more than 59 seconds.")}else s("Milliseconds must be given in three digits.");else s('No decimal separator (".") found.')}else s("Must be exactly two digits.");else s("No time unit separator found.");else s("Timestamp must start with a character in the range 0-9.");else s("No timestamp found.")}this.parse=function(t,o){if(a(n),t.startTime=l(),void 0!=t.startTime)if(t.startTime<o&&s("Start timestamp is not greater than or equal to start timestamp of previous cue."),i.test(e[r])&&s("Timestamp not separated from '--\x3e' by whitespace."),a(n),"-"==e[r])if(r++,"-"==e[r])if(r++,">"==e[r]){if(r++,i.test(e[r])&&s("'--\x3e' not separated from timestamp by whitespace."),a(n),t.endTime=l(),void 0!=t.endTime)return t.endTime<=t.startTime&&s("End timestamp is not greater than start timestamp."),i.test(e[r]),a(n),function(e,t){for(var i=e.split(n),r=[Line truncated
@@ -55,6 +55,56 @@
*
* d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
*/
function createLifetime(art) {
let closed = Boolean(art.isDestroy);
const cleanups = /* @__PURE__ */ new Set();
let cancel;
const cancelled = new Promise((resolve) => {
cancel = resolve;
});
function run(cleanup) {
try {
cleanup();
} catch (error) {
console.warn("Failed to clean up multiple subtitles:", error);
}
}
const lifetime = {
get closed() {
return closed;
},
own(cleanup) {
if (closed)
run(cleanup);
else
cleanups.add(cleanup);
return () => cleanups.delete(cleanup);
},
wait(value) {
return Promise.race([value, cancelled]);
},
dispose() {
if (closed)
return;
closed = true;
cancel();
const pending = [...cleanups].reverse();
cleanups.clear();
for (const cleanup of pending)
run(cleanup);
}
};
if (!closed) {
lifetime.own(() => art.off("destroy", lifetime.dispose));
try {
art.on("destroy", lifetime.dispose);
} catch (error) {
lifetime.dispose();
throw error;
}
}
return lifetime;
}
var defaultCueSettings = {
direction: "horizontal",
snapToLines: true,
@@ -799,85 +849,166 @@ var WebVTTSerializer = function() {
return result;
};
};
async function loadVtt(option, { getExt, srtToVtt, assToVtt }) {
const response = await fetch(option.url);
const buffer = await response.arrayBuffer();
const decoder = new TextDecoder(option.encoding || "utf-8");
const text = decoder.decode(buffer);
switch (option.type || getExt(option.url)) {
case "srt": {
return srtToVtt(text);
}
case "ass": {
return assToVtt(text);
}
case "vtt": {
return text;
}
default:
return "";
}
}
function mergeTrees(trees) {
function parseTracks(vtts, subtitles) {
const parser = new WebVTTParser();
const result = parser.parse("", "metadata");
for (let i = 0; i < trees.length; i++) {
const tree = trees[i];
if (!tree.updated) {
tree.updated = true;
for (let j = 0; j < tree.cues.length; j++) {
const cue = tree.cues[j];
for (let k = 0; k < cue.tree.children.length; k++) {
const children = cue.tree.children[k];
children.value = `<div class="art-subtitle-${tree.name}">${children.value}</div>`;
return vtts.map((vtt, index) => {
const tree = parser.parse(vtt, "metadata");
tree.url = subtitles[index].url;
tree.name = subtitles[index].name;
return tree;
});
}
function serializeTracks(trees) {
const cues = [];
for (const tree of trees) {
for (const cue of tree.cues) {
cues.push({
...cue,
tree: {
...cue.tree,
children: cue.tree.children.map((child) => ({
...child,
value: `<div class="art-subtitle-${tree.name}">${child.value}</div>`
}))
}
}
});
}
result.cues.push(...tree.cues);
}
return result;
return new WebVTTSerializer().serialize(cues);
}
function createRenderer(art, lifetime, unescape) {
let current = null;
function own(url) {
let freed = false;
let release = () => {
};
const entry = {
free() {
if (freed)
return;
freed = true;
release();
URL.revokeObjectURL(url);
}
};
release = lifetime.own(entry.free);
return entry;
}
return (vtt) => {
if (lifetime.closed)
return;
const url = URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
const entry = own(url);
if (lifetime.closed)
return;
const previous = current;
let option;
let escape;
let configured = false;
current = entry;
try {
option = art.option.subtitle;
escape = option.escape;
if (lifetime.closed || current !== entry)
return;
option.escape = false;
configured = true;
const config = { ...option, url, type: "vtt", onVttLoad: unescape };
if (lifetime.closed || current !== entry)
return;
const pending = art.subtitle.init(config);
Promise.resolve(pending).catch((error) => {
if (current === entry) {
current = null;
entry.free();
}
if (!lifetime.closed)
console.warn("Failed to initialize multiple subtitles:", error);
});
} catch (error) {
if (current === entry) {
current = previous;
if (configured && option.escape === false)
option.escape = escape;
}
entry.free();
throw error;
} finally {
if (previous !== current)
previous?.free();
if (entry !== current)
entry.free();
}
};
}
async function loadVtt(option, { getExt, srtToVtt, assToVtt }, lifetime) {
if (lifetime.closed)
return;
const controller = typeof AbortController === "function" ? new AbortController() : null;
const release = lifetime.own(() => controller?.abort());
try {
const url = option.url;
if (lifetime.closed)
return;
const response = await lifetime.wait(controller ? fetch(url, { signal: controller.signal }) : fetch(url));
if (lifetime.closed)
return;
if (response.ok === false)
throw new Error(`Failed to fetch multiple subtitles: HTTP ${response.status}`);
const buffer = await lifetime.wait(response.arrayBuffer());
if (lifetime.closed)
return;
const text = new TextDecoder(option.encoding || "utf-8").decode(buffer);
switch (option.type || getExt(option.url)) {
case "srt":
return srtToVtt(text);
case "ass":
return assToVtt(text);
case "vtt":
return text;
default:
return "";
}
} catch (error) {
controller?.abort();
throw error;
} finally {
release();
}
}
function artplayerPluginMultipleSubtitles({ subtitles = [] }) {
return async (art) => {
const { unescape, getExt, srtToVtt, assToVtt } = art.constructor.utils;
const parser = new WebVTTParser();
const seri = new WebVTTSerializer();
const vtts = await Promise.all(
subtitles.map((option) => {
return loadVtt(option, { getExt, srtToVtt, assToVtt });
})
);
const trees = vtts.map((vtt, index) => {
const tree = parser.parse(vtt, "metadata");
tree.url = subtitles[index].url;
tree.name = subtitles[index].name;
return tree;
});
let lastUrl = "";
function setTracks(trees2 = []) {
const tree = mergeTrees(trees2);
const vtt = seri.serialize(tree.cues);
URL.revokeObjectURL(lastUrl);
const url = URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
lastUrl = url;
art.option.subtitle.escape = false;
art.subtitle.init({
...art.option.subtitle,
url,
type: "vtt",
onVttLoad: unescape
});
const lifetime = createLifetime(art);
const render = createRenderer(art, lifetime, unescape);
let trees = [];
function setTracks(selected) {
if (!lifetime.closed)
render(serializeTracks(selected));
}
setTracks(trees);
return {
const result = {
name: "multipleSubtitles",
tracks(names = []) {
return setTracks(names.map((name) => trees.find((tree) => tree.name === name)));
if (!lifetime.closed)
setTracks(names.map((name) => trees.find((tree) => tree.name === name)));
},
reset() {
return setTracks(trees);
setTracks(trees);
}
};
try {
if (lifetime.closed)
return result;
const vtts = await Promise.all(subtitles.map((option) => loadVtt(option, { getExt, srtToVtt, assToVtt }, lifetime)));
if (lifetime.closed)
return result;
trees = parseTracks(vtts, subtitles);
setTracks(trees);
return result;
} catch (error) {
lifetime.dispose();
throw error;
}
};
}
export {
@@ -1,95 +1,41 @@
import { WebVTTParser, WebVTTSerializer } from './parser'
async function loadVtt(option, { getExt, srtToVtt, assToVtt }) {
const response = await fetch(option.url)
const buffer = await response.arrayBuffer()
const decoder = new TextDecoder(option.encoding || 'utf-8')
const text = decoder.decode(buffer)
switch (option.type || getExt(option.url)) {
case 'srt': {
return srtToVtt(text)
}
case 'ass': {
return assToVtt(text)
}
case 'vtt': {
return text
}
default:
return ''
}
}
function mergeTrees(trees) {
const parser = new WebVTTParser()
const result = parser.parse('', 'metadata')
for (let i = 0; i < trees.length; i++) {
const tree = trees[i]
if (!tree.updated) {
tree.updated = true
for (let j = 0; j < tree.cues.length; j++) {
const cue = tree.cues[j]
for (let k = 0; k < cue.tree.children.length; k++) {
const children = cue.tree.children[k]
children.value = `<div class="art-subtitle-${tree.name}">${children.value}</div>`
}
}
}
result.cues.push(...tree.cues)
}
return result
}
import createLifetime from './lifetime'
import { parseTracks, serializeTracks } from './merge'
import createRenderer from './render'
import loadVtt from './request'
export default function artplayerPluginMultipleSubtitles({ subtitles = [] }) {
return async (art) => {
const { unescape, getExt, srtToVtt, assToVtt } = art.constructor.utils
const parser = new WebVTTParser()
const seri = new WebVTTSerializer()
const vtts = await Promise.all(
subtitles.map((option) => {
return loadVtt(option, { getExt, srtToVtt, assToVtt })
}),
)
const trees = vtts.map((vtt, index) => {
const tree = parser.parse(vtt, 'metadata')
tree.url = subtitles[index].url
tree.name = subtitles[index].name
return tree
})
let lastUrl = ''
function setTracks(trees = []) {
const tree = mergeTrees(trees)
const vtt = seri.serialize(tree.cues)
URL.revokeObjectURL(lastUrl)
const url = URL.createObjectURL(new Blob([vtt], { type: 'text/vtt' }))
lastUrl = url
art.option.subtitle.escape = false
art.subtitle.init({
...art.option.subtitle,
url,
type: 'vtt',
onVttLoad: unescape,
})
const lifetime = createLifetime(art)
const render = createRenderer(art, lifetime, unescape)
let trees = []
function setTracks(selected) {
if (!lifetime.closed)
render(serializeTracks(selected))
}
setTracks(trees)
return {
const result = {
name: 'multipleSubtitles',
tracks(names = []) {
return setTracks(names.map(name => trees.find(tree => tree.name === name)))
if (!lifetime.closed)
setTracks(names.map(name => trees.find(tree => tree.name === name)))
},
reset() {
return setTracks(trees)
setTracks(trees)
},
}
try {
if (lifetime.closed)
return result
const vtts = await Promise.all(subtitles.map(option => loadVtt(option, { getExt, srtToVtt, assToVtt }, lifetime)))
if (lifetime.closed)
return result
trees = parseTracks(vtts, subtitles)
setTracks(trees)
return result
}
catch (error) {
lifetime.dispose()
throw error
}
}
}
@@ -0,0 +1,48 @@
export default function createLifetime(art) {
let closed = Boolean(art.isDestroy)
const cleanups = new Set()
let cancel
const cancelled = new Promise((resolve) => {
cancel = resolve
})
function run(cleanup) {
try {
cleanup()
}
catch (error) {
console.warn('Failed to clean up multiple subtitles:', error)
}
}
const lifetime = {
get closed() { return closed },
own(cleanup) {
if (closed)
run(cleanup)
else
cleanups.add(cleanup)
return () => cleanups.delete(cleanup)
},
wait(value) { return Promise.race([value, cancelled]) },
dispose() {
if (closed)
return
closed = true
cancel()
const pending = [...cleanups].reverse()
cleanups.clear()
for (const cleanup of pending)
run(cleanup)
},
}
if (!closed) {
lifetime.own(() => art.off('destroy', lifetime.dispose))
try {
art.on('destroy', lifetime.dispose)
}
catch (error) {
lifetime.dispose()
throw error
}
}
return lifetime
}
@@ -0,0 +1,30 @@
import { WebVTTParser, WebVTTSerializer } from './parser'
export function parseTracks(vtts, subtitles) {
const parser = new WebVTTParser()
return vtts.map((vtt, index) => {
const tree = parser.parse(vtt, 'metadata')
tree.url = subtitles[index].url
tree.name = subtitles[index].name
return tree
})
}
export function serializeTracks(trees) {
const cues = []
for (const tree of trees) {
for (const cue of tree.cues) {
cues.push({
...cue,
tree: {
...cue.tree,
children: cue.tree.children.map(child => ({
...child,
value: `<div class="art-subtitle-${tree.name}">${child.value}</div>`,
})),
},
})
}
}
return new WebVTTSerializer().serialize(cues)
}
@@ -0,0 +1,68 @@
export default function createRenderer(art, lifetime, unescape) {
let current = null
function own(url) {
let freed = false
let release = () => {}
const entry = {
free() {
if (freed)
return
freed = true
release()
URL.revokeObjectURL(url)
},
}
release = lifetime.own(entry.free)
return entry
}
return (vtt) => {
if (lifetime.closed)
return
// Allocate before replacing the active URL so allocation failure preserves it.
const url = URL.createObjectURL(new Blob([vtt], { type: 'text/vtt' }))
const entry = own(url)
if (lifetime.closed)
return
const previous = current
let option
let escape
let configured = false
current = entry
try {
option = art.option.subtitle
escape = option.escape
if (lifetime.closed || current !== entry)
return
option.escape = false
configured = true
const config = { ...option, url, type: 'vtt', onVttLoad: unescape }
if (lifetime.closed || current !== entry)
return
const pending = art.subtitle.init(config)
// Selection stays void and registration does not await the host's media loading.
Promise.resolve(pending).catch((error) => {
if (current === entry) {
current = null
entry.free()
}
if (!lifetime.closed)
console.warn('Failed to initialize multiple subtitles:', error)
})
}
catch (error) {
if (current === entry) {
current = previous
if (configured && option.escape === false)
option.escape = escape
}
entry.free()
throw error
}
finally {
if (previous !== current)
previous?.free()
if (entry !== current)
entry.free()
}
}
}
@@ -0,0 +1,33 @@
export default async function loadVtt(option, { getExt, srtToVtt, assToVtt }, lifetime) {
if (lifetime.closed)
return
const controller = typeof AbortController === 'function' ? new AbortController() : null
const release = lifetime.own(() => controller?.abort())
try {
const url = option.url
if (lifetime.closed)
return
const response = await lifetime.wait(controller ? fetch(url, { signal: controller.signal }) : fetch(url))
if (lifetime.closed)
return
if (response.ok === false)
throw new Error(`Failed to fetch multiple subtitles: HTTP ${response.status}`)
const buffer = await lifetime.wait(response.arrayBuffer())
if (lifetime.closed)
return
const text = new TextDecoder(option.encoding || 'utf-8').decode(buffer)
switch (option.type || getExt(option.url)) {
case 'srt': return srtToVtt(text)
case 'ass': return assToVtt(text)
case 'vtt': return text
default: return ''
}
}
catch (error) {
controller?.abort()
throw error
}
finally {
release()
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,73 @@
# PKG-MULTI-SUB-03 模块边界与资源修复
本次修改生产源码,保持工厂名称/必需参数、async 注册结果、name/tracks/reset、同步 void
选择方法和原分发入口。自有 JS 先按职责拆清,严格 TS 与公开声明仍由 04 完成。
原始 parser、许可和公开类型未改,没有新增依赖或改变包版本。
## 结构变化
| 文件 | 实际职责 |
| --- | --- |
| index.js | 注册编排、名称选择、活跃状态检查和公开返回对象 |
| request.js | 每轨请求、原生状态检查、字节解码与既有核心格式转换 |
| merge.js | 解析元数据和按选择顺序序列化;通过复制 cue 包装文本,原树不变 |
| lifetime.js | 本次注册的 destroy 监听、请求取消、Promise 提前结束与清理登记 |
| render.js | Blob 所有权、新旧 URL 替换、重入保护及宿主 init 失败处理 |
| parser.js | 保持原始 vendor 边界,不因本次拆分改写算法 |
模块依赖由入口向请求/纯解析/安装流动;仅入口和宿主安装/生命周期需要 art,纯 merge
不依赖 DOM、fetch 或播放器。没有引入要求用户升级核心的共享运行时依赖。
## 独立缺陷修正
- 销毁中 fetch/body 通过取消信号和关闭 Promise 结束;无 AbortController 的旧环境仍
能及时结束注册并忽略迟到结果。返回相同形状的失活结果,不新增 destroy 拒绝。
- 单轨失败保留原传输/解码异常身份,关闭整个注册并取消兄弟请求;HTTP ok=false 明确
拒绝且取消未消费的响应,不再把错误页面当作有效字幕。
- 最后 Blob URL 在 destroy 时释放。保留的 tracks/reset 此后返回 undefined,不再
读取名称、分配 URL 或触发宿主。重复 destroy 不重复释放。
- 分配新 URL 成功后才尝试替换;分配失败保留旧 URL。同步 init 失败释放新 URL,
保留先前资源并回滚该次 escape 写入;首次注册失败同时移除监听。
- 观察宿主真实 Promise 的 rejection,释放失败安装对应的 URL,并在活跃时以 console.warn
保留诊断。正常注册仍不等待宿主加载;tracks/reset 仍为 void。宿主核心可能自行显示
notice,这里不引入新公开事件,也不假装异步错误会从旧 void 方法同步抛出。
- 老 init 的迟到错误不释放后来成功替换的资源;同步重入选择、配置读取失败,以及
用户 getter/init 中触发 destroy 都有明确归属检查。
异步 init 失败发生时,宿主可能已经更新自身状态;插件不能通用回滚第三方宿主的副作用。
此时释放失败资源,保留旧异步接口时序并记录错误;调用方可在仍活跃时重新 reset。
正常 destroy 不恢复共享 subtitle.escape,避免覆盖后续插件/用户的配置。
## 保留的兼容边界
1. 采用已发布 1.2.0 的逐轨连接语义;没有恢复 1.0.0 按下标丢 cue 的缺陷。
2. 轨内 parser 排序、轨间选择顺序、同名取第一个、重复选择、未知名 TypeError、空轨、
onParser 未执行,以及坏 header 仍可保留可用 cue 的容错均保留。
3. option.type 优先、当前核心 SRT/ASS 转换、encoding 默认与原生解码行为保持。
轨元数据在下载结束后读取;restart 不重新下载字幕,配置在每次安装时读取。
4. 不再调用无实际资源的 revokeObjectURL('')。历史测试仍断言旧调用记录,候选正常
测试仅对这一项无资源清理差异作明确区分,不放宽真实 URL 的释放断言。
5. 合并不再原地标记 updated/改 cue;冻结树重复序列化测试和九种真实旧产物逐字对照
证明输出保持,另对实际核心转换和 Windows-1252/UTF-16LE 字节逐字比较。
## 验证与限制
命令及准确运行结果、输入指纹、原生浏览器观察见
[验证记录](../baselines/multiple-subtitles-resources.json)。新生命周期/merge 文件接入
test:unit 与 test:multiple-subtitles,生产三入口由正常 yarn build 生成。
浏览器矩阵使用发布/候选核心、Chromium/Firefox/Windows WebKit 和源码/现代/legacy
插件,覆盖 SRT 显示/选择、挂起请求销毁、原生 Blob 不可访问、HTTP 失败及真实视频切源。
其中资源/错误场景直接执行公开注册函数,显示与切源场景使用真实 plugins.add。
最终本包 241 项通过;源码/main/legacy 各 31 项通过(其中冻结树纯函数用例固定读取源码,
其余正常/生命周期/转换对照通过选定入口)。三入口原生浏览器各 30 项,共 90 项通过。
完整 CI 2311 项通过:1963 单元、14 工程、334 基线;严格生产 TS 仍 346 文件。
状态更新后 9 项风险/契约元数据测试通过。显式 lint 无错误或警告;全量 lint 仍有既有
生成声明 unused-disable 警告。实际 ESM 只验证导出形状和工厂,不冒充完整安装消费者。
这不是全部历史核心、实体 Safari/移动设备、ASS 原生显示、样式/编辑器或 npm 发布准入。
HOST/HTTP 在本次边界回归后关闭;LIFE/MERGE 的完整支持范围组合仍由 05 补齐。
每项状态以 tasks/risks 与验证记录为准;后续 04 迁移 TS 时重跑相同生产入口用例。
独立本地任务提交,不推送、不发布。回退本提交恢复旧单文件和生成产物,同时重新引入
02 已复现的泄漏/失败行为;02 历史基线及浏览器证据保留。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
状态:todo 90 / doing 13 / blocked 0 / done 119 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 89 / doing 13 / blocked 0 / done 120 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -218,7 +218,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| PKG-MULTI-SUB-01 | artplayer-plugin-multiple-subtitles<br>核对包契约与历史用法 | BASE-05 | subtitles/onParser、multipleSubtitles 名称及实际返回方法 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结;接续 VENDOR-03,定位 WebVTT parser 来源/修订及 CC0 头,保留解析/序列化行为 | H | done |
| PKG-MULTI-SUB-02 | artplayer-plugin-multiple-subtitles<br>建立特有行为与错误测试 | PKG-MULTI-SUB-01, ENG-03, ENG-05 | VTT/SRT/ASS、编码/重叠 cue/排序/空轨/失败与切换 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-MULTI-SUB-03 | artplayer-plugin-multiple-subtitles<br>整理内部职责与资源 | PKG-MULTI-SUB-02, CORE-15 | 解析与合并/获取/渲染分离,过期请求及对象 URL 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
| PKG-MULTI-SUB-03 | artplayer-plugin-multiple-subtitles<br>整理内部职责与资源 | PKG-MULTI-SUB-02, CORE-15 | 解析与合并/获取/渲染分离,过期请求及对象 URL 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-MULTI-SUB-04 | artplayer-plugin-multiple-subtitles<br>迁移自有源码和公开类型 | PKG-MULTI-SUB-03, ENG-04, ENG-06, CORE-07 | 准确 parser/cue/tree/result 类型与真实异步形状 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-MULTI-SUB-05 | artplayer-plugin-multiple-subtitles<br>验证新旧核心和组合 | PKG-MULTI-SUB-04, CORE-22 | 与核心字幕/偏移/全屏组合,旧名称调用不变 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-MULTI-SUB-06 | artplayer-plugin-multiple-subtitles<br>验证分发并同步文档 | PKG-MULTI-SUB-05, ENG-07 | multiple.subtitles.js、parser 来源许可及打包边界 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
@@ -513,6 +513,7 @@
- PKG-DASH-05: [记录](changes/2026-09-12-PKG-DASH-05-sdk-checkpoint.md) [记录](baselines/dash-sdk.json) [记录](baselines/dash-sdk-checkpoint.json) [记录](dash-validation.md) [记录](changes/2026-09-12-PKG-DASH-05-sdk-types.md) [记录](baselines/dash-sdk-types-validation.json) [记录](changes/2026-09-12-PKG-DASH-05-sdk-events.md) [记录](baselines/dash-sdk-events-validation.json) [记录](changes/2026-09-12-PKG-DASH-05-seek-diagnosis.md) [记录](baselines/dash-seek-diagnosis.json)
- PKG-MULTI-SUB-01: [记录](baselines/multiple-subtitles-release.json) [记录](baselines/multiple-subtitles-contract.md) [记录](baselines/multiple-subtitles-contract-validation.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-01-contract.md)
- PKG-MULTI-SUB-02: [记录](baselines/multiple-subtitles-failures.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-02-failures.md)
- PKG-MULTI-SUB-03: [记录](baselines/multiple-subtitles-resources.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-03-resources.md)
- PKG-ADS-01: [记录](baselines/ads-release.json) [记录](baselines/ads-contract.md) [记录](baselines/ads-contract-validation.json) [记录](changes/2026-09-12-PKG-ADS-01-contract.md) [记录](scripts/ads-contract.test.mjs)
- PKG-ADS-02: [记录](changes/2026-09-12-PKG-ADS-02-tests.md) [记录](ads-validation.md) [记录](baselines/ads-validation.json)
- PKG-ADS-03: [记录](changes/2026-09-12-PKG-ADS-03-lifecycle.md) [记录](ads-validation.md) [记录](baselines/ads-lifecycle-validation.json)
+17
View File
@@ -1,5 +1,22 @@
# 进度与证据
## PKG-MULTI-SUB-03 模块与资源修复完成
生产源码拆为入口、请求、纯解析合并、生命周期和宿主安装五个模块,vendor parser 不变。
修复 HTTP 失败、兄弟请求取消、fetch/body 销毁中结算、Blob 泄漏、分配失败替换顺序、
同步/异步安装失败与重入归属;保留 async 注册与 void tracks/reset、轨序、元数据和容错。
九种 markup/timing/空轨输入及真实转换/编码逐字对照已发布 1.2.0,冻结树重复序列化通过。
源码/main/legacy 各 31 项通过;本包 241 项通过,两新增文件已接入主 test:unit。
三入口配发布/候选核心,在 Chromium/Firefox/Windows WebKit 共 90 项通过:真实 SRT、
选择/清空/reset、请求销毁、Blob 不可访问、HTTP 失败和真实视频切源无重复字幕下载。
完整 CI 2311 项通过(1963 单元、14 工程、334 基线);严格生产 TS 仍 346,04 继续 TS。
正常生产构建及实际 ESM 导入通过,三个 dist 与 docs/compiled 对应字节一致。
HTTP/HOST 风险关闭;LIFE/MERGE 完整支持范围组合仍待 05。见
[变更](changes/2026-09-13-PKG-MULTI-SUB-03-resources.md)和[验证](baselines/multiple-subtitles-resources.json)。
222 项:120 done、13 doing、89 todo;196 项风险。独立本地提交,不推送、不发布。
下一步 MULTI-SUB-04 自有模块严格 TS 与旧消费者公开类型;VTT 类型取舍继续待决。
## PKG-MULTI-SUB-02 错误与生命周期复现完成
9 份历史实现各 18 类异常/边界,共 162 项;本包命令及主 test:unit 已接入,
+2 -2
View File
@@ -198,5 +198,5 @@
| MULTI-SUB-MERGE-01 | open / 已复现 | 1.0.0 merges cues by index while later versions concatenate independently timed track cues | PKG-MULTI-SUB-02, PKG-MULTI-SUB-03, PKG-MULTI-SUB-05 |
| MULTI-SUB-LIFE-01 | open / 已复现 | Pending downloads and final object URL have no destroy owner | PKG-MULTI-SUB-02, PKG-MULTI-SUB-03, PKG-MULTI-SUB-05 |
| MULTI-SUB-EXPORT-01 | open / 已复现 | Older CommonJS default objects conflict with export-assignment declarations; latest exports a direct factory | PKG-MULTI-SUB-04, PKG-MULTI-SUB-06 |
| MULTI-SUB-HTTP-01 | open / 已复现 | HTTP error responses with parseable bodies are installed as subtitles | PKG-MULTI-SUB-03, PKG-MULTI-SUB-05 |
| MULTI-SUB-HOST-01 | open / 已复现 | Subtitle init rejections are ignored and failed installation or URL replacement leaks or invalidates resources | PKG-MULTI-SUB-03, PKG-MULTI-SUB-05 |
| MULTI-SUB-HTTP-01 | resolved / 已复现 | HTTP error responses with parseable bodies are installed as subtitles | PKG-MULTI-SUB-03, PKG-MULTI-SUB-05 |
| MULTI-SUB-HOST-01 | resolved / 已复现 | Subtitle init rejections are ignored and failed installation or URL replacement leaks or invalidates resources | PKG-MULTI-SUB-03, PKG-MULTI-SUB-05 |
+48 -10
View File
@@ -4279,10 +4279,16 @@
"test/multiple-subtitles-failures.test.js",
"test/browser/multiple-subtitles-history.spec.js",
"refactor/baselines/multiple-subtitles-failures.json",
"refactor/changes/2026-09-13-PKG-MULTI-SUB-02-failures.md"
"refactor/changes/2026-09-13-PKG-MULTI-SUB-02-failures.md",
"refactor/baselines/multiple-subtitles-resources.json",
"refactor/changes/2026-09-13-PKG-MULTI-SUB-03-resources.md",
"test/multiple-subtitles-lifecycle.test.js",
"test/multiple-subtitles-merge.test.js",
"test/browser/multiple-subtitles-lifecycle.spec.js"
],
"compatibleResolution": "Keep historical merge profiles explicit and test real overlap/timing before choosing compatibility behavior.",
"closureCriteria": "Unaligned lengths, overlap, sorting, names and real subtitle display across supported cores are verified."
"closureCriteria": "Unaligned lengths, overlap, sorting, names and real subtitle display across supported cores are verified.",
"workspaceState": "Candidate resource fixes and serialization parity are verified in task 03. This risk remains open for the complete supported-core/device coverage owned by task 05."
},
{
"id": "MULTI-SUB-LIFE-01",
@@ -4302,10 +4308,16 @@
"test/multiple-subtitles-failures.test.js",
"test/browser/multiple-subtitles-history.spec.js",
"refactor/baselines/multiple-subtitles-failures.json",
"refactor/changes/2026-09-13-PKG-MULTI-SUB-02-failures.md"
"refactor/changes/2026-09-13-PKG-MULTI-SUB-02-failures.md",
"refactor/baselines/multiple-subtitles-resources.json",
"refactor/changes/2026-09-13-PKG-MULTI-SUB-03-resources.md",
"test/multiple-subtitles-lifecycle.test.js",
"test/multiple-subtitles-merge.test.js",
"test/browser/multiple-subtitles-lifecycle.spec.js"
],
"compatibleResolution": "Reproduce stale completion and own requests and URLs without changing public selection methods.",
"closureCriteria": "Cancellation, failures, source changes, repeated selection and destroy are verified with native core coverage."
"closureCriteria": "Cancellation, failures, source changes, repeated selection and destroy are verified with native core coverage.",
"workspaceState": "Candidate resource fixes and serialization parity are verified in task 03. This risk remains open for the complete supported-core/device coverage owned by task 05."
},
{
"id": "MULTI-SUB-EXPORT-01",
@@ -4331,7 +4343,7 @@
"compatibleResolution": "Reject actual HTTP failures with explicit diagnostics while preserving successful conversion and original transport errors.",
"closureCriteria": "Native HTTP errors, transport/body failures, sibling cancellation and successful requests have candidate regressions.",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-MULTI-SUB-03",
"PKG-MULTI-SUB-05"
@@ -4340,8 +4352,21 @@
"test/multiple-subtitles-failures.test.js",
"test/browser/multiple-subtitles-history.spec.js",
"refactor/baselines/multiple-subtitles-failures.json",
"refactor/changes/2026-09-13-PKG-MULTI-SUB-02-failures.md"
]
"refactor/changes/2026-09-13-PKG-MULTI-SUB-02-failures.md",
"refactor/baselines/multiple-subtitles-resources.json",
"refactor/changes/2026-09-13-PKG-MULTI-SUB-03-resources.md",
"test/multiple-subtitles-lifecycle.test.js",
"test/multiple-subtitles-merge.test.js",
"test/browser/multiple-subtitles-lifecycle.spec.js"
],
"resolutionEvidence": [
"refactor/baselines/multiple-subtitles-resources.json",
"refactor/changes/2026-09-13-PKG-MULTI-SUB-03-resources.md",
"test/multiple-subtitles-lifecycle.test.js",
"test/multiple-subtitles-merge.test.js",
"test/browser/multiple-subtitles-lifecycle.spec.js"
],
"resolutionRationale": "HTTP failures reject before body consumption and abort their requests. Exact transport/body errors, sibling cancellation, conversion success and native 404 rejection pass candidate regressions; source/main/legacy run with both core fixtures."
},
{
"id": "MULTI-SUB-HOST-01",
@@ -4349,7 +4374,7 @@
"compatibleResolution": "Own installation results and URL rollback while preserving void selection methods and normal registration timing.",
"closureCriteria": "Synchronous and asynchronous host failures, stale rejection, allocation failure and destroy leave no plugin-owned live resources.",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-MULTI-SUB-03",
"PKG-MULTI-SUB-05"
@@ -4358,8 +4383,21 @@
"test/multiple-subtitles-failures.test.js",
"test/browser/multiple-subtitles-history.spec.js",
"refactor/baselines/multiple-subtitles-failures.json",
"refactor/changes/2026-09-13-PKG-MULTI-SUB-02-failures.md"
]
"refactor/changes/2026-09-13-PKG-MULTI-SUB-02-failures.md",
"refactor/baselines/multiple-subtitles-resources.json",
"refactor/changes/2026-09-13-PKG-MULTI-SUB-03-resources.md",
"test/multiple-subtitles-lifecycle.test.js",
"test/multiple-subtitles-merge.test.js",
"test/browser/multiple-subtitles-lifecycle.spec.js"
],
"resolutionEvidence": [
"refactor/baselines/multiple-subtitles-resources.json",
"refactor/changes/2026-09-13-PKG-MULTI-SUB-03-resources.md",
"test/multiple-subtitles-lifecycle.test.js",
"test/multiple-subtitles-merge.test.js",
"test/browser/multiple-subtitles-lifecycle.spec.js"
],
"resolutionRationale": "Owned installations retain prior URLs on synchronous failure, observe async rejections, release the failed active URL without affecting newer installs, and clean up on destroy. Controlled sync/async/reentrant/getter failures and native Blob revocation pass; void methods and registration timing are preserved."
}
]
}
+5 -2
View File
@@ -2297,11 +2297,14 @@
"PKG-MULTI-SUB-02",
"CORE-15"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "解析与合并/获取/渲染分离,过期请求及对象 URL 清理",
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
"evidence": []
"evidence": [
"baselines/multiple-subtitles-resources.json",
"changes/2026-09-13-PKG-MULTI-SUB-03-resources.md"
]
},
{
"id": "PKG-MULTI-SUB-04",
@@ -0,0 +1,169 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { multipleSubtitlesCandidate } from '../helpers/multiple-subtitles.js'
import { expect, test } from './fixtures.js'
const implementation = await multipleSubtitlesCandidate()
const vtt = 'WEBVTT\n\n00:00.000 --> 00:08.000\nCached caption\n'
const srt = text => `1\n00:00:00,000 --> 00:00:08,000\n${text}\n`
async function prepare(page, core) {
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: implementation.code })
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.multipleProbe = { initialized: [], revoked: [], signal: null, errors: [], requests: [] }
const fetch = window.fetch.bind(window)
window.multipleNativeFetch = fetch
window.fetch = (url, options) => {
if (typeof url === 'string' && url.startsWith('/test/multiple-'))
window.multipleProbe.requests.push(url)
if (url === '/test/multiple-held.vtt')
window.multipleProbe.signal = options?.signal
return fetch(url, options)
}
const revoke = URL.revokeObjectURL.bind(URL)
URL.revokeObjectURL = (url) => {
window.multipleProbe.revoked.push(url)
revoke(url)
}
const init = window.art.subtitle.init.bind(window.art.subtitle)
window.art.subtitle.init = (option) => {
window.multipleProbe.initialized.push({ url: option.url, destroyed: window.art.isDestroy })
const result = init(option)
Promise.resolve(result).catch(error => window.multipleProbe.errors.push({ name: error.name, message: error.message }))
return result
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
async function evidence(page, testInfo, core, observation) {
const probe = await page.evaluate(() => ({ ...window.multipleProbe, signal: window.multipleProbe.signal ? { aborted: window.multipleProbe.signal.aborted } : null }))
expect(probe.errors).toEqual([])
await testInfo.attach('multiple-subtitles-lifecycle', { body: JSON.stringify({ core, implementation: { name: implementation.name, sha256: hash(implementation.code) }, observation, probe }), contentType: 'application/json' })
}
for (const core of ['published', 'candidate']) {
test(`Multiple subtitles candidate ${core} core: SRT display and selectors survive the resource refactor`, async ({ page }, testInfo) => {
await page.route('**/test/multiple-en.srt', route => route.fulfill({ body: srt('English'), contentType: 'text/plain' }))
await page.route('**/test/multiple-jp.srt', route => route.fulfill({ body: srt('日本語'), contentType: 'text/plain' }))
await prepare(page, core)
await page.evaluate(async () => {
await window.art.plugins.add(window.artplayerPluginMultipleSubtitles({ subtitles: [{ name: 'en', url: '/test/multiple-en.srt' }, { name: 'jp', url: '/test/multiple-jp.srt' }] }))
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(2)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await expect(page.locator('.art-subtitle-en')).toHaveText('English')
await expect(page.locator('.art-subtitle-jp')).toHaveText('日本語')
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks(['jp']))
await expect(page.locator('.art-subtitle-en')).toHaveCount(0)
await expect(page.locator('.art-subtitle-jp')).toHaveText('日本語')
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks([]))
await expect(page.locator('.art-subtitle-jp')).toHaveCount(0)
await page.evaluate(() => window.art.plugins.multipleSubtitles.reset())
await expect(page.locator('.art-subtitle-en')).toHaveText('English')
await expect(page.locator('.art-subtitle-jp')).toHaveText('日本語')
const released = await page.evaluate(() => {
window.art.destroy(false)
return window.multipleProbe.initialized.every(item => window.multipleProbe.revoked.includes(item.url))
})
expect(released).toBe(true)
await evidence(page, testInfo, core, { nativeSrtDisplay: true, released })
})
test(`Multiple subtitles candidate ${core} core: destroy settles a pending native request before it responds`, async ({ page }, testInfo) => {
let requested
let release
const started = new Promise((resolve) => {
requested = resolve
})
const held = new Promise((resolve) => {
release = resolve
})
await page.route('**/test/multiple-held.vtt', async (route) => {
requested()
await held
await route.fulfill({ body: vtt, contentType: 'text/vtt' })
})
try {
await prepare(page, core)
await page.evaluate(() => {
window.multiplePending = window.artplayerPluginMultipleSubtitles({ subtitles: [{ url: '/test/multiple-held.vtt', name: 'held' }] })(window.art)
})
await started
const state = await page.evaluate(async () => {
window.art.destroy(false)
const result = await window.multiplePending
return { name: result.name, voidReset: result.reset() === undefined, initialized: window.multipleProbe.initialized.length, aborted: window.multipleProbe.signal.aborted }
})
expect(state).toEqual({ name: 'multipleSubtitles', voidReset: true, initialized: 0, aborted: true })
release()
await evidence(page, testInfo, core, state)
}
finally { release() }
})
test(`Multiple subtitles candidate ${core} core: destroy revokes the Blob and retained reset stays inert`, async ({ page }, testInfo) => {
await page.route('**/test/multiple-ready.vtt', route => route.fulfill({ body: vtt, contentType: 'text/vtt' }))
await prepare(page, core)
await page.evaluate(async () => {
window.multipleResult = await window.artplayerPluginMultipleSubtitles({ subtitles: [{ url: '/test/multiple-ready.vtt', name: 'ready' }] })(window.art)
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
const state = await page.evaluate(async () => {
const url = window.multipleProbe.initialized[0].url
window.art.destroy(false)
const voidReset = window.multipleResult.reset() === undefined
let inaccessible = false
try {
await window.multipleNativeFetch(url)
}
catch (error) {
inaccessible = error.name === 'TypeError'
}
return { voidReset, inaccessible, initialized: window.multipleProbe.initialized.length, revoked: window.multipleProbe.revoked.includes(url) }
})
expect(state).toEqual({ voidReset: true, inaccessible: true, initialized: 1, revoked: true })
await evidence(page, testInfo, core, state)
})
test(`Multiple subtitles candidate ${core} core: native HTTP error rejects without installing subtitles`, async ({ page }, testInfo) => {
await page.route('**/test/multiple-error.vtt', route => route.fulfill({ status: 404, body: vtt, contentType: 'text/vtt' }))
await prepare(page, core)
const state = await page.evaluate(async () => {
let message
try {
await window.artplayerPluginMultipleSubtitles({ subtitles: [{ url: '/test/multiple-error.vtt', name: 'error' }] })(window.art)
}
catch (error) {
message = error.message
}
window.art.destroy(false)
return { message, initialized: window.multipleProbe.initialized.length }
})
expect(state).toEqual({ message: 'Failed to fetch multiple subtitles: HTTP 404', initialized: 0 })
await evidence(page, testInfo, core, state)
})
test(`Multiple subtitles candidate ${core} core: video source switching retains cached subtitle selection`, async ({ page }, testInfo) => {
await page.route('**/test/multiple-restart.vtt', route => route.fulfill({ body: vtt, contentType: 'text/vtt' }))
await prepare(page, core)
await page.evaluate(async () => {
await window.art.plugins.add(window.artplayerPluginMultipleSubtitles({ subtitles: [{ url: '/test/multiple-restart.vtt', name: 'restart' }] }))
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
await page.evaluate(() => window.art.switchUrl('/test/pattern.mp4?multiple-restart=1'))
await page.evaluate(() => window.art.plugins.multipleSubtitles.reset())
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await expect(page.locator('.art-subtitle-restart')).toHaveText('Cached caption')
const requests = await page.evaluate(() => window.multipleProbe.requests)
expect(requests).toEqual(['/test/multiple-restart.vtt'])
await page.evaluate(() => window.art.destroy(false))
await evidence(page, testInfo, core, { requests, nativeSourceSwitch: true })
})
}
+5 -3
View File
@@ -28,7 +28,7 @@ export async function multipleSubtitlesHistorical() {
return implementations
}
export function multipleSubtitlesEnvironment(implementation, { script = false, responses = {}, fetchResponse, coreUtils, onInit, onCreate } = {}) {
export function multipleSubtitlesEnvironment(implementation, { script = false, responses = {}, fetchResponse, coreUtils, onInit, onCreate, abortController = true } = {}) {
const requests = []
const requestOptions = []
const blobs = new Map()
@@ -38,6 +38,7 @@ export function multipleSubtitlesEnvironment(implementation, { script = false, r
const initStates = []
const converted = []
const listeners = new Map()
const warnings = []
const module = { exports: {} }
const utils = {
getExt: url => url.split('.').pop(),
@@ -80,7 +81,8 @@ export function multipleSubtitlesEnvironment(implementation, { script = false, r
const context = {
Blob,
TextDecoder,
console: { log() {}, warn() {}, error() {} },
AbortController: abortController ? AbortController : undefined,
console: { log() {}, warn(...args) { warnings.push(args) }, error() {} },
URL: {
createObjectURL(blob) {
onCreate?.(blob)
@@ -115,5 +117,5 @@ export function multipleSubtitlesEnvironment(implementation, { script = false, r
for (const callback of [...(listeners.get(name) || [])])
callback(...args)
}
return { factory, exported, art, utils, requests, requestOptions, blobs, liveBlobs, revoked, initialized, initStates, converted, listeners, emit, responseFor, latestText }
return { factory, exported, art, utils, requests, requestOptions, blobs, liveBlobs, revoked, initialized, initStates, converted, listeners, warnings, emit, responseFor, latestText }
}
+319
View File
@@ -0,0 +1,319 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Lifecycle regressions use the repository Node runner.
import test from 'node:test'
import { multipleSubtitlesCandidate, multipleSubtitlesEnvironment, subtitleVtt } from './helpers/multiple-subtitles.js'
const candidate = await multipleSubtitlesCandidate()
const create = options => multipleSubtitlesEnvironment(candidate, options)
const options = { subtitles: [{ url: 'a.vtt', name: 'a' }] }
function deferred() {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
async function flush() {
for (let index = 0; index < 20; index++)
await Promise.resolve()
}
function empty(env) {
assert.equal(env.liveBlobs.size, 0)
assert.equal(env.listeners.get('destroy')?.size || 0, 0)
}
for (const abortController of [true, false]) {
for (const phase of ['fetch', 'body']) {
test(`Multiple subtitles candidate: destroy cancels ${phase}, AbortController=${abortController}`, async () => {
const held = deferred()
const started = deferred()
const env = create({ abortController, fetchResponse: (_url, _options, responseFor) => {
if (phase === 'fetch') {
started.resolve()
return held.promise
}
return { ...responseFor('a.vtt'), arrayBuffer() {
started.resolve()
return held.promise
} }
} })
const pending = env.factory(options)(env.art)
await started.promise
env.emit('destroy')
const result = await pending
assert.equal(result.name, 'multipleSubtitles')
assert.equal(result.reset(), undefined)
assert.equal(result.tracks(['missing']), undefined)
assert.equal(env.requestOptions[0]?.signal.aborted, abortController ? true : undefined)
assert.equal(env.initialized.length, 0)
empty(env)
held.reject(new Error('late rejected operation'))
await flush()
assert.equal(env.initialized.length, 0)
assert.equal(env.warnings.length, 0)
})
}
}
test('Multiple subtitles candidate: already destroyed hosts do not request or subscribe', async () => {
const env = create()
env.emit('destroy')
const result = await env.factory(options)(env.art)
result.reset()
assert.equal(env.requests.length, 0)
assert.equal(env.initialized.length, 0)
empty(env)
})
test('Multiple subtitles candidate: failed request preserves error identity and cancels a sibling body', async () => {
const failed = deferred()
const body = deferred()
const started = deferred()
const error = new Error('network failed')
const env = create({ fetchResponse: url => url === 'a.vtt'
? failed.promise
: { ok: true, arrayBuffer() {
started.resolve()
return body.promise
} } })
const pending = env.factory({ subtitles: [...options.subtitles, { url: 'b.vtt', name: 'b' }] })(env.art)
await started.promise
failed.reject(error)
await assert.rejects(pending, caught => caught === error)
assert.equal(env.requestOptions[1].signal.aborted, true)
body.resolve(new TextEncoder().encode(subtitleVtt('late')).buffer)
await flush()
assert.equal(env.initialized.length, 0)
empty(env)
})
test('Multiple subtitles candidate: HTTP failure rejects without reading or installing its body', async () => {
let read = false
const env = create({ fetchResponse: () => ({ ok: false, status: 404, arrayBuffer() {
read = true
} }) })
await assert.rejects(env.factory(options)(env.art), { message: 'Failed to fetch multiple subtitles: HTTP 404' })
assert.equal(read, false)
assert.equal(env.requestOptions[0].signal.aborted, true)
assert.equal(env.initialized.length, 0)
empty(env)
})
test('Multiple subtitles candidate: body failure and bad encoding release registration resources', async () => {
const error = new Error('body failed')
const env = create({ fetchResponse: () => ({ ok: true, arrayBuffer: () => Promise.reject(error) }) })
await assert.rejects(env.factory(options)(env.art), caught => caught === error)
empty(env)
const invalid = create()
await assert.rejects(invalid.factory({ subtitles: [{ ...options.subtitles[0], encoding: 'invalid-label' }] })(invalid.art), { name: 'RangeError' })
empty(invalid)
})
test('Multiple subtitles candidate: destroy releases the last URL and makes retained methods inert', async () => {
const env = create()
const result = await env.factory(options)(env.art)
env.emit('destroy')
env.emit('destroy')
assert.equal(result.reset(), undefined)
assert.equal(result.tracks(), undefined)
assert.deepEqual(env.revoked, ['blob:subtitle-1'])
assert.equal(env.initialized.length, 1)
empty(env)
})
test('Multiple subtitles candidate: allocation failure preserves the old URL and reset recovers', async () => {
let calls = 0
const error = new Error('allocation failed')
const env = create({ onCreate() {
if (++calls === 2)
throw error
} })
const result = await env.factory(options)(env.art)
assert.throws(() => result.reset(), caught => caught === error)
assert.equal(env.liveBlobs.has('blob:subtitle-1'), true)
assert.equal(env.revoked.length, 0)
result.reset()
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-2'])
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: synchronous initial host failure rolls back escape and frees the new URL', async () => {
const error = new Error('init failed')
const env = create({ onInit() {
throw error
} })
await assert.rejects(env.factory(options)(env.art), caught => caught === error)
assert.equal(env.art.option.subtitle.escape, true)
empty(env)
})
test('Multiple subtitles candidate: a failed synchronous replacement retains the previous resource', async () => {
let calls = 0
const error = new Error('replacement failed')
const env = create({ onInit() {
if (++calls === 2)
throw error
} })
const result = await env.factory(options)(env.art)
assert.throws(() => result.reset(), caught => caught === error)
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-1'])
result.reset()
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-3'])
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: host media loading does not delay registration or change void selection', async () => {
const held = deferred()
const env = create({ onInit: () => held.promise })
const result = await env.factory(options)(env.art)
assert.equal(result.name, 'multipleSubtitles')
assert.equal(result.reset(), undefined)
assert.equal(env.initialized.length, 2)
held.resolve()
await flush()
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-2'])
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: asynchronous rejection is observed and only frees its own URL', async () => {
const operations = [deferred(), deferred()]
let calls = 0
const env = create({ onInit: () => operations[calls++].promise })
const result = await env.factory(options)(env.art)
result.reset()
operations[0].reject(new Error('stale failure'))
await flush()
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-2'])
operations[1].reject(new Error('current failure'))
await flush()
assert.equal(env.liveBlobs.size, 0)
assert.equal(env.warnings.length, 2)
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: destroy in host init does not install resources again', async () => {
const env = create({ onInit() {
env.emit('destroy')
} })
const result = await env.factory(options)(env.art)
result.reset()
assert.equal(env.initialized.length, 1)
assert.deepEqual(env.revoked, ['blob:subtitle-1'])
empty(env)
})
test('Multiple subtitles candidate: reentrant selection keeps only the newest resource', async () => {
let result
let reenter = false
const env = create({ onInit() {
if (reenter) {
reenter = false
result.tracks([])
}
} })
result = await env.factory(options)(env.art)
reenter = true
result.reset()
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-3'])
assert.equal(await env.latestText(), 'WEBVTT\n\n')
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: unknown names throw before allocation and do not corrupt later resets', async () => {
const env = create()
const result = await env.factory(options)(env.art)
assert.throws(() => result.tracks(['missing']), { name: 'TypeError' })
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-1'])
result.reset()
assert.equal((env.utils.unescape(await env.latestText()).match(/<div/g) || []).length, 1)
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: destroy during allocation releases the returned URL immediately', async () => {
const env = create({ onCreate() {
env.emit('destroy')
} })
await env.factory(options)(env.art)
assert.equal(env.initialized.length, 0)
empty(env)
})
test('Multiple subtitles candidate: throwing live configuration preserves the old resource', async () => {
const env = create()
const result = await env.factory(options)(env.art)
const old = env.art.option.subtitle
const error = new Error('configuration getter failed')
Object.defineProperty(env.art.option, 'subtitle', { configurable: true, get() {
throw error
} })
assert.throws(() => result.reset(), caught => caught === error)
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-1'])
Object.defineProperty(env.art.option, 'subtitle', { value: old })
result.reset()
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: destroy from live configuration stops the following init', async () => {
const env = create()
const result = await env.factory(options)(env.art)
Object.defineProperty(env.art.option.subtitle, 'style', { enumerable: true, get() {
env.emit('destroy')
return {}
} })
result.reset()
assert.equal(env.initialized.length, 1)
empty(env)
})
test('Multiple subtitles candidate: reentrant successful selection survives the outer host error', async () => {
let result
let reenter = false
const error = new Error('outer init failed')
const env = create({ onInit() {
if (reenter) {
reenter = false
result.tracks([])
throw error
}
} })
result = await env.factory(options)(env.art)
reenter = true
assert.throws(() => result.reset(), caught => caught === error)
assert.deepEqual([...env.liveBlobs.keys()], ['blob:subtitle-3'])
env.emit('destroy')
empty(env)
})
test('Multiple subtitles candidate: late host rejection after destroy is handled without another write', async () => {
const host = deferred()
const env = create({ onInit: () => host.promise })
await env.factory(options)(env.art)
env.emit('destroy')
host.reject(new Error('late host failure'))
await flush()
assert.equal(env.warnings.length, 0)
assert.equal(env.initialized.length, 1)
assert.deepEqual(env.revoked, ['blob:subtitle-1'])
empty(env)
})
test('Multiple subtitles candidate: destroy from a URL getter stops the request before dispatch', async () => {
const env = create()
const entry = { name: 'a', get url() {
env.emit('destroy')
return 'a.vtt'
} }
await env.factory({ subtitles: [entry] })(env.art)
assert.equal(env.requests.length, 0)
assert.equal(env.initialized.length, 0)
empty(env)
})
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
// eslint-disable-next-line test/no-import-node-test -- Merge and decoder regressions use the repository Node runner.
import test from 'node:test'
import { loadModules } from './helpers/load.js'
import { multipleSubtitlesCandidate, multipleSubtitlesEnvironment, multipleSubtitlesHistorical, subtitleVtt } from './helpers/multiple-subtitles.js'
const { parseTracks, serializeTracks, ...coreUtils } = await loadModules({
parseTracks: { file: 'packages/artplayer-plugin-multiple-subtitles/src/merge', name: 'parseTracks' },
serializeTracks: { file: 'packages/artplayer-plugin-multiple-subtitles/src/merge', name: 'serializeTracks' },
getExt: { file: 'packages/artplayer/src/utils/file', name: 'getExt' },
srtToVtt: { file: 'packages/artplayer/src/utils/subtitle', name: 'srtToVtt' },
assToVtt: { file: 'packages/artplayer/src/utils/subtitle', name: 'assToVtt' },
})
const candidate = await multipleSubtitlesCandidate()
const historical = (await multipleSubtitlesHistorical()).find(item => item.name === 'published-1.2.0-main')
function freeze(value) {
if (value && typeof value === 'object') {
Object.freeze(value)
for (const child of Object.values(value)) freeze(child)
}
return value
}
test('Multiple subtitles merge: repeated selection serializes frozen trees without accumulating wrappers', () => {
const trees = freeze(parseTracks([subtitleVtt('A'), subtitleVtt('B')], [{ name: 'a' }, { name: 'b' }]))
const original = JSON.stringify(trees)
const first = serializeTracks(trees)
serializeTracks([trees[1], trees[0], trees[1]])
assert.equal(serializeTracks(trees), first)
assert.equal(JSON.stringify(trees), original)
})
test('Multiple subtitles merge: actual released serializer output is preserved for text, markup and timing cases', async () => {
const samples = [
subtitleVtt('A & B < C > D'),
subtitleVtt('<b>bold</b> tail'),
subtitleVtt('<c.red>color</c> <i>italic</i>'),
subtitleVtt('<v Speaker>voice</v>'),
subtitleVtt('<ruby>text<rt>note</rt></ruby>'),
subtitleVtt('line one\nline two'),
'WEBVTT\n\n00:02.000 --> 00:05.000\nLater\n\n00:00.000 --> 00:03.000\nEarlier\n',
subtitleVtt('best effort').replace('WEBVTT', 'BAD HEADER'),
'WEBVTT\n\n',
]
for (const body of samples) {
const environments = [historical, candidate].map(implementation => multipleSubtitlesEnvironment(implementation, { responses: { 'a.vtt': body, 'b.vtt': subtitleVtt('second') } }))
const results = await Promise.all(environments.map(env => env.factory({ subtitles: [{ url: 'a.vtt', name: 'a' }, { url: 'b.vtt', name: 'b' }] })(env.art)))
for (const selection of [null, ['b', 'a', 'b'], [], ['a']]) {
if (selection)
results.forEach(result => result.tracks(selection))
assert.equal(await environments[1].latestText(), await environments[0].latestText(), body)
}
results.forEach(result => result.reset())
assert.equal(await environments[1].latestText(), await environments[0].latestText(), body)
environments[1].emit('destroy')
}
})
test('Multiple subtitles request: real core SRT/ASS conversion and encoded text retain released output', async () => {
const responses = {
'a.SRT?track=1': '1\n00:00:00,000 --> 00:00:02,000\nSRT text\n',
'b.ass': 'Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,ASS text',
'c.vtt': Buffer.from(subtitleVtt('café'), 'latin1'),
'd.vtt': Buffer.from(subtitleVtt('日本語'), 'utf16le'),
}
const options = { subtitles: [{ url: 'a.SRT?track=1', name: 'srt' }, { url: 'b.ass', name: 'ass' }, { url: 'c.vtt', name: 'latin', encoding: 'windows-1252' }, { url: 'd.vtt', name: 'utf16', encoding: 'utf-16le' }] }
const old = multipleSubtitlesEnvironment(historical, { responses, coreUtils })
const current = multipleSubtitlesEnvironment(candidate, { responses, coreUtils })
await old.factory(options)(old.art)
await current.factory(options)(current.art)
const text = await current.latestText()
assert.equal(text, await old.latestText())
for (const value of ['SRT text', 'ASS text', 'café', '日本語']) assert(text.includes(value))
current.emit('destroy')
})
+2 -2
View File
@@ -64,11 +64,11 @@ for (const implementation of implementations) {
assert.equal(env.initialized[0].onVttLoad, env.utils.unescape)
assert.equal(env.initialized[0].style, env.art.option.subtitle.style)
assert.equal(env.initialized[0].type, 'vtt')
assert.deepEqual(env.revoked, [''])
assert.deepEqual(env.revoked, implementation.version === 'candidate' ? [] : [''])
env.art.option.subtitle.encoding = 'utf-16le'
result.reset()
assert.equal(env.initialized[1].encoding, 'utf-16le')
assert.deepEqual(env.revoked, ['', 'blob:subtitle-1'])
assert.deepEqual(env.revoked, implementation.version === 'candidate' ? ['blob:subtitle-1'] : ['', 'blob:subtitle-1'])
assert.equal(env.blobs.get('blob:subtitle-2').type, 'text/vtt')
})