refactor(audio): [PKG-AUDIO-03] separate track ownership and native lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 02:22:13 +08:00
1 parent 803e87252e
commit 9c07ea830c
20 files changed
+12654 -250

No files matched your search

@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,o){"object"===typeof exports&&"undefined"!==typeof module?module.exports=o():"function"==typeof define&&define.amd?(t.artplayerPluginAudioTrack=o(),define(function(){return t.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=o()}(this,function(){"use strict";return function(e){return o=>{let{url:n,offset:a=0,sync:t=.3}=e;const u=new Audio;function i(){if(!o.video||!n)return;const e=o.currentTime+a;Math.abs(u.currentTime-e)>t&&(u.currentTime=e)}return u.preload="auto",n&&(u.src=n),o.on("play",()=>{n&&(i(),u.play().catch(e=>{console.warn(e)}))}),o.on("pause",()=>{u.pause()}),o.on("seek",()=>{i()}),o.on("video:timeupdate",()=>{o.playing&&i()}),o.on("video:ratechange",()=>{u.playbackRate=o.video.playbackRate}),o.on("video:volumechange",()=>{u.volume=o.volume,u.muted=o.muted}),o.on("video:waiting",()=>{u.pause()}),o.on("video:playing",()=>{n&&o.playing&&u.play().catch(e=>{console.warn(e)})}),o.on("destroy",()=>{u.pause(),u.src="",u.load()}),u.volume=o.volume,u.muted=o.muted,u.playbackRate=o.video?.playbackRate||1,{name:"artplayerPluginAudioTrack",audio:u,update:function(e){e.url&&e.url!==n&&(n=e.url,u.src=n,o.playing&&u.play().catch(e=>console.warn(e))),void 0!==e.offset&&(a=e.offset),void 0!==e.sync&&(t=e.sync)}}}}});
!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)}}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,o){"object"===typeof exports&&"undefined"!==typeof module?module.exports=o():"function"==typeof define&&define.amd?(t.artplayerPluginAudioTrack=o(),define(function(){return t.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=o()}(this,function(){"use strict";return function(e){return o=>{var n;let{url:a,offset:t=0,sync:u=.3}=e;const i=new Audio;function l(){if(!o.video||!a)return;const e=o.currentTime+t;Math.abs(i.currentTime-e)>u&&(i.currentTime=e)}return i.preload="auto",a&&(i.src=a),o.on("play",()=>{a&&(l(),i.play().catch(e=>{console.warn(e)}))}),o.on("pause",()=>{i.pause()}),o.on("seek",()=>{l()}),o.on("video:timeupdate",()=>{o.playing&&l()}),o.on("video:ratechange",()=>{i.playbackRate=o.video.playbackRate}),o.on("video:volumechange",()=>{i.volume=o.volume,i.muted=o.muted}),o.on("video:waiting",()=>{i.pause()}),o.on("video:playing",()=>{a&&o.playing&&i.play().catch(e=>{console.warn(e)})}),o.on("destroy",()=>{i.pause(),i.src="",i.load()}),i.volume=o.volume,i.muted=o.muted,i.playbackRate=(null==(n=o.video)?void 0:n.playbackRate)||1,{name:"artplayerPluginAudioTrack",audio:i,update:function(e){e.url&&e.url!==a&&(a=e.url,i.src=a,o.playing&&i.play().catch(e=>console.warn(e))),void 0!==e.offset&&(t=e.offset),void 0!==e.sync&&(u=e.sync)}}}}});
!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)}}}}});
+141 -65
View File
@@ -4,86 +4,162 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
function artplayerPluginAudioTrack(option) {
return (art) => {
let { url, offset = 0, sync = 0.3 } = option;
const audio = new Audio();
function createAudioTrack(option) {
let { url, offset = 0, sync = 0.3 } = option;
const audio = new Audio();
let closed = false;
function play() {
if (closed || !url)
return;
audio.play().catch((error) => {
if (!closed)
console.warn(error);
});
}
function pause() {
if (!closed)
audio.pause();
}
function destroy() {
if (closed)
return;
closed = true;
const failures = [];
for (const release of [() => audio.pause(), () => audio.removeAttribute("src"), () => audio.load()]) {
try {
release();
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
try {
audio.preload = "auto";
if (url) {
if (url)
audio.src = url;
} catch (error) {
try {
destroy();
} catch {
}
function syncAudio() {
if (!art.video || !url)
throw error;
}
return {
audio,
play,
pause,
destroy,
sync(time) {
if (closed || !url)
return;
const videoTime = art.currentTime;
const targetTime = videoTime + offset;
if (Math.abs(audio.currentTime - targetTime) > sync) {
audio.currentTime = targetTime;
}
}
art.on("play", () => {
if (!url)
const target = time + offset;
if (Math.abs(audio.currentTime - target) > sync)
audio.currentTime = target;
},
update(newOption, playing) {
if (closed)
return;
syncAudio();
audio.play().catch((err) => {
console.warn(err);
});
});
art.on("pause", () => {
audio.pause();
});
art.on("seek", () => {
syncAudio();
});
art.on("video:timeupdate", () => {
if (art.playing) {
syncAudio();
}
});
art.on("video:ratechange", () => {
audio.playbackRate = art.video.playbackRate;
});
art.on("video:volumechange", () => {
audio.volume = art.volume;
audio.muted = art.muted;
});
art.on("video:waiting", () => {
audio.pause();
});
art.on("video:playing", () => {
if (url && art.playing) {
audio.play().catch((err) => {
console.warn(err);
});
}
});
art.on("destroy", () => {
audio.pause();
audio.src = "";
audio.load();
});
function update(newOption) {
if (newOption.url && newOption.url !== url) {
url = newOption.url;
audio.src = url;
if (art.playing) {
audio.play().catch((err) => console.warn(err));
if (playing)
play();
}
if (newOption.offset !== void 0)
offset = newOption.offset;
if (newOption.sync !== void 0)
sync = newOption.sync;
}
};
}
function artplayerPluginAudioTrack(option) {
return (art) => {
const track = createAudioTrack(option);
const { audio } = track;
const subscriptions = [];
let active = true;
function syncAudio() {
if (art.video)
track.sync(art.currentTime);
}
function listen(event, callback) {
const listener = () => {
if (active)
callback();
};
subscriptions.push([event, listener]);
art.on(event, listener);
}
function destroy() {
if (!active)
return;
active = false;
const failures = [];
for (const [event, listener] of subscriptions.splice(0)) {
try {
art.off(event, listener);
} catch (error) {
failures.push(error);
}
}
if (newOption.offset !== void 0) {
offset = newOption.offset;
}
if (newOption.sync !== void 0) {
sync = newOption.sync;
try {
track.destroy();
} catch (error) {
failures.push(error);
}
if (failures.length)
throw failures[0];
}
try {
listen("play", () => {
syncAudio();
track.play();
});
for (const event of ["pause", "video:pause", "video:ended", "video:waiting", "video:emptied", "video:seeking"])
listen(event, track.pause);
listen("seek", syncAudio);
listen("video:seeked", () => {
syncAudio();
if (art.playing)
track.play();
});
listen("video:timeupdate", () => {
if (art.playing)
syncAudio();
});
listen("video:ratechange", () => {
audio.playbackRate = art.video.playbackRate;
});
listen("video:volumechange", () => {
audio.volume = art.volume;
audio.muted = art.muted;
});
listen("video:playing", () => {
if (art.playing) {
syncAudio();
track.play();
}
});
listen("destroy", destroy);
audio.volume = art.volume;
audio.muted = art.muted;
audio.playbackRate = art.video?.playbackRate || 1;
} catch (error) {
try {
destroy();
} catch {
}
throw error;
}
audio.volume = art.volume;
audio.muted = art.muted;
audio.playbackRate = art.video?.playbackRate || 1;
return {
name: "artplayerPluginAudioTrack",
audio,
update
update(newOption) {
if (active)
track.update(newOption, art.playing);
}
};
};
}
@@ -0,0 +1,58 @@
# Audio Track maintenance
The default factory in src/index.js keeps the public `(option) => (art) => result`
contract. The result exposes the same HTMLAudioElement for its entire lifetime, a
literal name, and synchronous update. Public declarations remain authored in types/
until the separate type migration; do not infer new public methods from internal helpers.
## Responsibilities
- src/index.js owns ArtPlayer subscriptions, reads host media/volume/rate, adapts public
update, and rolls back partial installation. It uses only on/off and existing media
properties, so it does not require new core lifecycle utilities.
- src/track.js creates the Audio element and owns URL/offset/threshold and closed state.
It handles synchronization, play rejection and source updates. It does not import ArtPlayer,
inspect DOM registries or install listeners. Dependencies flow from the entry to the track.
There are no custom timers, UI nodes, styles, workers or playback SDKs. Browser media
loading belongs to the exposed Audio element. Changing its URL preserves element identity.
The plugin does not mute the main video; consumers still choose their own mixing behavior.
## Events and lifetime
Public play synchronizes before audio playback. Native pause/ended/waiting/emptied/seeking
pause audio; seeked synchronizes and resumes only if the video is playing. Native playing
synchronizes again before resume. Ordinary playing timeupdate retains the strict drift
threshold and offset rule. Rate and volume/mute follow the existing core properties.
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.
Installation failures preserve the original error after attempting rollback. Saved callbacks
and update are inert after close, including when a host off method fails. Rejections from
an already closed track are consumed; active play failures still warn with the original error.
No promise is added to the public update return value.
URL truthiness/same-URL handling, defaults, deferred offset/sync update and exposed audio are
preserved. Negative offset start and duration boundaries are not redesigned in this lifecycle
change; their remaining media validation is tracked in refactor/audio-validation.md.
## Change and verify
Edit track.js for URL/sync/media ownership and index.js for host event handling. Keep
published-only defect observations in the tests when adding candidate regressions.
```sh
node --test test/audio-track.test.js
yarn build artplayer-plugin-audio-track
yarn test:browser test/browser/audio-track.spec.js --trace on
yarn ci:check
```
ARTPLAYER_TEST_AUDIO accepts platform-delimited built filenames for all three Node artifact
formats. ARTPLAYER_AUDIO_ARTIFACT selects one main/legacy browser build; otherwise tests compile
source. These are explicit local artifacts, not isolated installed npm packages.
The browser suite includes frozen old core/plugin combinations, real AAC/decoded video,
source failure recovery, and old/native pause/end plus corrected candidate cleanup.
See refactor/audio-validation.md for failed baseline probes, Windows WebKit WAV/size limits,
physical-device gaps and the separate 04/05/06 type/combination/distribution gates.
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,o){"object"===typeof exports&&"undefined"!==typeof module?module.exports=o():"function"==typeof define&&define.amd?(t.artplayerPluginAudioTrack=o(),define(function(){return t.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=o()}(this,function(){"use strict";return function(e){return o=>{let{url:n,offset:a=0,sync:t=.3}=e;const u=new Audio;function i(){if(!o.video||!n)return;const e=o.currentTime+a;Math.abs(u.currentTime-e)>t&&(u.currentTime=e)}return u.preload="auto",n&&(u.src=n),o.on("play",()=>{n&&(i(),u.play().catch(e=>{console.warn(e)}))}),o.on("pause",()=>{u.pause()}),o.on("seek",()=>{i()}),o.on("video:timeupdate",()=>{o.playing&&i()}),o.on("video:ratechange",()=>{u.playbackRate=o.video.playbackRate}),o.on("video:volumechange",()=>{u.volume=o.volume,u.muted=o.muted}),o.on("video:waiting",()=>{u.pause()}),o.on("video:playing",()=>{n&&o.playing&&u.play().catch(e=>{console.warn(e)})}),o.on("destroy",()=>{u.pause(),u.src="",u.load()}),u.volume=o.volume,u.muted=o.muted,u.playbackRate=o.video?.playbackRate||1,{name:"artplayerPluginAudioTrack",audio:u,update:function(e){e.url&&e.url!==n&&(n=e.url,u.src=n,o.playing&&u.play().catch(e=>console.warn(e))),void 0!==e.offset&&(a=e.offset),void 0!==e.sync&&(t=e.sync)}}}}});
!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)}}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,o){"object"===typeof exports&&"undefined"!==typeof module?module.exports=o():"function"==typeof define&&define.amd?(t.artplayerPluginAudioTrack=o(),define(function(){return t.artplayerPluginAudioTrack})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAudioTrack=o()}(this,function(){"use strict";return function(e){return o=>{var n;let{url:a,offset:t=0,sync:u=.3}=e;const i=new Audio;function l(){if(!o.video||!a)return;const e=o.currentTime+t;Math.abs(i.currentTime-e)>u&&(i.currentTime=e)}return i.preload="auto",a&&(i.src=a),o.on("play",()=>{a&&(l(),i.play().catch(e=>{console.warn(e)}))}),o.on("pause",()=>{i.pause()}),o.on("seek",()=>{l()}),o.on("video:timeupdate",()=>{o.playing&&l()}),o.on("video:ratechange",()=>{i.playbackRate=o.video.playbackRate}),o.on("video:volumechange",()=>{i.volume=o.volume,i.muted=o.muted}),o.on("video:waiting",()=>{i.pause()}),o.on("video:playing",()=>{a&&o.playing&&i.play().catch(e=>{console.warn(e)})}),o.on("destroy",()=>{i.pause(),i.src="",i.load()}),i.volume=o.volume,i.muted=o.muted,i.playbackRate=(null==(n=o.video)?void 0:n.playbackRate)||1,{name:"artplayerPluginAudioTrack",audio:i,update:function(e){e.url&&e.url!==a&&(a=e.url,i.src=a,o.playing&&i.play().catch(e=>console.warn(e))),void 0!==e.offset&&(t=e.offset),void 0!==e.sync&&(u=e.sync)}}}}});
!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)}}}}});
@@ -4,86 +4,162 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
function artplayerPluginAudioTrack(option) {
return (art) => {
let { url, offset = 0, sync = 0.3 } = option;
const audio = new Audio();
function createAudioTrack(option) {
let { url, offset = 0, sync = 0.3 } = option;
const audio = new Audio();
let closed = false;
function play() {
if (closed || !url)
return;
audio.play().catch((error) => {
if (!closed)
console.warn(error);
});
}
function pause() {
if (!closed)
audio.pause();
}
function destroy() {
if (closed)
return;
closed = true;
const failures = [];
for (const release of [() => audio.pause(), () => audio.removeAttribute("src"), () => audio.load()]) {
try {
release();
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
try {
audio.preload = "auto";
if (url) {
if (url)
audio.src = url;
} catch (error) {
try {
destroy();
} catch {
}
function syncAudio() {
if (!art.video || !url)
throw error;
}
return {
audio,
play,
pause,
destroy,
sync(time) {
if (closed || !url)
return;
const videoTime = art.currentTime;
const targetTime = videoTime + offset;
if (Math.abs(audio.currentTime - targetTime) > sync) {
audio.currentTime = targetTime;
}
}
art.on("play", () => {
if (!url)
const target = time + offset;
if (Math.abs(audio.currentTime - target) > sync)
audio.currentTime = target;
},
update(newOption, playing) {
if (closed)
return;
syncAudio();
audio.play().catch((err) => {
console.warn(err);
});
});
art.on("pause", () => {
audio.pause();
});
art.on("seek", () => {
syncAudio();
});
art.on("video:timeupdate", () => {
if (art.playing) {
syncAudio();
}
});
art.on("video:ratechange", () => {
audio.playbackRate = art.video.playbackRate;
});
art.on("video:volumechange", () => {
audio.volume = art.volume;
audio.muted = art.muted;
});
art.on("video:waiting", () => {
audio.pause();
});
art.on("video:playing", () => {
if (url && art.playing) {
audio.play().catch((err) => {
console.warn(err);
});
}
});
art.on("destroy", () => {
audio.pause();
audio.src = "";
audio.load();
});
function update(newOption) {
if (newOption.url && newOption.url !== url) {
url = newOption.url;
audio.src = url;
if (art.playing) {
audio.play().catch((err) => console.warn(err));
if (playing)
play();
}
if (newOption.offset !== void 0)
offset = newOption.offset;
if (newOption.sync !== void 0)
sync = newOption.sync;
}
};
}
function artplayerPluginAudioTrack(option) {
return (art) => {
const track = createAudioTrack(option);
const { audio } = track;
const subscriptions = [];
let active = true;
function syncAudio() {
if (art.video)
track.sync(art.currentTime);
}
function listen(event, callback) {
const listener = () => {
if (active)
callback();
};
subscriptions.push([event, listener]);
art.on(event, listener);
}
function destroy() {
if (!active)
return;
active = false;
const failures = [];
for (const [event, listener] of subscriptions.splice(0)) {
try {
art.off(event, listener);
} catch (error) {
failures.push(error);
}
}
if (newOption.offset !== void 0) {
offset = newOption.offset;
}
if (newOption.sync !== void 0) {
sync = newOption.sync;
try {
track.destroy();
} catch (error) {
failures.push(error);
}
if (failures.length)
throw failures[0];
}
try {
listen("play", () => {
syncAudio();
track.play();
});
for (const event of ["pause", "video:pause", "video:ended", "video:waiting", "video:emptied", "video:seeking"])
listen(event, track.pause);
listen("seek", syncAudio);
listen("video:seeked", () => {
syncAudio();
if (art.playing)
track.play();
});
listen("video:timeupdate", () => {
if (art.playing)
syncAudio();
});
listen("video:ratechange", () => {
audio.playbackRate = art.video.playbackRate;
});
listen("video:volumechange", () => {
audio.volume = art.volume;
audio.muted = art.muted;
});
listen("video:playing", () => {
if (art.playing) {
syncAudio();
track.play();
}
});
listen("destroy", destroy);
audio.volume = art.volume;
audio.muted = art.muted;
audio.playbackRate = art.video?.playbackRate || 1;
} catch (error) {
try {
destroy();
} catch {
}
throw error;
}
audio.volume = art.volume;
audio.muted = art.muted;
audio.playbackRate = art.video?.playbackRate || 1;
return {
name: "artplayerPluginAudioTrack",
audio,
update
update(newOption) {
if (active)
track.update(newOption, art.playing);
}
};
};
}
@@ -1,101 +1,95 @@
import { createAudioTrack } from './track'
export default function artplayerPluginAudioTrack(option) {
return (art) => {
let { url, offset = 0, sync = 0.3 } = option
const audio = new Audio()
audio.preload = 'auto'
if (url) {
audio.src = url
}
const track = createAudioTrack(option)
const { audio } = track
const subscriptions = []
let active = true
function syncAudio() {
if (!art.video || !url)
return
const videoTime = art.currentTime
const targetTime = videoTime + offset
if (Math.abs(audio.currentTime - targetTime) > sync) {
audio.currentTime = targetTime
}
if (art.video)
track.sync(art.currentTime)
}
art.on('play', () => {
if (!url)
return
syncAudio()
audio.play().catch((err) => {
console.warn(err)
})
})
art.on('pause', () => {
audio.pause()
})
art.on('seek', () => {
syncAudio()
})
art.on('video:timeupdate', () => {
if (art.playing) {
syncAudio()
function listen(event, callback) {
const listener = () => {
if (active)
callback()
}
})
subscriptions.push([event, listener])
art.on(event, listener)
}
art.on('video:ratechange', () => {
audio.playbackRate = art.video.playbackRate
})
function destroy() {
if (!active)
return
active = false
const failures = []
for (const [event, listener] of subscriptions.splice(0)) {
try {
art.off(event, listener)
}
catch (error) { failures.push(error) }
}
try {
track.destroy()
}
catch (error) { failures.push(error) }
if (failures.length)
throw failures[0]
}
art.on('video:volumechange', () => {
try {
listen('play', () => {
syncAudio()
track.play()
})
for (const event of ['pause', 'video:pause', 'video:ended', 'video:waiting', 'video:emptied', 'video:seeking'])
listen(event, track.pause)
listen('seek', syncAudio)
listen('video:seeked', () => {
syncAudio()
if (art.playing)
track.play()
})
listen('video:timeupdate', () => {
if (art.playing)
syncAudio()
})
listen('video:ratechange', () => {
audio.playbackRate = art.video.playbackRate
})
listen('video:volumechange', () => {
audio.volume = art.volume
audio.muted = art.muted
})
listen('video:playing', () => {
if (art.playing) {
syncAudio()
track.play()
}
})
listen('destroy', destroy)
audio.volume = art.volume
audio.muted = art.muted
})
art.on('video:waiting', () => {
audio.pause()
})
art.on('video:playing', () => {
if (url && art.playing) {
audio.play().catch((err) => {
console.warn(err)
})
}
})
art.on('destroy', () => {
audio.pause()
audio.src = ''
audio.load()
})
function update(newOption) {
if (newOption.url && newOption.url !== url) {
url = newOption.url
audio.src = url
if (art.playing) {
audio.play().catch(err => console.warn(err))
}
}
if (newOption.offset !== undefined) {
offset = newOption.offset
}
if (newOption.sync !== undefined) {
sync = newOption.sync
}
audio.playbackRate = art.video?.playbackRate || 1
}
catch (error) {
try {
destroy()
}
catch {}
throw error
}
audio.volume = art.volume
audio.muted = art.muted
audio.playbackRate = art.video?.playbackRate || 1
return {
name: 'artplayerPluginAudioTrack',
audio,
update,
update(newOption) {
if (active)
track.update(newOption, art.playing)
},
}
}
}
@@ -0,0 +1,75 @@
export function createAudioTrack(option) {
let { url, offset = 0, sync = 0.3 } = option
const audio = new Audio()
let closed = false
function play() {
if (closed || !url)
return
audio.play().catch((error) => {
if (!closed)
console.warn(error)
})
}
function pause() {
if (!closed)
audio.pause()
}
function destroy() {
if (closed)
return
closed = true
const failures = []
for (const release of [() => audio.pause(), () => audio.removeAttribute('src'), () => audio.load()]) {
try {
release()
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
try {
audio.preload = 'auto'
if (url)
audio.src = url
}
catch (error) {
try {
destroy()
}
catch {}
throw error
}
return {
audio,
play,
pause,
destroy,
sync(time) {
if (closed || !url)
return
const target = time + offset
if (Math.abs(audio.currentTime - target) > sync)
audio.currentTime = target
},
update(newOption, playing) {
if (closed)
return
if (newOption.url && newOption.url !== url) {
url = newOption.url
audio.src = url
if (playing)
play()
}
if (newOption.offset !== undefined)
offset = newOption.offset
if (newOption.sync !== undefined)
sync = newOption.sync
},
}
}
+11
View File
@@ -1,5 +1,16 @@
# Audio Track 行为与媒体测试
## PKG-AUDIO-03 候选修复
音频状态已从核心事件接入拆出,源码 44 项、加入三种产物共 89 项 Node 通过。
正式 main/legacy 各三引擎 42 项通过(均含原有 9 缺陷观察/3 原生诊断);CI 583 项通过。
原生暂停/结束/直接 seek、无错误媒体清理和销毁后 update 已有候选回归。
source 标签可由 ARTPLAYER_AUDIO_ARTIFACT 显式选择实际 main/legacy 文件;未设置时仍编译源码。
ARTPLAYER_TEST_AUDIO 接受平台分隔的三个 Node 产物路径,候选生命周期用例也逐格式执行。
具体输入、初轮六个错误 currentSrc 断言及最终证据见
[生命周期记录](baselines/audio-lifecycle-validation.json)。AUDIO-LIFE-01 按守卫/空 src 范围关闭,
AUDIO-SYNC-01 的边界/完整缓冲及设备验证继续 open。下面保留 02 当时的基线结果与问题描述。
PKG-AUDIO-02;公开来源见 [1.1.0 契约](baselines/audio-track-contract.md),
固定执行结果及失败保留见 [验证记录](baselines/audio-validation.json)。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
# PKG-AUDIO-03:音频同步与资源归属
状态:本步生命周期与结构验收完成;起点 803e8725。02 已冻结真实旧版缺陷。
将媒体状态/操作与 ArtPlayer 事件安装分开,保留公开工厂、同一 audio、同步 update。
修复原生暂停/结束后继续播放、销毁空 src 错误、销毁后的外部 update 和注册中断清理。
本步仍为 JS,04 单独完成严格 TS/兼容声明。负偏移起点等未完整验证项继续由 05 接续,
不为本次资源修复顺便改变偏移/空 URL/同 URL 更新语义。
## 实现和兼容
index.js 只负责读取核心状态、安装/释放订阅、公开 update 和失败回滚。
track.js 拥有 Audio、URL/offset/sync、关闭状态与媒体操作;不接收整个 art、不依赖新核心工具。
两个模块职责清楚,不为拆分额外创建纯转发模块。包内 ARCHITECTURE.md 给出实际入口、
资源归属、时序、错误规则、回归命令及未完成事项。
原生 video:pause/ended/waiting/emptied/seeking 暂停音频;seeked/playing 重新同步并按主视频
实际播放状态恢复。原公开 play/seek/timeupdate 及音量/倍率仍保留。
销毁先关闭入口、解绑所有自有订阅,再关闭媒体状态、pause/removeAttribute('src')/load。
每步清理独立尝试,正常销毁传播首个清理错误;初始化失败保留原始错误。
关闭后的 update/保留事件回调/待决播放拒绝不会重新写入资源或警告;活动状态拒绝仍警告原错误。
保持工厂必传对象、注册/返回同步、公开 name/audio/update、同一元素引用、无自动主视频静音、
空/同 URL 不重载、update 部分配置的运行时支持、默认值和严格漂移阈值。
没有更改公开 manifest/声明(与 1.1.0 冻结 LF 哈希相同)或新增运行依赖;JS/TS 类型不一致
由 04 处理。构建产物只通过正式 yarn build 生成,docs/compiled 三份与包内产物逐字节相同。
## 验证结果
- Source Node 专项 44 项;含候选三格式后 89 项,均通过。六组候选生命周期回归也在
main/legacy/ESM 执行,不只检查源码;旧版缺陷观察仍绑定旧 npm 归档。
- 完整 CI 583 项(540+11+32)、254 生产 TS 通过。本步两个音频 JS 文件未计为 TS 完成。
- main/legacy 各三引擎 42 项通过,无失败、重试或跳过;各包括 9 项旧缺陷观察、3 项原生诊断。
新增候选验证确认直接暂停/结束停止音频、直接 currentTime seek 同步、关闭后引用无效及无媒体错误。
- 正式构建通过;main 2102 字节/gzip 973,旧基线 1471/gzip 676。增加 297 gzip 字节用于
原生事件与资源守卫,不称体积优化。所有分发 SHA 与样本/运行输入见冻结记录。
- 定向 lint 通过;完整 CI 后仅修改浏览器清理断言,额外定向 lint 和两产物矩阵均通过。
计划/风险表和 diff 检查通过:216 项任务、126 项风险,无未解决事项豁免。
证据:[audio-lifecycle-validation.json](../baselines/audio-lifecycle-validation.json)。
首轮 36 通过、6 失败全部保留:测试错误要求只读 currentSrc 清空,而三个引擎的 audio
和核心 video 均已 src 属性缺失、paused=true、readyState/networkState=0、error=null,
currentSrc 仍保留旧值。改检查这些可观察资源状态,没有修改媒体源码、扩大等待或忽略错误。
这也明确限定清理保证:不会伪造浏览器只读属性。
## 后续与交付
AUDIO-LIFE-01 按已登记范围关闭;不声称所有设备没有泄漏。
AUDIO-SYNC-01 仅原生暂停/结束/seek 问题已修复,负偏移起点/时长边界、真实缓冲和连续切源
仍由 05 保持 open。WAV/WebKit、设备、安装包、类型、示例和版本门槛继续保留。
独立提交主题:`refactor(audio): [PKG-AUDIO-03] separate track ownership and native lifecycle`。
可回退本任务提交恢复旧生产行为及生成产物;不要回退旧发布基线或删除历史缺陷证据。
接续 PKG-AUDIO-04,把这两个职责模块迁移严格 TS,补兼容公开声明和编辑器;无 push/tag/publish/merge。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 216 项,范围 22 个包及工作区/示例。
状态:todo 148 / doing 1 / blocked 0 / done 67 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 147 / doing 1 / blocked 0 / done 68 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -157,7 +157,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| PKG-AUDIO-01 | artplayer-plugin-audio-track<br>核对包契约与历史用法 | BASE-05 | url/offset/sync、audio 实例暴露、update 与音量语义 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
| 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 | todo |
| 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 | todo |
| PKG-AUDIO-05 | artplayer-plugin-audio-track<br>验证新旧核心和组合 | PKG-AUDIO-04, CORE-22 | 主视频切源和缓冲恢复,多实例独立音频、旧核心 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-AUDIO-06 | artplayer-plugin-audio-track<br>验证分发并同步文档 | PKG-AUDIO-05, ENG-07 | audio.track.js 示例与运行返回值一致的声明 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
@@ -468,6 +468,7 @@
- PKG-CHAPTER-04: [记录](changes/2026-09-10-PKG-CHAPTER-04-public-types.md) [记录](baselines/chapter-types-validation.json)
- PKG-AUDIO-01: [记录](changes/2026-09-12-PKG-AUDIO-01-contract.md) [记录](baselines/audio-track-contract.md) [记录](baselines/audio-track-release.json)
- 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-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)
+13
View File
@@ -1,5 +1,18 @@
# 进度与证据
## 最新完成:PKG-AUDIO-03 源码拆分与生命周期修复
Audio Track 已拆为入口事件管理与独立媒体状态两个 JS 模块;修复原生暂停/结束后
外部音频继续播放、销毁空 src 错误和旧引用复活,补失败回滚与自有订阅清理。
公开 audio、同步 update、旧 URL/偏移/阈值语义保持,包内架构文档已同步。
44 项源码/89 项含三格式 Node 通过,CI 583 项;正式 main/legacy 三引擎各 42 项通过,
其中保留 9 项旧缺陷观察和 3 项原生诊断。三份 docs/compiled 与包产物一致。
首轮错误 currentSrc 断言及原始 trace 保留;main gzip 973(基线 676),不声称体积优化。
当前 216 项、68 done、1 doing、147 todo。Audio-02 已提交 803e8725,03 独立提交核实后
进入 04 严格 TS/公开类型;完整负偏移/缓冲、真机、分发门槛仍待后续,HLS 两风险仍 open。
详见 [源码交付](changes/2026-09-12-PKG-AUDIO-03-lifecycle.md) 与 [冻结证据](baselines/audio-lifecycle-validation.json)。
## 最新完成:PKG-AUDIO-02 行为与真实媒体基线
38 项 Node 契约覆盖源码和发布三格式,完整 CI 577 项、254 个生产 TS 通过。
+1 -1
View File
@@ -126,7 +126,7 @@
| HLS-ENV-01 | open / 已复现 | Windows WebKit 缺失 MSE,HLS 播放矩阵尚缺 Safari 证据 | PKG-HLS-05, REL-09, REVIEW-02 |
| HLS-CRASH-01 | open / 已复现 | Firefox Hls 1.5.17 worker 销毁可在原生 video 与候选核心复现崩溃 | PKG-HLS-SDK-01, PKG-HLS-05, REVIEW-02 |
| HLS-PLAYBACK-01 | open / 已复现 | Firefox 旧核心与 Hls 1.7.2 切组后视频档位未完成切换 | PKG-HLS-SDK-01, PKG-HLS-05, REVIEW-02 |
| AUDIO-LIFE-01 | open / 已复现 | Audio Track 销毁后 update 与媒体/订阅缺少终止守卫 | PKG-AUDIO-02, PKG-AUDIO-03, PKG-AUDIO-05 |
| AUDIO-LIFE-01 | resolved / 已复现 | Audio Track 销毁后 update 与媒体/订阅缺少终止守卫 | PKG-AUDIO-02, PKG-AUDIO-03, PKG-AUDIO-05 |
| AUDIO-SYNC-01 | open / 已复现 | Audio Track 未同步原生暂停/结束,偏移边界与切源/缓冲仍待完整验证 | PKG-AUDIO-02, PKG-AUDIO-03, PKG-AUDIO-05 |
| AUDIO-TYPE-01 | open / 源码/产物事实 | Audio Track update 部分配置与公开/编辑器声明不一致 | PKG-AUDIO-04 |
| AUDIO-DEMO-01 | open / 源码/产物事实 | Audio Track README 示例链接名称不匹配且缺维护说明 | PKG-AUDIO-06 |
+11 -4
View File
@@ -2648,7 +2648,7 @@
"id": "AUDIO-LIFE-01",
"title": "Audio Track 销毁后 update 与媒体/订阅缺少终止守卫",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-AUDIO-02",
"PKG-AUDIO-03",
@@ -2662,7 +2662,13 @@
"refactor/baselines/audio-validation.json"
],
"compatibleResolution": "保留公开 audio 和同步 update,封闭销毁后的资源写入并管理插件订阅,验证待决播放拒绝。",
"closureCriteria": "受控复现和新旧核心真实媒体切源/销毁验证通过,不以源码观察代替泄漏证据。"
"closureCriteria": "受控复现和新旧核心真实媒体切源/销毁验证通过,不以源码观察代替泄漏证据。",
"resolutionEvidence": [
"refactor/baselines/audio-lifecycle-validation.json",
"refactor/changes/2026-09-12-PKG-AUDIO-03-lifecycle.md",
"packages/artplayer-plugin-audio-track/ARCHITECTURE.md"
],
"resolutionRationale": "候选源码/三格式受控回归及 main/legacy 三引擎验证:插件自己解绑,关闭后的 update/回调/拒绝无资源写入,移除 src 后无新增媒体错误;安装/解绑失败仍释放。此关闭只覆盖已登记守卫/空 src 问题,不称全设备无内存泄漏。"
},
{
"id": "AUDIO-SYNC-01",
@@ -2679,9 +2685,10 @@
"refactor/baselines/audio-track-release.json",
"test/audio-track.test.js",
"test/browser/audio-track.spec.js",
"refactor/baselines/audio-validation.json"
"refactor/baselines/audio-validation.json",
"refactor/baselines/audio-lifecycle-validation.json"
],
"compatibleResolution": "固定严格阈值、偏移正负、倍率和音量语义;实测负目标/超时长及连续切源,按证据修复。",
"compatibleResolution": "PKG-AUDIO-03 已修复原生暂停/结束与直接 seek 同步;严格阈值、偏移正负、倍率/音量不变。PKG-AUDIO-05 继续负目标/超时长、真实缓冲和连续切源的完整验证,条目仍 open。",
"closureCriteria": "正常/边界/失败/缓冲恢复有媒体与受控断言,实际支持环境缺口明确处理。"
},
{
+5 -2
View File
@@ -1511,11 +1511,14 @@
"PKG-AUDIO-02",
"CORE-10"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "外部 audio 的同步策略、监听与源更新生命周期",
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
"evidence": []
"evidence": [
"changes/2026-09-12-PKG-AUDIO-03-lifecycle.md",
"baselines/audio-lifecycle-validation.json"
]
},
{
"id": "PKG-AUDIO-04",
+100 -1
View File
@@ -136,7 +136,7 @@ for (const { name, factory } of implementations) {
left.audio.calls.length = 0
right.audio.calls.length = 0
first.art.emit('destroy')
assert.deepEqual(left.audio.calls, [['pause'], ['src', ''], ['load']])
assert.deepEqual(left.audio.calls, [['pause'], name.startsWith('published') ? ['src', ''] : ['removeAttribute', 'src'], ['load']])
assert.deepEqual(right.audio.calls, [])
second.art.currentTime = 2
second.art.emit('seek')
@@ -145,6 +145,105 @@ 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}: closed callbacks and retained update cannot revive resources`, (t) => {
const { art, listeners } = audioHost(t)
const plugin = candidate({ url: 'audio.aac' })(art)
const retained = [...listeners.values()].flatMap(set => [...set])
art.emit('destroy')
assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 0)
plugin.audio.calls.length = 0
Object.defineProperty(art, 'playing', { get() {
throw new Error('Closed host read')
} })
assert.equal(plugin.update({ url: 'revived.aac' }), undefined)
for (const callback of retained)
callback()
assert.deepEqual(plugin.audio.calls, [])
})
test(`${name}: pending playback rejection becomes inert after destroy`, async (t) => {
const { art, setPlay, warnings } = audioHost(t)
let reject
const pending = new Promise((resolve, fail) => {
reject = fail
})
setPlay(() => pending)
candidate({ url: 'audio.aac' })(art)
art.emit('play').emit('destroy')
reject(new Error('Cancelled playback'))
await Promise.resolve()
assert.deepEqual(warnings, [])
})
test(`${name}: unsubscription failure does not prevent remaining cleanup`, (t) => {
const { art } = audioHost(t)
const plugin = candidate({ url: 'audio.aac' })(art)
const off = art.off
const failure = new Error('Controlled off failure')
t.mock.method(art, 'off', (event, callback) => {
if (event === 'play')
throw failure
return off(event, callback)
})
plugin.audio.calls.length = 0
assert.throws(() => art.emit('destroy'), error => error === failure)
assert.deepEqual(plugin.audio.calls, [['pause'], ['removeAttribute', 'src'], ['load']])
plugin.audio.calls.length = 0
art.emit('play')
plugin.update({ url: 'revived.aac' })
assert.deepEqual(plugin.audio.calls, [])
})
test(`${name}: partial subscription failure releases the media and original error`, (t) => {
const { art, instances, listeners } = audioHost(t)
const on = art.on
const failure = new Error('Controlled on failure')
t.mock.method(art, 'on', (event, callback) => {
on(event, callback)
if (event === 'pause')
throw failure
return art
})
assert.throws(() => candidate({ url: 'audio.aac' })(art), error => error === failure)
assert.deepEqual(instances[0].calls.slice(-3), [['pause'], ['removeAttribute', 'src'], ['load']])
assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 0)
})
test(`${name}: source initialization and pause failures still attempt media release`, (t) => {
const failure = new Error('Controlled src failure')
const { art, instances } = audioHost(t, (audio) => {
Object.defineProperty(audio, 'src', {
get: () => '',
set() {
throw failure
},
})
audio.pause = () => {
audio.calls.push(['pause'])
throw new Error('Secondary cleanup failure')
}
})
assert.throws(() => candidate({ url: 'audio.aac' })(art), error => error === failure)
assert.deepEqual(instances[0].calls.slice(-3), [['pause'], ['removeAttribute', 'src'], ['load']])
})
test(`${name}: native pause/end/seek synchronize audio without public pause calls`, (t) => {
const { art } = audioHost(t)
const { audio } = candidate({ url: 'audio.aac' })(art)
audio.calls.length = 0
art.emit('video:pause').emit('video:ended').emit('video:seeking')
assert.deepEqual(audio.calls, [['pause'], ['pause'], ['pause']])
art.currentTime = 3
art.emit('video:seeked')
assert.equal(audio.currentTime, 3)
art.playing = true
art.currentTime = 4
art.emit('video:playing')
assert.deepEqual(audio.calls.slice(-2), [['currentTime', 4], ['play']])
})
}
test('published defect AUDIO-LIFE-01: retained update reloads audio after destroy', (t) => {
const { art, listeners } = audioHost(t)
const plugin = historical({ url: 'audio.aac' })(art)
+29 -4
View File
@@ -37,8 +37,8 @@ test.beforeAll(async () => {
const bytes = readMember(await ensureArchive(release), member)
assert.equal(hash(bytes), release.files[member])
publishedCode = bytes.toString()
sourceCode = await compilePackage(release.name, 'umd')
evidence = { release, sourceSHA256: hash(sourceCode), audio: { sha256: hash(tone), bytes: tone.length, format: 'AAC mono 48000 Hz, 16 seconds, 440 Hz tone in MP4' }, wav: { sha256: hash(wav), bytes: wav.length, format: 'PCM16LE mono 16000 Hz, 16 seconds, integer square wave 200 Hz' }, scope: 'Source browser baseline; no physical device or installed package acceptance' }
sourceCode = process.env.ARTPLAYER_AUDIO_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_AUDIO_ARTIFACT, 'utf8') : await compilePackage(release.name, 'umd')
evidence = { release, sourceSHA256: hash(sourceCode), candidate: process.env.ARTPLAYER_AUDIO_ARTIFACT || 'workspace source build', audio: { sha256: hash(tone), bytes: tone.length, format: 'AAC mono 48000 Hz, 16 seconds, 440 Hz tone in MP4' }, wav: { sha256: hash(wav), bytes: wav.length, format: 'PCM16LE mono 16000 Hz, 16 seconds, integer square wave 200 Hz' }, scope: 'Source or explicit artifact browser checks; no physical device or installed package acceptance' }
})
test.afterEach(async ({ page }, testInfo) => {
@@ -116,6 +116,31 @@ test('published AUDIO-LIFE-01: empty src cleanup produces a native media error',
})
for (const core of ['published', 'candidate']) {
test(`${core} core + source audio: native pause/end stop audio and closed references stay inert`, async ({ page }, testInfo) => {
await openAudio(page, core, 'source', testInfo)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => window.art.video.pause())
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
await page.evaluate(() => {
window.art.video.currentTime = 4
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeCloseTo(4, 1)
await page.evaluate(() => {
window.art.seek = 7.8
window.art.play()
})
await expect.poll(() => page.evaluate(() => window.art.video.ended)).toBe(true)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.networkState)).toBe(0)
expect(await page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
expect(await page.evaluate(() => window.audioPlugin.audio.error)).toBeNull()
await page.evaluate(() => window.audioPlugin.update({ url: '/test/audio-tone.m4a?source=revived' }))
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBeNull()
expect(await page.evaluate(() => window.audioPlugin.audio.networkState)).toBe(0)
})
test(`${core} core + published audio: native pause and end leave external audio playing`, async ({ page }, testInfo) => {
await openAudio(page, core, 'published', testInfo)
await page.locator('#play').click()
@@ -181,7 +206,7 @@ for (const core of ['published', 'candidate']) {
window.art.destroy()
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe('')
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe(plugin === 'published' ? '' : null)
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
expect(await page.locator('.art-video-player').count()).toBe(0)
})
@@ -200,7 +225,7 @@ for (const core of ['published', 'candidate']) {
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.readyState)).toBe(0)
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe('')
expect(await page.evaluate(() => window.audioPlugin.audio.getAttribute('src'))).toBe(plugin === 'published' ? '' : null)
})
}
}
+31 -20
View File
@@ -1,10 +1,27 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import vm from 'node:vm'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { loadPackage } from './load.js'
async function readFactory(bytes, esm) {
if (esm)
return (await import(`data:text/javascript;base64,${Buffer.from(bytes).toString('base64')}`)).default
const context = {
module: { exports: {} },
Audio: function Audio() {
return new globalThis.Audio()
},
console,
}
context.exports = context.module.exports
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
return context.module.exports
}
export async function audioImplementations() {
const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/audio-track-release.json', import.meta.url)))
const archive = await ensureArchive(release)
@@ -13,29 +30,16 @@ export async function audioImplementations() {
const member = `package/${release.manifest[format].replace(/^\.\//, '')}`
const bytes = readMember(archive, member)
assert.equal(hash(bytes), release.files[member])
let factory
if (format === 'module') {
factory = (await import(`data:text/javascript;base64,${Buffer.from(bytes).toString('base64')}`)).default
}
else {
const context = {
module: { exports: {} },
Audio: function Audio() {
return new globalThis.Audio()
},
console,
}
context.exports = context.module.exports
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
factory = context.module.exports
}
const factory = await readFactory(bytes, format === 'module')
implementations.push({ name: `published-${format}`, factory })
}
for (const file of (process.env.ARTPLAYER_TEST_AUDIO || '').split(path.delimiter).filter(Boolean))
implementations.push({ name: `artifact-${path.basename(file)}`, factory: await readFactory(fs.readFileSync(file), file.endsWith('.mjs')) })
return implementations
}
// Records plugin intent only. It does not decode media or emulate browser timing.
export function audioHost(t) {
export function audioHost(t, initialize = () => {}) {
const instances = []
const warnings = []
let play = () => Promise.resolve()
@@ -43,17 +47,20 @@ export function audioHost(t) {
constructor() {
instances.push(this)
this.calls = []
this.values = new Map()
this.paused = true
for (const [name, initial] of Object.entries({ src: '', preload: '', currentTime: 0, playbackRate: 1, volume: 1, muted: false })) {
let value = initial
this.values.set(name, initial)
Object.defineProperty(this, name, {
get: () => value,
configurable: true,
get: () => this.values.get(name),
set: (next) => {
this.calls.push([name, next])
value = next
this.values.set(name, next)
},
})
}
initialize(this)
}
play() {
@@ -63,6 +70,10 @@ export function audioHost(t) {
pause() { this.calls.push(['pause']) }
load() { this.calls.push(['load']) }
removeAttribute(name) {
this.calls.push(['removeAttribute', name])
this.values.set(name, '')
}
}
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Audio')
Object.defineProperty(globalThis, 'Audio', { configurable: true, value: Audio })