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

@@ -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
},
}
}