fix(audio): [PKG-AUDIO-05] checkpoint zero-time resume and native buffering evidence

This commit is contained in:
Harvey Zhao committed 2026-09-12 03:58:39 +08:00
1 parent 0218196df9
commit d18c545675
25 files changed
+14373 -32

No files matched your search

@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAudioTrack=t(),define(function(){return e.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=t()}(this,function(){"use strict";return function(e){return t=>{const o=function(e){let{url:t,offset:o=0,sync:n=.3}=e;const a=new Audio;let i=!1;function u(){!i&&t&&a.play().catch(e=>{i||console.warn(e)})}function c(){if(i)return;i=!0;const e=[];for(const o of[()=>a.pause(),()=>a.removeAttribute("src"),()=>a.load()])try{o()}catch(t){e.push(t)}if(e.length)throw e[0]}try{a.preload="auto",t&&(a.src=t)}catch(r){try{c()}catch{}throw r}return{audio:a,play:u,pause:function(){i||a.pause()},destroy:c,sync(e){if(i||!t)return;const u=e+o;Math.abs(a.currentTime-u)>n&&(a.currentTime=u)},update(e,c){i||(e.url&&e.url!==t&&(t=e.url,a.src=t,c&&u()),void 0!==e.offset&&(o=e.offset),void 0!==e.sync&&(n=e.sync))}}}(e),{audio:n}=o,a=[];let i=!0;function u(){t.video&&o.sync(t.currentTime)}function c(e,o){const n=()=>{i&&o()};a.push([e,n]),t.on(e,n)}function r(){if(!i)return;i=!1;const e=[];for(const[o,i]of a.splice(0))try{t.off(o,i)}catch(n){e.push(n)}try{o.destroy()}catch(n){e.push(n)}if(e.length)throw e[0]}try{c("play",()=>{u(),o.play()});for(const e of["pause","video:pause","video:ended","video:waiting","video:emptied","video:seeking"])c(e,o.pause);c("seek",u),c("video:seeked",()=>{u(),t.playing&&o.play()}),c("video:timeupdate",()=>{t.playing&&u()}),c("video:ratechange",()=>{n.playbackRate=t.video.playbackRate}),c("video:volumechange",()=>{n.volume=t.volume,n.muted=t.muted}),c("video:playing",()=>{t.playing&&(u(),o.play())}),c("destroy",r),n.volume=t.volume,n.muted=t.muted,n.playbackRate=t.video?.playbackRate||1}catch(d){try{r()}catch{}throw d}return{name:"artplayerPluginAudioTrack",audio:n,update(e){i&&o.update(e,t.playing)}}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAudioTrack=t(),define(function(){return e.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=t()}(this,function(){"use strict";return function(e){return t=>{const o=function(e){let{url:t,offset:o=0,sync:n=.3}=e;const a=new Audio;let i=!1;function u(){!i&&t&&a.play().catch(e=>{i||console.warn(e)})}function c(){if(i)return;i=!0;const e=[];for(const o of[()=>a.pause(),()=>a.removeAttribute("src"),()=>a.load()])try{o()}catch(t){e.push(t)}if(e.length)throw e[0]}try{a.preload="auto",t&&(a.src=t)}catch(r){try{c()}catch{}throw r}return{audio:a,play:u,pause:function(){i||a.pause()},destroy:c,sync(e){if(i||!t)return;const u=e+o;Math.abs(a.currentTime-u)>n&&(a.currentTime=u)},update(e,c){i||(e.url&&e.url!==t&&(t=e.url,a.src=t,c&&u()),void 0!==e.offset&&(o=e.offset),void 0!==e.sync&&(n=e.sync))}}}(e),{audio:n}=o,a=[];let i=!0;function u(){t.video&&o.sync(t.currentTime)}function c(){const e=t.video;return!!e&&("playing"in e&&"boolean"===typeof e.playing?e.playing:t.playing||!1===e.paused&&!e.ended&&e.readyState>2)}function r(e,o){const n=()=>{i&&o()};a.push([e,n]),t.on(e,n)}function d(){if(!i)return;i=!1;const e=[];for(const[o,i]of a.splice(0))try{t.off(o,i)}catch(n){e.push(n)}try{o.destroy()}catch(n){e.push(n)}if(e.length)throw e[0]}try{r("play",()=>{u(),o.play()});for(const e of["pause","video:pause","video:ended","video:waiting","video:emptied","video:seeking"])r(e,o.pause);r("seek",u),r("video:seeked",()=>{u(),c()&&o.play()}),r("video:timeupdate",()=>{t.playing&&u()}),r("video:ratechange",()=>{n.playbackRate=t.video.playbackRate}),r("video:volumechange",()=>{n.volume=t.volume,n.muted=t.muted}),r("video:playing",()=>{c()&&(u(),o.play())}),r("video:canplay",()=>{n.paused&&c()&&(u(),o.play())}),r("destroy",d),n.volume=t.volume,n.muted=t.muted,n.playbackRate=t.video?.playbackRate||1}catch(s){try{d()}catch{}throw s}return{name:"artplayerPluginAudioTrack",audio:n,update(e){i&&o.update(e,t.playing)}}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAudioTrack=t(),define(function(){return e.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=t()}(this,function(){"use strict";return function(e){return t=>{var o;const n=function(e){let{url:t,offset:o=0,sync:n=.3}=e;const a=new Audio;let i=!1;function u(){!i&&t&&a.play().catch(e=>{i||console.warn(e)})}function c(){if(i)return;i=!0;const e=[];for(const o of[()=>a.pause(),()=>a.removeAttribute("src"),()=>a.load()])try{o()}catch(t){e.push(t)}if(e.length)throw e[0]}try{a.preload="auto",t&&(a.src=t)}catch(r){try{c()}catch(d){}throw r}return{audio:a,play:u,pause:function(){i||a.pause()},destroy:c,sync(e){if(i||!t)return;const u=e+o;Math.abs(a.currentTime-u)>n&&(a.currentTime=u)},update(e,c){i||(e.url&&e.url!==t&&(t=e.url,a.src=t,c&&u()),void 0!==e.offset&&(o=e.offset),void 0!==e.sync&&(n=e.sync))}}}(e),{audio:a}=n,i=[];let u=!0;function c(){t.video&&n.sync(t.currentTime)}function r(e,o){const n=()=>{u&&o()};i.push([e,n]),t.on(e,n)}function d(){if(!u)return;u=!1;const e=[];for(const[n,a]of i.splice(0))try{t.off(n,a)}catch(o){e.push(o)}try{n.destroy()}catch(o){e.push(o)}if(e.length)throw e[0]}try{r("play",()=>{c(),n.play()});for(const e of["pause","video:pause","video:ended","video:waiting","video:emptied","video:seeking"])r(e,n.pause);r("seek",c),r("video:seeked",()=>{c(),t.playing&&n.play()}),r("video:timeupdate",()=>{t.playing&&c()}),r("video:ratechange",()=>{a.playbackRate=t.video.playbackRate}),r("video:volumechange",()=>{a.volume=t.volume,a.muted=t.muted}),r("video:playing",()=>{t.playing&&(c(),n.play())}),r("destroy",d),a.volume=t.volume,a.muted=t.muted,a.playbackRate=(null==(o=t.video)?void 0:o.playbackRate)||1}catch(s){try{d()}catch(l){}throw s}return{name:"artplayerPluginAudioTrack",audio:a,update(e){u&&n.update(e,t.playing)}}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAudioTrack=t(),define(function(){return e.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=t()}(this,function(){"use strict";return function(e){return t=>{var o;const n=function(e){let{url:t,offset:o=0,sync:n=.3}=e;const a=new Audio;let i=!1;function u(){!i&&t&&a.play().catch(e=>{i||console.warn(e)})}function c(){if(i)return;i=!0;const e=[];for(const o of[()=>a.pause(),()=>a.removeAttribute("src"),()=>a.load()])try{o()}catch(t){e.push(t)}if(e.length)throw e[0]}try{a.preload="auto",t&&(a.src=t)}catch(r){try{c()}catch(d){}throw r}return{audio:a,play:u,pause:function(){i||a.pause()},destroy:c,sync(e){if(i||!t)return;const u=e+o;Math.abs(a.currentTime-u)>n&&(a.currentTime=u)},update(e,c){i||(e.url&&e.url!==t&&(t=e.url,a.src=t,c&&u()),void 0!==e.offset&&(o=e.offset),void 0!==e.sync&&(n=e.sync))}}}(e),{audio:a}=n,i=[];let u=!0;function c(){t.video&&n.sync(t.currentTime)}function r(){const e=t.video;return!!e&&("playing"in e&&"boolean"===typeof e.playing?e.playing:t.playing||!1===e.paused&&!e.ended&&e.readyState>2)}function d(e,o){const n=()=>{u&&o()};i.push([e,n]),t.on(e,n)}function l(){if(!u)return;u=!1;const e=[];for(const[n,a]of i.splice(0))try{t.off(n,a)}catch(o){e.push(o)}try{n.destroy()}catch(o){e.push(o)}if(e.length)throw e[0]}try{d("play",()=>{c(),n.play()});for(const e of["pause","video:pause","video:ended","video:waiting","video:emptied","video:seeking"])d(e,n.pause);d("seek",c),d("video:seeked",()=>{c(),r()&&n.play()}),d("video:timeupdate",()=>{t.playing&&c()}),d("video:ratechange",()=>{a.playbackRate=t.video.playbackRate}),d("video:volumechange",()=>{a.volume=t.volume,a.muted=t.muted}),d("video:playing",()=>{r()&&(c(),n.play())}),d("video:canplay",()=>{a.paused&&r()&&(c(),n.play())}),d("destroy",l),a.volume=t.volume,a.muted=t.muted,a.playbackRate=(null==(o=t.video)?void 0:o.playbackRate)||1}catch(s){try{l()}catch(p){}throw s}return{name:"artplayerPluginAudioTrack",audio:a,update(e){u&&n.update(e,t.playing)}}}}});
+16 -2
View File
@@ -84,6 +84,14 @@ function artplayerPluginAudioTrack(option) {
if (art.video)
track.sync(art.currentTime);
}
function canResumeAudio() {
const video = art.video;
if (!video)
return false;
if ("playing" in video && typeof video.playing === "boolean")
return video.playing;
return art.playing || video.paused === false && !video.ended && video.readyState > 2;
}
function listen(event, callback) {
const listener = () => {
if (active)
@@ -122,7 +130,7 @@ function artplayerPluginAudioTrack(option) {
listen("seek", syncAudio);
listen("video:seeked", () => {
syncAudio();
if (art.playing)
if (canResumeAudio())
track.play();
});
listen("video:timeupdate", () => {
@@ -137,7 +145,13 @@ function artplayerPluginAudioTrack(option) {
audio.muted = art.muted;
});
listen("video:playing", () => {
if (art.playing) {
if (canResumeAudio()) {
syncAudio();
track.play();
}
});
listen("video:canplay", () => {
if (audio.paused && canResumeAudio()) {
syncAudio();
track.play();
}
+1 -1
View File
@@ -44,7 +44,7 @@
"check:toolchain": "node scripts/check-toolchain.mjs",
"lint:fix": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --fix",
"check:plan": "node refactor/scripts/plan.mjs --check",
"test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js test/package-check.test.js test/declarations.test.js test/editor-types.test.js test/coverage.test.js test/performance-report.test.js",
"test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js test/package-check.test.js test/declarations.test.js test/editor-types.test.js test/coverage.test.js test/performance-report.test.js test/media-gate.test.js",
"test:baseline": "node --test refactor/scripts/*.test.mjs",
"ci:check": "yarn check:toolchain --strict && yarn check:plan && yarn lint && yarn check:types && yarn typecheck && yarn test",
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
@@ -26,6 +26,13 @@ pause audio; seeked synchronizes and resumes only if the video is playing. Nativ
synchronizes again before resume. Ordinary playing timeupdate retains the strict drift
threshold and offset rule. Rate and volume/mute follow the existing core properties.
The entry owns 15 subscriptions. Resume checks respect an explicit proxy video.playing
boolean first. For native media they also accept an unpaused, non-ended video with
readyState > 2 at currentTime zero: the historical art.playing getter requires time > 0,
which can otherwise leave audio paused after the seek associated with a source switch.
If seeked arrives before readiness, canplay synchronizes and resumes only paused audio
whose host is playing. A paused host or proxy playing=false must never trigger recovery.
Destroy marks the entry inactive before removing listeners. The track then closes before
pausing, removing the src attribute and loading the empty element. Cleanup attempts all
steps even when a consumer-overridden method throws, then propagates the first cleanup error.
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAudioTrack=t(),define(function(){return e.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=t()}(this,function(){"use strict";return function(e){return t=>{const o=function(e){let{url:t,offset:o=0,sync:n=.3}=e;const a=new Audio;let i=!1;function u(){!i&&t&&a.play().catch(e=>{i||console.warn(e)})}function c(){if(i)return;i=!0;const e=[];for(const o of[()=>a.pause(),()=>a.removeAttribute("src"),()=>a.load()])try{o()}catch(t){e.push(t)}if(e.length)throw e[0]}try{a.preload="auto",t&&(a.src=t)}catch(r){try{c()}catch{}throw r}return{audio:a,play:u,pause:function(){i||a.pause()},destroy:c,sync(e){if(i||!t)return;const u=e+o;Math.abs(a.currentTime-u)>n&&(a.currentTime=u)},update(e,c){i||(e.url&&e.url!==t&&(t=e.url,a.src=t,c&&u()),void 0!==e.offset&&(o=e.offset),void 0!==e.sync&&(n=e.sync))}}}(e),{audio:n}=o,a=[];let i=!0;function u(){t.video&&o.sync(t.currentTime)}function c(e,o){const n=()=>{i&&o()};a.push([e,n]),t.on(e,n)}function r(){if(!i)return;i=!1;const e=[];for(const[o,i]of a.splice(0))try{t.off(o,i)}catch(n){e.push(n)}try{o.destroy()}catch(n){e.push(n)}if(e.length)throw e[0]}try{c("play",()=>{u(),o.play()});for(const e of["pause","video:pause","video:ended","video:waiting","video:emptied","video:seeking"])c(e,o.pause);c("seek",u),c("video:seeked",()=>{u(),t.playing&&o.play()}),c("video:timeupdate",()=>{t.playing&&u()}),c("video:ratechange",()=>{n.playbackRate=t.video.playbackRate}),c("video:volumechange",()=>{n.volume=t.volume,n.muted=t.muted}),c("video:playing",()=>{t.playing&&(u(),o.play())}),c("destroy",r),n.volume=t.volume,n.muted=t.muted,n.playbackRate=t.video?.playbackRate||1}catch(d){try{r()}catch{}throw d}return{name:"artplayerPluginAudioTrack",audio:n,update(e){i&&o.update(e,t.playing)}}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAudioTrack=t(),define(function(){return e.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=t()}(this,function(){"use strict";return function(e){return t=>{const o=function(e){let{url:t,offset:o=0,sync:n=.3}=e;const a=new Audio;let i=!1;function u(){!i&&t&&a.play().catch(e=>{i||console.warn(e)})}function c(){if(i)return;i=!0;const e=[];for(const o of[()=>a.pause(),()=>a.removeAttribute("src"),()=>a.load()])try{o()}catch(t){e.push(t)}if(e.length)throw e[0]}try{a.preload="auto",t&&(a.src=t)}catch(r){try{c()}catch{}throw r}return{audio:a,play:u,pause:function(){i||a.pause()},destroy:c,sync(e){if(i||!t)return;const u=e+o;Math.abs(a.currentTime-u)>n&&(a.currentTime=u)},update(e,c){i||(e.url&&e.url!==t&&(t=e.url,a.src=t,c&&u()),void 0!==e.offset&&(o=e.offset),void 0!==e.sync&&(n=e.sync))}}}(e),{audio:n}=o,a=[];let i=!0;function u(){t.video&&o.sync(t.currentTime)}function c(){const e=t.video;return!!e&&("playing"in e&&"boolean"===typeof e.playing?e.playing:t.playing||!1===e.paused&&!e.ended&&e.readyState>2)}function r(e,o){const n=()=>{i&&o()};a.push([e,n]),t.on(e,n)}function d(){if(!i)return;i=!1;const e=[];for(const[o,i]of a.splice(0))try{t.off(o,i)}catch(n){e.push(n)}try{o.destroy()}catch(n){e.push(n)}if(e.length)throw e[0]}try{r("play",()=>{u(),o.play()});for(const e of["pause","video:pause","video:ended","video:waiting","video:emptied","video:seeking"])r(e,o.pause);r("seek",u),r("video:seeked",()=>{u(),c()&&o.play()}),r("video:timeupdate",()=>{t.playing&&u()}),r("video:ratechange",()=>{n.playbackRate=t.video.playbackRate}),r("video:volumechange",()=>{n.volume=t.volume,n.muted=t.muted}),r("video:playing",()=>{c()&&(u(),o.play())}),r("video:canplay",()=>{n.paused&&c()&&(u(),o.play())}),r("destroy",d),n.volume=t.volume,n.muted=t.muted,n.playbackRate=t.video?.playbackRate||1}catch(s){try{d()}catch{}throw s}return{name:"artplayerPluginAudioTrack",audio:n,update(e){i&&o.update(e,t.playing)}}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAudioTrack=t(),define(function(){return e.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=t()}(this,function(){"use strict";return function(e){return t=>{var o;const n=function(e){let{url:t,offset:o=0,sync:n=.3}=e;const a=new Audio;let i=!1;function u(){!i&&t&&a.play().catch(e=>{i||console.warn(e)})}function c(){if(i)return;i=!0;const e=[];for(const o of[()=>a.pause(),()=>a.removeAttribute("src"),()=>a.load()])try{o()}catch(t){e.push(t)}if(e.length)throw e[0]}try{a.preload="auto",t&&(a.src=t)}catch(r){try{c()}catch(d){}throw r}return{audio:a,play:u,pause:function(){i||a.pause()},destroy:c,sync(e){if(i||!t)return;const u=e+o;Math.abs(a.currentTime-u)>n&&(a.currentTime=u)},update(e,c){i||(e.url&&e.url!==t&&(t=e.url,a.src=t,c&&u()),void 0!==e.offset&&(o=e.offset),void 0!==e.sync&&(n=e.sync))}}}(e),{audio:a}=n,i=[];let u=!0;function c(){t.video&&n.sync(t.currentTime)}function r(e,o){const n=()=>{u&&o()};i.push([e,n]),t.on(e,n)}function d(){if(!u)return;u=!1;const e=[];for(const[n,a]of i.splice(0))try{t.off(n,a)}catch(o){e.push(o)}try{n.destroy()}catch(o){e.push(o)}if(e.length)throw e[0]}try{r("play",()=>{c(),n.play()});for(const e of["pause","video:pause","video:ended","video:waiting","video:emptied","video:seeking"])r(e,n.pause);r("seek",c),r("video:seeked",()=>{c(),t.playing&&n.play()}),r("video:timeupdate",()=>{t.playing&&c()}),r("video:ratechange",()=>{a.playbackRate=t.video.playbackRate}),r("video:volumechange",()=>{a.volume=t.volume,a.muted=t.muted}),r("video:playing",()=>{t.playing&&(c(),n.play())}),r("destroy",d),a.volume=t.volume,a.muted=t.muted,a.playbackRate=(null==(o=t.video)?void 0:o.playbackRate)||1}catch(s){try{d()}catch(l){}throw s}return{name:"artplayerPluginAudioTrack",audio:a,update(e){u&&n.update(e,t.playing)}}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAudioTrack=t(),define(function(){return e.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=t()}(this,function(){"use strict";return function(e){return t=>{var o;const n=function(e){let{url:t,offset:o=0,sync:n=.3}=e;const a=new Audio;let i=!1;function u(){!i&&t&&a.play().catch(e=>{i||console.warn(e)})}function c(){if(i)return;i=!0;const e=[];for(const o of[()=>a.pause(),()=>a.removeAttribute("src"),()=>a.load()])try{o()}catch(t){e.push(t)}if(e.length)throw e[0]}try{a.preload="auto",t&&(a.src=t)}catch(r){try{c()}catch(d){}throw r}return{audio:a,play:u,pause:function(){i||a.pause()},destroy:c,sync(e){if(i||!t)return;const u=e+o;Math.abs(a.currentTime-u)>n&&(a.currentTime=u)},update(e,c){i||(e.url&&e.url!==t&&(t=e.url,a.src=t,c&&u()),void 0!==e.offset&&(o=e.offset),void 0!==e.sync&&(n=e.sync))}}}(e),{audio:a}=n,i=[];let u=!0;function c(){t.video&&n.sync(t.currentTime)}function r(){const e=t.video;return!!e&&("playing"in e&&"boolean"===typeof e.playing?e.playing:t.playing||!1===e.paused&&!e.ended&&e.readyState>2)}function d(e,o){const n=()=>{u&&o()};i.push([e,n]),t.on(e,n)}function l(){if(!u)return;u=!1;const e=[];for(const[n,a]of i.splice(0))try{t.off(n,a)}catch(o){e.push(o)}try{n.destroy()}catch(o){e.push(o)}if(e.length)throw e[0]}try{d("play",()=>{c(),n.play()});for(const e of["pause","video:pause","video:ended","video:waiting","video:emptied","video:seeking"])d(e,n.pause);d("seek",c),d("video:seeked",()=>{c(),r()&&n.play()}),d("video:timeupdate",()=>{t.playing&&c()}),d("video:ratechange",()=>{a.playbackRate=t.video.playbackRate}),d("video:volumechange",()=>{a.volume=t.volume,a.muted=t.muted}),d("video:playing",()=>{r()&&(c(),n.play())}),d("video:canplay",()=>{a.paused&&r()&&(c(),n.play())}),d("destroy",l),a.volume=t.volume,a.muted=t.muted,a.playbackRate=(null==(o=t.video)?void 0:o.playbackRate)||1}catch(s){try{l()}catch(p){}throw s}return{name:"artplayerPluginAudioTrack",audio:a,update(e){u&&n.update(e,t.playing)}}}}});
@@ -84,6 +84,14 @@ function artplayerPluginAudioTrack(option) {
if (art.video)
track.sync(art.currentTime);
}
function canResumeAudio() {
const video = art.video;
if (!video)
return false;
if ("playing" in video && typeof video.playing === "boolean")
return video.playing;
return art.playing || video.paused === false && !video.ended && video.readyState > 2;
}
function listen(event, callback) {
const listener = () => {
if (active)
@@ -122,7 +130,7 @@ function artplayerPluginAudioTrack(option) {
listen("seek", syncAudio);
listen("video:seeked", () => {
syncAudio();
if (art.playing)
if (canResumeAudio())
track.play();
});
listen("video:timeupdate", () => {
@@ -137,7 +145,13 @@ function artplayerPluginAudioTrack(option) {
audio.muted = art.muted;
});
listen("video:playing", () => {
if (art.playing) {
if (canResumeAudio()) {
syncAudio();
track.play();
}
});
listen("video:canplay", () => {
if (audio.paused && canResumeAudio()) {
syncAudio();
track.play();
}
@@ -2,7 +2,7 @@ import type Artplayer from 'artplayer'
import type { Option, RuntimeResult, UpdateOption } from '../types/artplayer-plugin-audio-track'
import { createAudioTrack } from './track'
type AudioEvent = 'play' | 'pause' | 'seek' | 'destroy' | 'video:pause' | 'video:ended' | 'video:waiting' | 'video:emptied' | 'video:seeking' | 'video:seeked' | 'video:timeupdate' | 'video:ratechange' | 'video:volumechange' | 'video:playing'
type AudioEvent = 'play' | 'pause' | 'seek' | 'destroy' | 'video:pause' | 'video:ended' | 'video:waiting' | 'video:emptied' | 'video:seeking' | 'video:seeked' | 'video:timeupdate' | 'video:ratechange' | 'video:volumechange' | 'video:playing' | 'video:canplay'
export default function artplayerPluginAudioTrack(option: Option) {
return (art: Artplayer): RuntimeResult => {
@@ -16,6 +16,15 @@ export default function artplayerPluginAudioTrack(option: Option) {
track.sync(art.currentTime)
}
function canResumeAudio() {
const video = art.video
if (!video)
return false
if ('playing' in video && typeof video.playing === 'boolean')
return video.playing
return art.playing || (video.paused === false && !video.ended && video.readyState > 2)
}
function listen(event: AudioEvent, callback: () => void) {
const listener = () => {
if (active)
@@ -54,7 +63,7 @@ export default function artplayerPluginAudioTrack(option: Option) {
listen('seek', syncAudio)
listen('video:seeked', () => {
syncAudio()
if (art.playing)
if (canResumeAudio())
track.play()
})
listen('video:timeupdate', () => {
@@ -69,7 +78,13 @@ export default function artplayerPluginAudioTrack(option: Option) {
audio.muted = art.muted
})
listen('video:playing', () => {
if (art.playing) {
if (canResumeAudio()) {
syncAudio()
track.play()
}
})
listen('video:canplay', () => {
if (audio.paused && canResumeAudio()) {
syncAudio()
track.play()
}
+42
View File
@@ -82,3 +82,45 @@ legacy 首次 WebKit 的“候选核心 + 已发布 audio”结束观察失败
两种结束用例现统一先确认视频/音频 seek 完成且就绪,再确认音频实际恢复推进后才
观测 video.ended,并记录宿主事件;避免原测试因音频早已暂停而误判。相关 WebKit
12 次及最终 main/legacy 各 42 项通过。未证明首次暂停的确切来源,未改生产代码。
## PKG-AUDIO-05 恢复播放修复与缓冲检查点
CORE-24 后,旧核心与候选 audio 的 WebKit 快速切源仍可复现音频暂停。补充宿主日志
后确认 video:seeked 时 video.currentTime=0、paused=false、readyState=4,而 art.playing=false。
原核心 playing 要求时间大于 0,候选的 seeking 暂停后错过恢复。先前猜测 readyState=2
并非这次日志的根因。index.ts 现在优先尊重 proxy 的显式 playing 布尔值,原生媒体额外
接受未暂停、未结束且 readyState>2 的时间零状态;canplay 为 seeked 早于就绪提供守卫恢复。
公开类型、默认值、偏移算法、update 的同步/真值语义和 audio 元素身份均未改变。
受控用例先红后绿,三种实际 JS 产物共 97 项通过;完整 CI 599 项(550+14+35)通过。
快速切源 WebKit 修复前四次 3 通过/1 失败,修复后四次全部通过;完整媒体和组合测试
还覆盖两种核心与两种插件。最终报告在本阶段冻结证据中登记,不能把四次专项当成全矩阵。
偏移边界使用相同 AAC 的独立原生 Audio 对照。首轮 legacy 的唯一失败来自原生对照
负 seek 后返回 0.01,而断言严格为 0;已保存报告。起点和时长边界现在都要求原生值
接近边界、插件接近同场原生值(差值小于 0.05 秒)、非负起点、无媒体错误,并验证
回到区间内 2.5 秒及实际恢复播放。不是只将候选结果与宽泛常量比较。
### 真实缓冲与原生能力限制
media-gate.js 用 loopback HTTP 提供固定媒体,先发前缀并扣住尾部,release 后发送原字节。
三项真实 HTTP 测试覆盖流式正文、完成的部分 Range、HEAD/416/suffix 与强 ETag。
该文件已加入 test:node,无新增依赖。集成测试要求实际推进后原生 isTrusted waiting,
同时检查视频缓冲暂停外部音频、音频缓冲不暂停视频,以及恢复后的时钟和同步。
初轮默认流式 24 项为 Chromium/Firefox 16 通过、Windows WebKit 8 失败;改为完整
部分 Range 的 WebKit 8 项仍失败。保留两次失败,未提高超时、重试或跳过。失败时
清理后的空 src/error=4 不能用来判断缓冲失败根因;请求附件现在在页面清理前写入。
最终同一流式夹具与实际 main/legacy 在 Chromium/Firefox 各 16 项通过;这是限定引擎
的补充证据,保留先前 Windows WebKit 失败,不代表三引擎完整缓冲验收。
独立 media-gate-native.spec.js 确认没有 ArtPlayer 实例,测试两种响应模式。最初使用
原生默认 preload,Firefox 也无法在放行前推进;与实际插件对齐为 preload=auto 后,
Chromium/Firefox 的八种组合都观测到推进与可信 waiting,Windows WebKit 四种组合
仍停在约 0、readyState=3/4、paused=false、无错误且没有可信 waiting。全部在放行后
推进。12 个诊断通过只证明观察和放行后恢复,不等于 12 个真实缓冲验收通过。
此证据限定当前 Windows WebKit、固定 MP4/AAC 和两种 HTTP 响应,不能推断真实 Safari
不支持缓冲;更不能为候选忽略 failing 用例。AUDIO-BUFFER-01 保持 open,Audio-05
仍 doing;需要其他可实际执行的原生环境/输入路径补齐。当前没有已安装 WSL 发行版或
可用 Docker 命令。物理设备、声音输出、自动播放策略和 Audio-06 的安装/demo 仍未验收。
File diff suppressed because it is too large. Load diff
@@ -22,3 +22,47 @@ Audio-04 的模块边界及旧类型入口不变;发现问题先比较已发
共用输入/事件/清理辅助移入 audio-fixture.js,通过显式 useAudioFixture() 为每个测试
文件注册各自 hooks,避免 import 缓存让后一个 suite 缺输入。原有媒体用例也会重跑。
本阶段只提交明确标注 checkpoint 的测试与发现,未提交生产修复,也未完成 Audio-05。
## CORE-24 后恢复实施
0218196d 已核实,工作区干净。六项候选核心切源回归已由 CORE-24 修复并重跑原断言。
Audio-05 恢复 doing;继续旧核心 WebKit 的音频恢复、边界原生对照和真实网络缓冲。
## 第二个检查点:已修复时间零恢复,保留缓冲缺口
本次有生产修复,仍不是 Audio-05 完成提交。index.ts 的 resume 判断优先采用 proxy
显式 playing 布尔值;原生媒体在时间零、未暂停/未结束且 readyState>2 时也可恢复。
canplay 只对暂停音频且宿主可播放的状态补恢复,监听仍由当前实例统一解绑。模块职责、
public 类型、update 同步返回、默认 offset/sync、音频身份和 URL 真值语义不变。
修复前日志精确显示 video:playing/seeking/seeked 的时间为 0、readyState=4、paused=false,
而 art.playing=false。不是先前猜测的 readyState=2。先加受控失败用例,修复后源/历史
共 46 项、三格式产物共 97 项通过。主/legacy 实际构建分别 78 项浏览器测试通过,无重试
或跳过;每组含 36 新旧组合、原 42 媒体项,其中 9 项历史缺陷观察和 3 项 WAV 原生诊断。
完整 CI 599 项通过(550 单元、14 工程、35 基线);声明校验和严格类型通过。
保留生成声明的一条既有 ESLint warning,没有新 lint error。
负偏移和超过时长现在与同文件独立原生 Audio 比较。main 首轮 78 通过;legacy 首轮
77 通过/1 失败,失败是原生负 seek 的实际 0.01 与严格 0 不等。记录此证据后,对两端
统一使用小于 0.05 秒的原生边界/候选差值断言,同时检查起点非负、无错误及区间内恢复。
最终 main/legacy 重新执行各 78 项通过。负/超时长的状态在断言之前保存,方便后续诊断。
新增 loopback media-gate 及三项真实 HTTP 测试,已纳入 test:node,没有新依赖。
真实缓冲测试保留完整四种新旧组合和三个引擎的要求,初轮 Chromium/Firefox 16 通过、
Windows WebKit 8 失败。换为有强 ETag 的完整部分 Range 响应后 WebKit 8 项仍失败;
没有删掉失败用例。原生 preload=auto 对照两种响应形状,证明 Chromium/Firefox 可以推进
后 waiting,Windows WebKit 此输入则没有推进到缓冲;12 项诊断通过仅证明观测与放行后恢复。
最初原生默认 preload 导致 Firefox 也不推进,已对齐插件属性,保留前后两个报告。
此独立原生观察不证明真实 Safari 不支持,也不替代插件实际缓冲验收。
最终流式夹具对实际 main/legacy 在 Chromium/Firefox 分别执行 16 项,均通过;这是明确
限定引擎的补充验证,不能覆盖之前保留的 Windows WebKit 失败。
证据和报告指纹见 [本次冻结记录](../baselines/audio-combinations-checkpoint.json)。所有首轮失败
报告/trace 按独立路径保留。原始报告中的附件仍写共用路径,读取时使用记录的复制结果目录;
cache 不入 Git,丢失后应按命令重跑,不能声称旧 trace 永久可用。
AUDIO-RESUME-01 按具体切源恢复范围关闭;AUDIO-SYNC-01 与新增 AUDIO-BUFFER-01
保持 open,任务仍为 doing。下一步需要有效原生环境补齐缓冲,并继续策略/设备要求;
Audio-06 的 tarball/8082 demo 仍待验收。其他独立包可继续,不能将该缺口升级成全局阻塞。
本检查点未更改包版本、推送、打 tag 或发布。回退整个本次提交可恢复 CORE-24 后的
候选音频行为;已确认的时间零恢复问题会随生产修复回退而重新出现。
+3 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 217 项,范围 22 个包及工作区/示例。
状态:todo 146 / doing 1 / blocked 0 / done 70 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 145 / doing 2 / blocked 0 / done 70 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -160,7 +160,7 @@
| PKG-AUDIO-02 | artplayer-plugin-audio-track<br>建立特有行为与错误测试 | PKG-AUDIO-01, ENG-03, ENG-05 | 偏移、倍率、seek、waiting/playing、加载错误和 autoplay 拒绝 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-AUDIO-03 | artplayer-plugin-audio-track<br>整理内部职责与资源 | PKG-AUDIO-02, CORE-10 | 外部 audio 的同步策略、监听与源更新生命周期 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-AUDIO-04 | artplayer-plugin-audio-track<br>迁移自有源码和公开类型 | PKG-AUDIO-03, ENG-04, ENG-06, CORE-07 | Option/Result/audio、update 输入和事件类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | done |
| PKG-AUDIO-05 | artplayer-plugin-audio-track<br>验证新旧核心和组合 | PKG-AUDIO-04, CORE-22, CORE-24 | 主视频切源和缓冲恢复,多实例独立音频、旧核心 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-AUDIO-05 | artplayer-plugin-audio-track<br>验证新旧核心和组合 | PKG-AUDIO-04, CORE-22, CORE-24 | 主视频切源和缓冲恢复,多实例独立音频、旧核心 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | doing |
| PKG-AUDIO-06 | artplayer-plugin-audio-track<br>验证分发并同步文档 | PKG-AUDIO-05, ENG-07 | audio.track.js 示例与运行返回值一致的声明 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
## 5 包迁移:artplayer-plugin-auto-thumbnail
@@ -472,7 +472,7 @@
- PKG-AUDIO-02: [记录](changes/2026-09-12-PKG-AUDIO-02-tests.md) [记录](audio-validation.md) [记录](baselines/audio-validation.json)
- PKG-AUDIO-03: [记录](changes/2026-09-12-PKG-AUDIO-03-lifecycle.md) [记录](baselines/audio-lifecycle-validation.json)
- PKG-AUDIO-04: [记录](changes/2026-09-12-PKG-AUDIO-04-types.md) [记录](baselines/audio-types-validation.json)
- PKG-AUDIO-05: [记录](changes/2026-09-12-PKG-AUDIO-05-combinations.md) [记录](baselines/audio-combinations-first.json)
- PKG-AUDIO-05: [记录](changes/2026-09-12-PKG-AUDIO-05-combinations.md) [记录](baselines/audio-combinations-first.json) [记录](baselines/audio-combinations-checkpoint.json) [记录](audio-validation.md)
- PKG-HLS-01: [记录](baselines/hls-control-contract.md) [记录](baselines/hls-control-release.json) [记录](changes/2026-09-11-PKG-HLS-01-contract.md)
- PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md)
- PKG-HLS-03: [记录](changes/2026-09-12-PKG-HLS-03-modules.md) [记录](baselines/hls-modules-validation.json) [记录](hls-validation.md)
+16
View File
@@ -1,5 +1,21 @@
# 进度与证据
## 当前实施:Audio-05 恢复修复与真实缓冲检查点
已修复旧核心时间零 seek 后候选音频漏恢复,严格 TS 与公开接口不变;三格式 Node 97 项、
完整 CI 599 项、main/legacy 三引擎媒体和新旧组合各 78 项通过。首次 legacy 原生负 seek
返回 0.01 导致的严格零断言失败已保留,补充边界对照后完整重跑。不是发布或全包验收。
独立原生媒体 preload=auto 对照已确认 Chromium/Firefox 可推进后真实 waiting,Windows
WebKit 在两种受限响应下未推进到缓冲,放行后可播放。AUDIO-RESUME-01 关闭,
AUDIO-BUFFER-01 与 AUDIO-SYNC-01 保持 open;没有跳过集成失败或将原生诊断当作通过。
当前 217 项:70 done、2 doing、145 todo。Audio-05 和 HLS-SDK-01 尚未完成。
本批为本地 checkpoint,源码、测试、包内架构与证据一同提交。其余包继续按依赖推进;
分发、设备、完整 CI/CD、各包大版本升级和三轮发布复盘仍待完成,未推送或发布。
见 [本次变更](changes/2026-09-12-PKG-AUDIO-05-combinations.md) 和
[冻结证据](baselines/audio-combinations-checkpoint.json)。以下是各阶段当时的历史状态。
## 最新完成:CORE-24 连续切源播放意图修复
Audio-05 检查点已提交 b5e96664,原六项候选核心回归保持原断言重跑全部通过。
+2 -1
View File
@@ -131,4 +131,5 @@
| AUDIO-TYPE-01 | accepted-with-scope / 源码/产物事实 | Audio Track update 部分配置与公开/编辑器声明不一致 | PKG-AUDIO-04 |
| AUDIO-DEMO-01 | open / 源码/产物事实 | Audio Track README 示例链接名称不匹配且缺维护说明 | PKG-AUDIO-06 |
| CORE-SOURCE-01 | resolved / 已复现 | 候选核心连续切源丢失原播放意图 | CORE-24 |
| AUDIO-RESUME-01 | open / 已复现 | WebKit 旧核心切源后候选音频保持暂停 | PKG-AUDIO-05 |
| AUDIO-RESUME-01 | resolved / 已复现 | WebKit 旧核心切源后候选音频保持暂停 | PKG-AUDIO-05 |
| AUDIO-BUFFER-01 | open / 已复现 | Windows WebKit 原生媒体无法在受限响应下推进至真实缓冲 | PKG-AUDIO-05 |
+34 -5
View File
@@ -2686,9 +2686,10 @@
"test/audio-track.test.js",
"test/browser/audio-track.spec.js",
"refactor/baselines/audio-validation.json",
"refactor/baselines/audio-lifecycle-validation.json"
"refactor/baselines/audio-lifecycle-validation.json",
"refactor/baselines/audio-combinations-checkpoint.json"
],
"compatibleResolution": "PKG-AUDIO-03 已修复原生暂停/结束与直接 seek 同步;严格阈值、偏移正负、倍率/音量不变。PKG-AUDIO-05 继续负目标/超时长、真实缓冲和连续切源的完整验证,条目仍 open。",
"compatibleResolution": "03 已修复原生暂停/结束;05 的连续切源和偏移原生对照已验证,真实缓冲 Chromium/Firefox 初轮通过但 Windows WebKit 保留 AUDIO-BUFFER-01;策略/设备缺口继续 open。",
"closureCriteria": "正常/边界/失败/缓冲恢复有媒体与受控断言,实际支持环境缺口明确处理。"
},
{
@@ -2753,15 +2754,43 @@
"id": "AUDIO-RESUME-01",
"title": "WebKit 旧核心切源后候选音频保持暂停",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"PKG-AUDIO-05"
],
"evidence": [
"refactor/baselines/audio-combinations-first.json",
"test/audio-track.test.js",
"test/browser/audio-combinations.spec.js",
"refactor/baselines/audio-combinations-checkpoint.json"
],
"compatibleResolution": "原生时间零且未暂停、未结束、readyState>2 时允许恢复;显式 proxy playing 布尔值优先。canplay 仅恢复暂停且宿主可播放的 audio,保持公开接口与同步算法。",
"closureCriteria": "保留原始失败,精确事件复现并有旧核心/新核心三引擎回归;不可仅重复到绿灯。",
"resolutionEvidence": [
"refactor/baselines/audio-combinations-checkpoint.json",
"refactor/changes/2026-09-12-PKG-AUDIO-05-combinations.md",
"packages/artplayer-plugin-audio-track/ARCHITECTURE.md"
],
"resolutionRationale": "失败日志确认 seeked 时间零、readyState=4、paused=false 而 art.playing=false。受控用例先红后绿,实际三格式 97 项通过;main/legacy 每组 78 项含三引擎旧/新核心组合通过。仅关闭此具体恢复缺陷,真实缓冲、策略与设备范围保持开放。"
},
{
"id": "AUDIO-BUFFER-01",
"title": "Windows WebKit 原生媒体无法在受限响应下推进至真实缓冲",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-AUDIO-05"
],
"evidence": [
"refactor/baselines/audio-combinations-first.json"
"test/browser/audio-buffering.spec.js",
"test/browser/media-gate-native.spec.js",
"test/helpers/media-gate.js",
"test/media-gate.test.js",
"refactor/baselines/audio-combinations-checkpoint.json",
"refactor/audio-validation.md"
],
"compatibleResolution": "先核实 seeked/canplay 和宿主 playing 的事件顺序;修复真实可播放后音频缺少恢复入口,保持旧核心支持。",
"closureCriteria": "保留原始失败,精确事件复现并有旧核心/新核心三引擎回归;不可仅重复到绿灯。"
"compatibleResolution": "保留真实 waiting 断言;使用独立原生对照和其他实际支持环境/输入继续验证,不将没有播放推进的诊断当成缓冲通过,也不凭此改变插件 API 或否定真实 Safari 支持。",
"closureCriteria": "受影响的缓冲/恢复得到真实媒体证据;与原生能力和测试夹具差异分开,发布环境的剩余范围经显式处理。"
}
]
}
+4 -2
View File
@@ -1575,13 +1575,15 @@
"CORE-22",
"CORE-24"
],
"status": "todo",
"status": "doing",
"risk": "H",
"deliverable": "主视频切源和缓冲恢复,多实例独立音频、旧核心",
"acceptance": "最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成",
"evidence": [
"changes/2026-09-12-PKG-AUDIO-05-combinations.md",
"baselines/audio-combinations-first.json"
"baselines/audio-combinations-first.json",
"baselines/audio-combinations-checkpoint.json",
"audio-validation.md"
]
},
{
+38
View File
@@ -146,6 +146,44 @@ for (const { name, factory } of implementations) {
const historical = implementations.find(item => item.name === 'published-main').factory
for (const { name, factory: candidate } of implementations.filter(item => !item.name.startsWith('published'))) {
test(`${name}: native playback at zero resumes after seek without overriding proxy playing`, (t) => {
const { art } = audioHost(t)
Object.assign(art.video, { currentTime: 0, paused: false, ended: false, readyState: 4 })
const { audio } = candidate({ url: 'audio.aac' })(art)
audio.calls.length = 0
art.emit('video:seeking').emit('video:seeked')
assert.deepEqual(audio.calls, [['pause'], ['play']])
art.video.playing = false
audio.calls.length = 0
art.emit('video:seeked').emit('video:playing').emit('video:canplay')
assert.deepEqual(audio.calls, [], 'A proxy explicit false is authoritative')
art.video.playing = true
art.emit('video:seeked')
assert.deepEqual(audio.calls, [['play']])
})
test(`${name}: canplay recovers a paused audio after seeked preceded host readiness`, (t) => {
const { art } = audioHost(t)
const { audio } = candidate({ url: 'audio.aac' })(art)
art.currentTime = 3
art.emit('video:seeking').emit('video:seeked')
assert.equal(audio.currentTime, 3)
audio.calls.length = 0
art.emit('video:canplay')
assert.deepEqual(audio.calls, [], 'A paused host must not resume audio')
art.playing = true
art.emit('video:canplay')
assert.deepEqual(audio.calls, [['play']])
audio.paused = false
audio.calls.length = 0
art.emit('video:canplay')
assert.deepEqual(audio.calls, [], 'Already playing audio needs no additional play request')
art.emit('destroy')
audio.calls.length = 0
art.emit('video:canplay')
assert.deepEqual(audio.calls, [])
})
test(`${name}: closed callbacks and retained update cannot revive resources`, (t) => {
const { art, listeners } = audioHost(t)
const plugin = candidate({ url: 'audio.aac' })(art)
+22
View File
@@ -97,3 +97,25 @@ The docs `assets/sample/video.mp4` remains an existing sample with its original
tracked by BASE-MEDIA-01. The new synthetic fixtures do not resolve rights for existing media.
Browser binaries/codec support vary by OS; these projects do not replace Safari/iOS/Android
device checks. See the [official browser guidance](https://playwright.dev/docs/browsers).
## Withheld media diagnostics
`test/helpers/media-gate.js` binds a temporary HTTP server to loopback and withholds
the tail of the pinned media until the test calls release(). Its default response
keeps the full Content-Length and streams the prefix; the diagnostic completeRanges
mode sends a bounded 206 response and holds the later range. Both preserve exact
bytes and a strong ETag. Partial range semantics follow
[RFC 9110 section 15.3.7](https://www.rfc-editor.org/rfc/rfc9110.html#name-partial-content).
`node --test test/media-gate.test.js` verifies actual HTTP bytes, headers and release.
`audio-buffering.spec.js` requires trusted waiting after actual clock progression,
then release, resumed clocks and synchronization across old/new core/plugin pairs.
It currently has unresolved Windows WebKit failures; do not skip them or count
the native diagnostic below as equivalent acceptance. See refactor/audio-validation.md.
`media-gate-native.spec.js` removes all ArtPlayer instances and uses a native element
with preload=auto. It records whether playback and starvation were observed for
both response modes. A passing diagnostic only proves observation plus successful
playback after release; inspect its progressed/waiting flags before claiming buffering.
These capability observation timeouts do not relax the integration assertions.
Archive the report and results (including traces) before starting another browser suite.
+62
View File
@@ -0,0 +1,62 @@
import fs from 'node:fs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { createMediaGate } from '../helpers/media-gate.js'
import { useAudioFixture } from './audio-fixture.js'
import { expect, test } from './fixtures.js'
const { openAudio } = useAudioFixture()
for (const core of ['published', 'candidate']) {
for (const plugin of ['published', 'source']) {
for (const owner of ['video', 'audio']) {
test(`${core} core + ${plugin} audio: real ${owner} starvation resumes after network release`, async ({ page }, testInfo) => {
const file = owner === 'video' ? 'pattern.mp4' : 'audio-tone.m4a'
const bytes = fs.readFileSync(new URL(`./media/${file}`, import.meta.url))
const limit = owner === 'video' ? 96 * 1024 : 32 * 1024
const gate = await createMediaGate(bytes, owner === 'video' ? 'video/mp4' : 'audio/mp4', limit)
try {
await openAudio(page, core, plugin, testInfo, owner === 'audio' ? gate.url : '/test/audio-tone.m4a')
await page.evaluate((owner) => {
window.starvation = []
const media = owner === 'video' ? window.art.video : window.audioPlugin.audio
media.addEventListener('waiting', (event) => {
window.starvation.push({ trusted: event.isTrusted, time: media.currentTime, readyState: media.readyState, audioPaused: window.audioPlugin.audio.paused, videoTime: window.art.currentTime })
})
}, owner)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime > 0.3 && window.art.currentTime > 0.3)).toBe(true)
if (owner === 'video')
await page.evaluate(url => window.art.switchUrl(url), gate.url)
await expect.poll(() => page.evaluate(() => window.starvation.some(event => event.trusted && event.time > 0.3 && event.readyState < 3))).toBe(true)
expect(gate.blocked).toBeGreaterThan(0)
const stalled = await page.evaluate(() => ({ audio: window.audioPlugin.audio.currentTime, video: window.art.currentTime }))
if (owner === 'video') {
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
}
else {
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stalled.video + 0.3)
expect(await page.evaluate(() => window.art.video.paused)).toBe(false)
}
gate.release()
await expect.poll(() => page.evaluate(() => !window.audioPlugin.audio.paused && !window.art.video.paused)).toBe(true)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(stalled.audio + 0.3)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stalled.video + 0.3)
await expect.poll(() => page.evaluate(() => Math.abs(window.audioPlugin.audio.currentTime - window.art.currentTime))).toBeLessThan(0.5)
expect(await page.evaluate(() => [window.art.video.error, window.audioPlugin.audio.error])).toEqual([null, null])
}
finally {
try {
await testInfo.attach('media-gate-requests', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), bytes: bytes.length, limit, requests: gate.requests }) })
const events = page.isClosed() ? { closed: true } : await page.evaluate(() => window.starvation)
await testInfo.attach('media-gate', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), bytes: bytes.length, limit, requests: gate.requests, events }) })
if (!page.isClosed())
await page.evaluate(() => window.art?.destroy())
}
finally {
await gate.close()
}
}
})
}
}
}
+38 -7
View File
@@ -80,33 +80,64 @@ for (const core of ['published', 'candidate']) {
test(`${core} core + ${plugin} audio: native offset bounds recover to an in-range seek`, async ({ page }, testInfo) => {
await openAudio(page, core, plugin, testInfo)
await page.evaluate(() => {
window.boundaryAudio = new Audio('/test/audio-tone.m4a?boundary=native')
window.boundaryAudio.muted = true
document.querySelector('#play').onclick = () => Promise.all([window.art.play(), window.boundaryAudio.play()])
})
await expect.poll(() => page.evaluate(() => window.boundaryAudio.readyState)).toBeGreaterThanOrEqual(2)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => {
window.art.pause()
window.boundaryAudio.pause()
window.audioPlugin.update({ offset: -2 })
window.art.seek = 0.5
window.boundaryAudio.currentTime = -1.5
})
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking)).toBe(true)
const negative = await page.evaluate(() => ({ time: window.audioPlugin.audio.currentTime, error: window.audioPlugin.audio.error?.code || null }))
expect(negative).toEqual({ time: 0, error: null })
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking && !window.boundaryAudio.seeking)).toBe(true)
const negative = await page.evaluate(() => {
const state = audio => ({ time: audio.currentTime, error: audio.error?.code || null })
return { audio: state(window.audioPlugin.audio), native: state(window.boundaryAudio) }
})
await testInfo.attach('negative-offset-native', { contentType: 'application/json', body: JSON.stringify(negative) })
expect(negative.native.time).toBeGreaterThanOrEqual(0)
expect(negative.native.time).toBeCloseTo(0, 1)
expect(negative.audio.time).toBeGreaterThanOrEqual(0)
expect(negative.audio.time).toBeCloseTo(negative.native.time, 1)
expect([negative.audio.error, negative.native.error]).toEqual([null, null])
await page.evaluate(() => {
window.audioPlugin.update({ offset: 20 })
window.art.seek = 1
window.boundaryAudio.currentTime = 21
})
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking)).toBe(true)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBe(16)
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking && !window.boundaryAudio.seeking)).toBe(true)
const overflow = await page.evaluate(() => {
const state = audio => ({ time: audio.currentTime, duration: audio.duration, error: audio.error?.code || null })
return { audio: state(window.audioPlugin.audio), native: state(window.boundaryAudio) }
})
await testInfo.attach('overflow-offset-native', { contentType: 'application/json', body: JSON.stringify(overflow) })
expect(overflow.native.duration).toBe(16)
expect(overflow.native.time).toBeCloseTo(overflow.native.duration, 1)
expect(overflow.audio.time).toBeCloseTo(overflow.native.time, 1)
expect([overflow.audio.error, overflow.native.error]).toEqual([null, null])
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
await page.evaluate(() => {
window.audioPlugin.update({ offset: -0.5 })
window.art.seek = 3
window.boundaryAudio.currentTime = 2.5
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeCloseTo(2.5, 1)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(2.8)
await expect.poll(() => page.evaluate(() => Math.abs(window.audioPlugin.audio.currentTime - window.art.currentTime + 0.5))).toBeLessThan(0.5)
await testInfo.attach('offset-boundary', { contentType: 'application/json', body: JSON.stringify({ negative, overflow: 16, recovered: true }) })
await page.evaluate(() => window.art.destroy())
await testInfo.attach('offset-boundary', { contentType: 'application/json', body: JSON.stringify({ negative, overflow, recovered: true }) })
await page.evaluate(() => {
window.art.destroy()
window.boundaryAudio.pause()
window.boundaryAudio.removeAttribute('src')
window.boundaryAudio.load()
})
})
}
}
+2 -2
View File
@@ -72,8 +72,8 @@ export function useAudioFixture() {
plugins: [window.artplayerPluginAudioTrack({ url, sync: 0.1 })],
})
window.audioPlugin = window.art.plugins.artplayerPluginAudioTrack
for (const event of ['play', 'pause', 'seek', 'video:waiting', 'video:playing', 'video:seeking', 'video:seeked', 'video:pause', 'video:ended'])
window.art.on(event, () => window.audioHostEvents.push({ event, videoTime: window.art.currentTime, audioTime: window.audioPlugin.audio.currentTime, audioPaused: window.audioPlugin.audio.paused }))
for (const event of ['play', 'pause', 'seek', 'video:waiting', 'video:playing', 'video:canplay', 'video:seeking', 'video:seeked', 'video:pause', 'video:ended'])
window.art.on(event, () => window.audioHostEvents.push({ event, videoTime: window.art.currentTime, videoPaused: window.art.video.paused, videoReadyState: window.art.video.readyState, playing: window.art.playing, audioTime: window.audioPlugin.audio.currentTime, audioPaused: window.audioPlugin.audio.paused }))
for (const event of ['loadedmetadata', 'playing', 'pause', 'seeked', 'error', 'emptied'])
window.audioPlugin.audio.addEventListener(event, () => window.audioEvents.push({ event, time: window.audioPlugin.audio.currentTime, src: window.audioPlugin.audio.currentSrc }))
document.querySelector('#play').onclick = () => window.art.play()
+63
View File
@@ -0,0 +1,63 @@
import fs from 'node:fs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { createMediaGate } from '../helpers/media-gate.js'
import { expect, test } from './fixtures.js'
for (const owner of ['video', 'audio']) {
for (const completeRanges of [false, true]) {
test(`native ${owner} ${completeRanges ? 'complete ranges' : 'streamed response'} capability without ArtPlayer instances`, async ({ page }, testInfo) => {
const file = owner === 'video' ? 'pattern.mp4' : 'audio-tone.m4a'
const bytes = fs.readFileSync(new URL(`./media/${file}`, import.meta.url))
const limit = owner === 'video' ? 96 * 1024 : 32 * 1024
const gate = await createMediaGate(bytes, owner === 'video' ? 'video/mp4' : 'audio/mp4', limit, completeRanges)
try {
await page.goto('/test/player.html')
await page.evaluate(({ owner, url }) => {
window.nativeMedia = document.createElement(owner)
window.nativeMedia.preload = 'auto'
window.nativeMedia.muted = true
window.nativeEvents = []
for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'playing', 'waiting', 'stalled', 'pause', 'error'])
window.nativeMedia.addEventListener(event, e => window.nativeEvents.push({ event, trusted: e.isTrusted, time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState }))
window.nativeMedia.src = url
document.body.append(window.nativeMedia)
document.querySelector('#play').onclick = () => window.nativeMedia.play()
}, { owner, url: gate.url })
await expect.poll(() => page.evaluate(() => window.nativeMedia.readyState >= 2 || Boolean(window.nativeMedia.error))).toBe(true)
await page.locator('#play').click()
let progressed = false
let waiting = false
try {
await page.waitForFunction(() => window.nativeMedia.currentTime > 0.3, null, { timeout: 3000 })
progressed = true
await page.waitForFunction(() => window.nativeEvents.some(event => event.event === 'waiting' && event.trusted && event.time > 0.3 && event.readyState < 3), null, { timeout: 7000 })
waiting = true
}
catch (error) {
if (error.name !== 'TimeoutError')
throw error
}
const before = await page.evaluate(() => ({ time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState, paused: window.nativeMedia.paused, error: window.nativeMedia.error?.code || null, instances: window.Artplayer.instances.length, events: window.nativeEvents }))
expect(before.instances).toBe(0)
expect(gate.blocked).toBeGreaterThan(0)
gate.release()
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.nativeMedia.currentTime)).toBeGreaterThan(before.time + 0.3)
expect(await page.evaluate(() => window.nativeMedia.error)).toBeNull()
await testInfo.attach('native-gate-capability', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), limit, completeRanges, progressed, waiting, before, requests: gate.requests, after: await page.evaluate(() => ({ time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState, paused: window.nativeMedia.paused })) }) })
}
finally {
try {
await page.evaluate(() => {
window.nativeMedia?.pause()
window.nativeMedia?.removeAttribute('src')
window.nativeMedia?.load()
})
}
finally {
await gate.close()
}
}
})
}
}
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import http from 'node:http'
// Serves real media bytes, withholding the tail until a test observes native starvation.
export async function createMediaGate(bytes, contentType, limit, completeRanges = false) {
assert(Number.isSafeInteger(limit) && limit > 0 && limit < bytes.length)
const requests = []
const pending = new Map()
let released = false
const etag = `"${createHash('sha256').update(bytes).digest('hex')}"`
const server = http.createServer((req, res) => {
if (!['GET', 'HEAD'].includes(req.method) || !req.url.startsWith('/media.')) {
res.writeHead(404).end()
return
}
let start = 0
let end = bytes.length - 1
if (req.headers.range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range)
start = match?.[1] ? Number(match[1]) : Math.max(0, bytes.length - Number(match?.[2]))
end = match?.[1] && match[2] ? Math.min(bytes.length - 1, Number(match[2])) : bytes.length - 1
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || start > end || start >= bytes.length) {
res.writeHead(416, { 'Content-Range': `bytes */${bytes.length}` }).end()
return
}
res.statusCode = 206
if (completeRanges && !released && start < limit)
end = Math.min(end, limit - 1)
res.setHeader('Content-Range', `bytes ${start}-${end}/${bytes.length}`)
}
res.setHeader('Content-Type', contentType)
res.setHeader('Content-Length', end - start + 1)
res.setHeader('Accept-Ranges', 'bytes')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('ETag', etag)
res.setHeader('Access-Control-Allow-Origin', '*')
const record = { method: req.method, range: req.headers.range || null, start, end, sent: 0, wasHeld: false, closed: false }
requests.push(record)
res.on('close', () => {
record.closed = true
pending.delete(res)
})
res.on('error', (error) => {
record.error = error.message
})
if (req.method === 'HEAD') {
res.end()
return
}
const availableEnd = released ? end + 1 : Math.min(end + 1, limit)
const sentEnd = Math.max(start, availableEnd)
if (sentEnd > start) {
res.write(bytes.subarray(start, sentEnd))
record.sent += sentEnd - start
}
if (sentEnd > end) {
res.end()
}
else {
record.wasHeld = true
pending.set(res, { offset: sentEnd, end, record })
res.flushHeaders()
}
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
return {
url: `http://127.0.0.1:${address.port}/media.${contentType === 'video/mp4' ? 'mp4' : 'm4a'}`,
requests,
get blocked() { return pending.size },
release() {
released = true
for (const [res, { offset, end, record }] of pending) {
if (!res.destroyed) {
record.sent += end + 1 - offset
res.end(bytes.subarray(offset, end + 1))
}
}
pending.clear()
},
async close() {
server.closeAllConnections()
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
},
}
}
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
// eslint-disable-next-line test/no-import-node-test -- Test the real local HTTP fixture with Node's runner.
import test from 'node:test'
import { setImmediate } from 'node:timers/promises'
import { createMediaGate } from './helpers/media-gate.js'
test('media gate completes a bounded range and withholds the rest without altering bytes', async () => {
const bytes = Buffer.from('0123456789abcdef')
const gate = await createMediaGate(bytes, 'audio/mp4', 5, true)
try {
const first = await fetch(gate.url, { headers: { Range: 'bytes=0-' }, signal: AbortSignal.timeout(2000) })
assert.equal(first.status, 206)
assert.equal(first.headers.get('Content-Range'), 'bytes 0-4/16')
assert.equal(first.headers.get('Content-Length'), '5')
assert.deepEqual(Buffer.from(await first.arrayBuffer()), bytes.subarray(0, 5))
const tail = await fetch(gate.url, { headers: { Range: 'bytes=5-' }, signal: AbortSignal.timeout(2000) })
assert.equal(tail.status, 206)
assert.equal(tail.headers.get('ETag'), first.headers.get('ETag'))
assert.equal(gate.blocked, 1)
let finished = false
const remaining = tail.arrayBuffer().then((body) => {
finished = true
return Buffer.from(body)
})
await setImmediate()
assert.equal(finished, false)
gate.release()
assert.deepEqual(await remaining, bytes.subarray(5))
assert.equal(gate.blocked, 0)
assert(gate.requests.some(request => request.wasHeld))
}
finally {
await gate.close()
}
})
test('media gate streams a prefix within a full declared response and releases the remaining bytes', async () => {
const bytes = Buffer.from('0123456789abcdef')
const gate = await createMediaGate(bytes, 'audio/mp4', 5)
try {
const response = await fetch(gate.url, { headers: { Range: 'bytes=0-' }, signal: AbortSignal.timeout(2000) })
assert.equal(response.headers.get('Content-Range'), 'bytes 0-15/16')
assert.equal(response.headers.get('Content-Length'), '16')
const reader = response.body.getReader()
const first = await reader.read()
assert.deepEqual(Buffer.from(first.value), bytes.subarray(0, 5))
let finished = false
const tail = reader.read().then((value) => {
finished = true
return value
})
await setImmediate()
assert.equal(finished, false)
assert.equal(gate.blocked, 1)
gate.release()
assert.deepEqual(Buffer.from((await tail).value), bytes.subarray(5))
assert.equal((await reader.read()).done, true)
}
finally {
await gate.close()
}
})
test('media gate preserves HEAD and rejects invalid ranges before serving the released suffix', async () => {
const gate = await createMediaGate(Buffer.from('0123456789'), 'video/mp4', 4)
try {
const head = await fetch(gate.url, { method: 'HEAD' })
assert.equal(head.status, 200)
assert.equal(head.headers.get('Content-Length'), '10')
assert.equal(await head.text(), '')
assert.equal(gate.blocked, 0)
const invalid = await fetch(gate.url, { headers: { Range: 'bytes=20-' } })
assert.equal(invalid.status, 416)
assert.equal(invalid.headers.get('Content-Range'), 'bytes */10')
gate.release()
const suffix = await fetch(gate.url, { headers: { Range: 'bytes=-3' } })
assert.equal(suffix.headers.get('Content-Range'), 'bytes 7-9/10')
assert.equal(await suffix.text(), '789')
}
finally {
await gate.close()
}
})