mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
fix(audio): [PKG-AUDIO-05] checkpoint zero-time resume and native buffering evidence
This commit is contained in:
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)}}}}});
|
||||
@@ -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
@@ -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)}}}}});
|
||||
+1
-1
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,原六项候选核心回归保持原断言重跑全部通过。
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user